⚠️ VeridianOS Kernel Documentation - This is low-level kernel code. All functions are unsafe unless explicitly marked otherwise. no_std

veridian_kernel/devtools/git/
commands.rs

1//! Git Porcelain Commands
2//!
3//! Implements user-facing git commands: init, add, commit, log, diff,
4//! branch, checkout, status.
5
6use alloc::{
7    collections::BTreeMap,
8    string::{String, ToString},
9    vec::Vec,
10};
11
12use super::{
13    objects::{Blob, Commit, GitObject, ObjectId, ObjectType, Person, Tree, TreeEntry},
14    refs::{RefStore, RefValue},
15};
16
17/// Index entry (staging area)
18#[derive(Debug, Clone)]
19pub(crate) struct IndexEntry {
20    pub(crate) path: String,
21    pub(crate) id: ObjectId,
22    pub(crate) mode: u32,
23    pub(crate) size: u64,
24}
25
26/// Git repository (in-memory)
27pub(crate) struct Repository {
28    /// Object store (id -> serialized data)
29    objects: BTreeMap<ObjectId, Vec<u8>>,
30    /// Reference store
31    pub(crate) refs: RefStore,
32    /// Index (staging area)
33    index: Vec<IndexEntry>,
34    /// Working directory path
35    pub(crate) workdir: String,
36    /// User configuration
37    pub(crate) config: GitConfig,
38}
39
40/// Git configuration
41#[derive(Debug, Clone)]
42pub(crate) struct GitConfig {
43    pub(crate) user_name: String,
44    pub(crate) user_email: String,
45}
46
47impl Default for GitConfig {
48    fn default() -> Self {
49        Self {
50            user_name: "VeridianOS User".to_string(),
51            user_email: "user@veridian.local".to_string(),
52        }
53    }
54}
55
56/// Diff hunk
57#[derive(Debug, Clone)]
58pub(crate) struct DiffHunk {
59    pub(crate) old_start: usize,
60    pub(crate) old_count: usize,
61    pub(crate) new_start: usize,
62    pub(crate) new_count: usize,
63    pub(crate) lines: Vec<DiffLine>,
64}
65
66/// Diff line
67#[derive(Debug, Clone)]
68pub(crate) enum DiffLine {
69    Context(String),
70    Added(String),
71    Removed(String),
72}
73
74/// Log entry for display
75#[derive(Debug, Clone)]
76pub(crate) struct LogEntry {
77    pub(crate) id: ObjectId,
78    pub(crate) author: String,
79    pub(crate) timestamp: u64,
80    pub(crate) message: String,
81}
82
83impl Repository {
84    /// Initialize a new repository
85    pub(crate) fn init(workdir: &str) -> Self {
86        Self {
87            objects: BTreeMap::new(),
88            refs: RefStore::new(),
89            index: Vec::new(),
90            workdir: workdir.to_string(),
91            config: GitConfig::default(),
92        }
93    }
94
95    /// Store a git object and return its ID
96    pub(crate) fn store_object(&mut self, obj: &GitObject) -> ObjectId {
97        let id = obj.compute_id();
98        self.objects.entry(id).or_insert_with(|| obj.serialize());
99        id
100    }
101
102    /// Retrieve a git object by ID
103    pub(crate) fn get_object(&self, id: &ObjectId) -> Option<GitObject> {
104        let data = self.objects.get(id)?;
105        GitObject::deserialize(data)
106    }
107
108    /// Check if an object exists
109    pub(crate) fn has_object(&self, id: &ObjectId) -> bool {
110        self.objects.contains_key(id)
111    }
112
113    /// Object count
114    pub(crate) fn object_count(&self) -> usize {
115        self.objects.len()
116    }
117
118    // -----------------------------------------------------------------------
119    // Porcelain commands
120    // -----------------------------------------------------------------------
121
122    /// `git add` -- stage a file
123    pub(crate) fn add(&mut self, path: &str, content: &[u8]) -> ObjectId {
124        let blob = Blob::new(content.to_vec());
125        let obj = blob.to_object();
126        let id = self.store_object(&obj);
127
128        // Update index
129        if let Some(entry) = self.index.iter_mut().find(|e| e.path == path) {
130            entry.id = id;
131            entry.size = content.len() as u64;
132        } else {
133            self.index.push(IndexEntry {
134                path: path.to_string(),
135                id,
136                mode: 0o100644,
137                size: content.len() as u64,
138            });
139        }
140
141        id
142    }
143
144    /// `git commit` -- create a commit from the index
145    pub(crate) fn commit(&mut self, message: &str) -> Option<ObjectId> {
146        if self.index.is_empty() {
147            return None;
148        }
149
150        // Build tree from index
151        let tree_id = self.build_tree_from_index();
152
153        // Create commit
154        let timestamp = 0; // Would use RTC in real implementation
155        let author = Person::new(&self.config.user_name, &self.config.user_email, timestamp);
156        let mut commit = Commit::new(tree_id, author, message);
157
158        // Set parent to current HEAD
159        if let Some(head_id) = self.refs.head() {
160            commit.parents.push(*head_id);
161        }
162
163        let obj = commit.to_object();
164        let commit_id = self.store_object(&obj);
165
166        // Update current branch
167        match self.refs.get("HEAD") {
168            Some(RefValue::Symbolic(target)) => {
169                let target = target.clone();
170                self.refs.set_direct(&target, commit_id);
171            }
172            _ => {
173                self.refs.set_direct("HEAD", commit_id);
174            }
175        }
176
177        Some(commit_id)
178    }
179
180    /// Build a tree object from the index
181    fn build_tree_from_index(&mut self) -> ObjectId {
182        let mut tree = Tree::new();
183
184        // Sort index entries
185        let mut sorted_index = self.index.clone();
186        sorted_index.sort_by(|a, b| a.path.cmp(&b.path));
187
188        for entry in &sorted_index {
189            // Simple: flat tree (no subdirectories)
190            tree.add_entry(TreeEntry::new(entry.mode, &entry.path, entry.id));
191        }
192
193        let obj = tree.to_object();
194        self.store_object(&obj)
195    }
196
197    /// `git log` -- walk commit history
198    pub(crate) fn log(&self, max_entries: usize) -> Vec<LogEntry> {
199        let mut entries = Vec::new();
200        let mut current = match self.refs.head() {
201            Some(id) => *id,
202            None => return entries,
203        };
204
205        for _ in 0..max_entries {
206            let obj = match self.get_object(&current) {
207                Some(o) if o.obj_type == ObjectType::Commit => o,
208                _ => break,
209            };
210
211            let commit = match Commit::from_data(&obj.data) {
212                Some(c) => c,
213                None => break,
214            };
215
216            entries.push(LogEntry {
217                id: current,
218                author: alloc::format!("{} <{}>", commit.author.name, commit.author.email),
219                timestamp: commit.author.timestamp,
220                message: commit.message.clone(),
221            });
222
223            // Follow first parent
224            current = match commit.parents.first() {
225                Some(p) => *p,
226                None => break,
227            };
228        }
229
230        entries
231    }
232
233    /// `git diff` -- compare two blobs (simple line diff)
234    pub(crate) fn diff_blobs(&self, old: &[u8], new: &[u8]) -> Vec<DiffHunk> {
235        let old_lines: Vec<&str> = core::str::from_utf8(old).unwrap_or("").lines().collect();
236        let new_lines: Vec<&str> = core::str::from_utf8(new).unwrap_or("").lines().collect();
237
238        // Simple LCS-based diff
239        let mut hunks = Vec::new();
240        let mut lines = Vec::new();
241        let mut old_idx = 0;
242        let mut new_idx = 0;
243
244        while old_idx < old_lines.len() || new_idx < new_lines.len() {
245            if old_idx < old_lines.len()
246                && new_idx < new_lines.len()
247                && old_lines[old_idx] == new_lines[new_idx]
248            {
249                lines.push(DiffLine::Context(old_lines[old_idx].to_string()));
250                old_idx += 1;
251                new_idx += 1;
252            } else if old_idx < old_lines.len()
253                && (new_idx >= new_lines.len()
254                    || !new_lines[new_idx..].contains(&old_lines[old_idx]))
255            {
256                lines.push(DiffLine::Removed(old_lines[old_idx].to_string()));
257                old_idx += 1;
258            } else if new_idx < new_lines.len() {
259                lines.push(DiffLine::Added(new_lines[new_idx].to_string()));
260                new_idx += 1;
261            }
262        }
263
264        if !lines.is_empty() {
265            hunks.push(DiffHunk {
266                old_start: 1,
267                old_count: old_lines.len(),
268                new_start: 1,
269                new_count: new_lines.len(),
270                lines,
271            });
272        }
273
274        hunks
275    }
276
277    /// `git branch` -- list or create branches
278    pub(crate) fn branch_list(&self) -> Vec<String> {
279        self.refs.branches()
280    }
281
282    pub(crate) fn branch_create(&mut self, name: &str) -> bool {
283        if let Some(head_id) = self.refs.head() {
284            let id = *head_id;
285            self.refs.create_branch(name, id);
286            true
287        } else {
288            false
289        }
290    }
291
292    pub(crate) fn branch_delete(&mut self, name: &str) -> bool {
293        let ref_name = alloc::format!("refs/heads/{}", name);
294        self.refs.delete(&ref_name)
295    }
296
297    /// `git checkout` -- switch branches
298    pub(crate) fn checkout(&mut self, branch: &str) -> bool {
299        self.refs.checkout_branch(branch)
300    }
301
302    /// `git status` -- show index and working tree status
303    pub(crate) fn status(&self) -> Vec<String> {
304        let mut lines = Vec::new();
305
306        if let Some(branch) = self.refs.current_branch() {
307            lines.push(alloc::format!("On branch {}", branch));
308        } else {
309            lines.push("HEAD detached".to_string());
310        }
311
312        if self.index.is_empty() {
313            lines.push("nothing to commit".to_string());
314        } else {
315            lines.push(alloc::format!(
316                "Changes to be committed ({} files):",
317                self.index.len()
318            ));
319            for entry in &self.index {
320                lines.push(alloc::format!("  new file: {}", entry.path));
321            }
322        }
323
324        lines
325    }
326
327    /// `git tag` -- create a lightweight tag
328    pub(crate) fn tag_create(&mut self, name: &str) -> bool {
329        if let Some(head_id) = self.refs.head() {
330            let id = *head_id;
331            self.refs.create_tag(name, id);
332            true
333        } else {
334            false
335        }
336    }
337
338    /// Get index entry count
339    pub(crate) fn index_count(&self) -> usize {
340        self.index.len()
341    }
342}
343
344// ---------------------------------------------------------------------------
345// Tests
346// ---------------------------------------------------------------------------
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351
352    #[test]
353    fn test_repository_init() {
354        let repo = Repository::init("/tmp/test");
355        assert_eq!(repo.workdir, "/tmp/test");
356        assert_eq!(repo.object_count(), 0);
357        assert_eq!(repo.index_count(), 0);
358    }
359
360    #[test]
361    fn test_add_file() {
362        let mut repo = Repository::init("/tmp/test");
363        let id = repo.add("hello.txt", b"Hello, World!\n");
364        assert_ne!(id, ObjectId::ZERO);
365        assert_eq!(repo.index_count(), 1);
366        assert!(repo.has_object(&id));
367    }
368
369    #[test]
370    fn test_add_overwrites_same_path() {
371        let mut repo = Repository::init("/tmp/test");
372        repo.add("file.txt", b"v1");
373        repo.add("file.txt", b"v2");
374        assert_eq!(repo.index_count(), 1);
375    }
376
377    #[test]
378    fn test_commit() {
379        let mut repo = Repository::init("/tmp/test");
380        repo.add("file.txt", b"content");
381        let id = repo.commit("Initial commit");
382        assert!(id.is_some());
383
384        let obj = repo.get_object(&id.unwrap()).unwrap();
385        assert_eq!(obj.obj_type, ObjectType::Commit);
386    }
387
388    #[test]
389    fn test_commit_empty_index() {
390        let mut repo = Repository::init("/tmp/test");
391        assert!(repo.commit("Empty").is_none());
392    }
393
394    #[test]
395    fn test_commit_chain() {
396        let mut repo = Repository::init("/tmp/test");
397        repo.add("a.txt", b"a");
398        let first = repo.commit("First").unwrap();
399
400        repo.add("b.txt", b"b");
401        let second = repo.commit("Second").unwrap();
402
403        // Second commit should have first as parent
404        let obj = repo.get_object(&second).unwrap();
405        let commit = Commit::from_data(&obj.data).unwrap();
406        assert_eq!(commit.parents.len(), 1);
407        assert_eq!(commit.parents[0], first);
408    }
409
410    #[test]
411    fn test_log() {
412        let mut repo = Repository::init("/tmp/test");
413        repo.add("a.txt", b"a");
414        repo.commit("First commit");
415        repo.add("b.txt", b"b");
416        repo.commit("Second commit");
417
418        let log = repo.log(10);
419        assert_eq!(log.len(), 2);
420        assert!(log[0].message.contains("Second commit"));
421        assert!(log[1].message.contains("First commit"));
422    }
423
424    #[test]
425    fn test_log_empty() {
426        let repo = Repository::init("/tmp/test");
427        let log = repo.log(10);
428        assert!(log.is_empty());
429    }
430
431    #[test]
432    fn test_diff_blobs() {
433        let repo = Repository::init("/tmp/test");
434        let old = b"line1\nline2\nline3\n";
435        let new = b"line1\nmodified\nline3\n";
436        let hunks = repo.diff_blobs(old, new);
437        assert!(!hunks.is_empty());
438    }
439
440    #[test]
441    fn test_diff_identical() {
442        let repo = Repository::init("/tmp/test");
443        let content = b"same\n";
444        let hunks = repo.diff_blobs(content, content);
445        // All context lines, still produces a hunk
446        assert!(!hunks.is_empty());
447    }
448
449    #[test]
450    fn test_branch_operations() {
451        let mut repo = Repository::init("/tmp/test");
452        repo.add("file.txt", b"content");
453        repo.commit("Initial");
454
455        assert!(repo.branch_create("dev"));
456        let branches = repo.branch_list();
457        assert!(branches.contains(&"main".to_string()));
458        assert!(branches.contains(&"dev".to_string()));
459    }
460
461    #[test]
462    fn test_checkout() {
463        let mut repo = Repository::init("/tmp/test");
464        repo.add("file.txt", b"content");
465        repo.commit("Initial");
466        repo.branch_create("dev");
467
468        assert!(repo.checkout("dev"));
469        assert_eq!(repo.refs.current_branch(), Some("dev"));
470    }
471
472    #[test]
473    fn test_checkout_nonexistent() {
474        let mut repo = Repository::init("/tmp/test");
475        assert!(!repo.checkout("nonexistent"));
476    }
477
478    #[test]
479    fn test_branch_delete() {
480        let mut repo = Repository::init("/tmp/test");
481        repo.add("file.txt", b"content");
482        repo.commit("Initial");
483        repo.branch_create("temp");
484        assert!(repo.branch_delete("temp"));
485        assert!(!repo.branch_delete("temp"));
486    }
487
488    #[test]
489    fn test_status_empty() {
490        let repo = Repository::init("/tmp/test");
491        let status = repo.status();
492        assert!(status.iter().any(|s| s.contains("nothing to commit")));
493    }
494
495    #[test]
496    fn test_status_with_staged() {
497        let mut repo = Repository::init("/tmp/test");
498        repo.add("file.txt", b"content");
499        let status = repo.status();
500        assert!(status.iter().any(|s| s.contains("1 files")));
501    }
502
503    #[test]
504    fn test_tag_create() {
505        let mut repo = Repository::init("/tmp/test");
506        repo.add("file.txt", b"content");
507        repo.commit("Release");
508        assert!(repo.tag_create("v1.0"));
509        assert!(repo.refs.tags().contains(&"v1.0".to_string()));
510    }
511
512    #[test]
513    fn test_config_default() {
514        let config = GitConfig::default();
515        assert!(!config.user_name.is_empty());
516        assert!(!config.user_email.is_empty());
517    }
518
519    #[test]
520    fn test_store_and_retrieve_object() {
521        let mut repo = Repository::init("/tmp/test");
522        let obj = GitObject::new(ObjectType::Blob, b"hello".to_vec());
523        let id = repo.store_object(&obj);
524        let retrieved = repo.get_object(&id).unwrap();
525        assert_eq!(retrieved.obj_type, ObjectType::Blob);
526        assert_eq!(&retrieved.data, b"hello");
527    }
528}