Skip to main content

nap_core/
vcs_git.rs

1//! Git backend implementation via shell commands.
2//!
3//! For v0, we use git CLI commands via `std::process::Command` for reliability
4//! and simplicity. The `gix` crate is excellent but its API is large and
5//! evolving — shelling out to git gives us battle-tested behavior with
6//! minimal code for the prototype.
7//!
8//! Every git operation includes verbose tracing for debug visibility.
9
10use std::path::Path;
11use std::process::Command;
12
13use tracing::{debug, trace};
14
15use crate::error::NapError;
16use crate::vcs::{CommitInfo, VcsBackend};
17
18/// Git-backed VCS implementation.
19#[derive(Debug, Default)]
20pub struct GitBackend;
21
22impl GitBackend {
23    pub fn new() -> Self {
24        Self
25    }
26
27    /// Clone a remote repository to a local directory.
28    ///
29    /// This is a standalone operation (not on `VcsBackend`) because it
30    /// creates a new repository rather than operating on an existing one.
31    /// The `dest` directory must not already exist.
32    pub fn clone_repo(url: &str, dest: &Path) -> Result<(), NapError> {
33        debug!(url = %url, dest = %dest.display(), "cloning git repository");
34        let parent = dest.parent().unwrap_or(Path::new("."));
35        let dest_name = dest.file_name().and_then(|n| n.to_str()).unwrap_or(".");
36        Self::run_git(parent, &["clone", url, dest_name])?;
37        Ok(())
38    }
39
40    /// Run a git command and return stdout. Logs the command and output at trace level.
41    fn run_git(path: &Path, args: &[&str]) -> Result<String, NapError> {
42        let args_display = args.join(" ");
43        trace!(
44            cwd = %path.display(),
45            command = %format!("git {args_display}"),
46            "executing git command"
47        );
48
49        let output = Command::new("git")
50            .args(args)
51            .current_dir(path)
52            .output()
53            .map_err(|e| {
54                NapError::VcsError(format!("failed to execute git {args_display}: {e}"))
55            })?;
56
57        let stdout = String::from_utf8_lossy(&output.stdout).to_string();
58        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
59
60        if !output.status.success() {
61            debug!(
62                command = %format!("git {args_display}"),
63                stderr = %stderr,
64                exit_code = ?output.status.code(),
65                "git command failed"
66            );
67            return Err(NapError::VcsError(format!(
68                "git {args_display} failed: {stderr}"
69            )));
70        }
71
72        trace!(
73            command = %format!("git {args_display}"),
74            stdout_len = stdout.len(),
75            "git command succeeded"
76        );
77        Ok(stdout.trim().to_string())
78    }
79}
80
81impl VcsBackend for GitBackend {
82    fn init(&self, path: &Path) -> Result<(), NapError> {
83        debug!(path = %path.display(), "initializing git repository");
84        std::fs::create_dir_all(path)?;
85        Self::run_git(path, &["init"])?;
86        // Set default branch to "main"
87        Self::run_git(path, &["checkout", "-b", "main"]).ok();
88        // Configure user for commits (local to this repo)
89        Self::run_git(path, &["config", "user.email", "nap@cinematiccanvas.com"])?;
90        Self::run_git(path, &["config", "user.name", "NAP"])?;
91        debug!(path = %path.display(), "git repository initialized");
92        Ok(())
93    }
94
95    fn commit(&self, path: &Path, message: &str, author: &str) -> Result<String, NapError> {
96        debug!(
97            path = %path.display(),
98            message = %message,
99            author = %author,
100            "creating git commit"
101        );
102
103        // Stage all changes
104        Self::run_git(path, &["add", "-A"])?;
105
106        // Check if there are staged changes
107        let status = Self::run_git(path, &["status", "--porcelain"])?;
108        if status.is_empty() {
109            return Err(NapError::VcsError("nothing to commit".to_string()));
110        }
111
112        // Commit with author override
113        let author_str = format!("{author} <{author}@nap>");
114        Self::run_git(path, &["commit", "-m", message, "--author", &author_str])?;
115
116        // Return the commit hash
117        let hash = Self::run_git(path, &["rev-parse", "HEAD"])?;
118        debug!(commit_hash = %hash, "git commit created");
119        Ok(hash)
120    }
121
122    fn read_file_at_ref(
123        &self,
124        repo_path: &Path,
125        file_path: &str,
126        reference: Option<&str>,
127    ) -> Result<String, NapError> {
128        match reference {
129            Some(git_ref) => {
130                trace!(
131                    repo = %repo_path.display(),
132                    file = %file_path,
133                    git_ref = %git_ref,
134                    "reading file at ref"
135                );
136                let spec = format!("{git_ref}:{file_path}");
137                Self::run_git(repo_path, &["show", &spec])
138            }
139            None => {
140                trace!(
141                    repo = %repo_path.display(),
142                    file = %file_path,
143                    "reading file from working tree"
144                );
145                let full_path = repo_path.join(file_path);
146                std::fs::read_to_string(&full_path).map_err(|e| {
147                    NapError::ManifestNotFound(format!("{}: {e}", full_path.display()))
148                })
149            }
150        }
151    }
152
153    fn log(
154        &self,
155        path: &Path,
156        file: Option<&str>,
157        limit: usize,
158    ) -> Result<Vec<CommitInfo>, NapError> {
159        let limit_str = format!("-{limit}");
160        let format_flag = "--format=%H%n%P%n%an%n%s%n%aI%n---".to_string();
161
162        let mut args = vec!["log", &limit_str, &format_flag];
163        if let Some(file_path) = file {
164            args.push("--");
165            args.push(file_path);
166        }
167
168        let output = Self::run_git(path, &args)?;
169        if output.is_empty() {
170            return Ok(vec![]);
171        }
172
173        let mut commits = Vec::new();
174        for entry in output.split("---\n") {
175            let entry = entry.trim();
176            if entry.is_empty() {
177                continue;
178            }
179            let lines: Vec<&str> = entry.lines().collect();
180            if lines.len() >= 5 {
181                commits.push(CommitInfo {
182                    id: lines[0].to_string(),
183                    parent: if lines[1].is_empty() {
184                        None
185                    } else {
186                        Some(lines[1].split_whitespace().next().unwrap_or("").to_string())
187                    },
188                    author: lines[2].to_string(),
189                    message: lines[3].to_string(),
190                    timestamp: lines[4].to_string(),
191                });
192            }
193        }
194
195        Ok(commits)
196    }
197
198    fn create_branch(&self, path: &Path, name: &str) -> Result<(), NapError> {
199        debug!(path = %path.display(), branch = %name, "creating git branch");
200        Self::run_git(path, &["branch", name])?;
201        Ok(())
202    }
203
204    fn switch_branch(&self, path: &Path, name: &str) -> Result<(), NapError> {
205        debug!(path = %path.display(), branch = %name, "switching git branch");
206        Self::run_git(path, &["checkout", name])?;
207        Ok(())
208    }
209
210    fn create_tag(&self, path: &Path, name: &str) -> Result<(), NapError> {
211        debug!(path = %path.display(), tag = %name, "creating git tag");
212        Self::run_git(path, &["tag", name])?;
213        Ok(())
214    }
215
216    fn current_branch(&self, path: &Path) -> Result<String, NapError> {
217        Self::run_git(path, &["rev-parse", "--abbrev-ref", "HEAD"])
218    }
219
220    fn head_hash(&self, path: &Path) -> Result<String, NapError> {
221        Self::run_git(path, &["rev-parse", "HEAD"])
222    }
223
224    fn list_branches(&self, path: &Path) -> Result<Vec<String>, NapError> {
225        let output = Self::run_git(path, &["branch", "--format=%(refname:short)"])?;
226        Ok(output
227            .lines()
228            .map(|l| l.trim().to_string())
229            .filter(|l| !l.is_empty())
230            .collect())
231    }
232
233    fn list_tags(&self, path: &Path) -> Result<Vec<String>, NapError> {
234        let output = Self::run_git(path, &["tag", "--list"])?;
235        Ok(output
236            .lines()
237            .map(|l| l.trim().to_string())
238            .filter(|l| !l.is_empty())
239            .collect())
240    }
241
242    // ── Remote operations ────────────────────────────────────────
243
244    fn add_remote(&self, path: &Path, name: &str, url: &str) -> Result<(), NapError> {
245        debug!(
246            path = %path.display(),
247            remote = %name,
248            url = %url,
249            "adding git remote"
250        );
251        Self::run_git(path, &["remote", "add", name, url])?;
252        Ok(())
253    }
254
255    fn remove_remote(&self, path: &Path, name: &str) -> Result<(), NapError> {
256        debug!(
257            path = %path.display(),
258            remote = %name,
259            "removing git remote"
260        );
261        Self::run_git(path, &["remote", "remove", name])?;
262        Ok(())
263    }
264
265    fn list_remotes(&self, path: &Path) -> Result<Vec<(String, String)>, NapError> {
266        debug!(path = %path.display(), "listing git remotes");
267        let output = Self::run_git(path, &["remote", "-v"])?;
268        let mut remotes = Vec::new();
269        for line in output.lines() {
270            let line = line.trim();
271            if line.is_empty() {
272                continue;
273            }
274            // Format: "origin\tgit@github.com:user/repo.git (fetch)"
275            if let Some(tab_pos) = line.find('\t') {
276                let name = line[..tab_pos].to_string();
277                let rest = &line[tab_pos + 1..];
278                if let Some(space_pos) = rest.rfind(" (") {
279                    let url = rest[..space_pos].to_string();
280                    // Only add once per remote (skip the (push) entry)
281                    if !remotes.iter().any(|(n, _): &(String, String)| n == &name) {
282                        remotes.push((name, url));
283                    }
284                }
285            }
286        }
287        Ok(remotes)
288    }
289
290    fn push(
291        &self,
292        path: &Path,
293        remote: Option<&str>,
294        branch: Option<&str>,
295    ) -> Result<(), NapError> {
296        let remote_display = remote.unwrap_or("origin");
297        let branch_display = branch.unwrap_or("current");
298        debug!(
299            path = %path.display(),
300            remote = %remote_display,
301            branch = %branch_display,
302            "pushing to git remote"
303        );
304
305        let mut args = vec!["push"];
306        if let Some(r) = remote {
307            args.push(r);
308        }
309        if let Some(b) = branch {
310            args.push(b);
311        }
312
313        Self::run_git(path, &args)?;
314        Ok(())
315    }
316
317    fn pull(
318        &self,
319        path: &Path,
320        remote: Option<&str>,
321        branch: Option<&str>,
322    ) -> Result<(), NapError> {
323        let remote_display = remote.unwrap_or("origin");
324        let branch_display = branch.unwrap_or("current");
325        debug!(
326            path = %path.display(),
327            remote = %remote_display,
328            branch = %branch_display,
329            "pulling from git remote"
330        );
331
332        let mut args = vec!["pull"];
333        if let Some(r) = remote {
334            args.push(r);
335        }
336        if let Some(b) = branch {
337            args.push(b);
338        }
339
340        Self::run_git(path, &args)?;
341        Ok(())
342    }
343
344    fn revert(&self, path: &Path, commit_hash: &str) -> Result<String, NapError> {
345        debug!(
346            path = %path.display(),
347            commit = %commit_hash,
348            "reverting git commit"
349        );
350
351        // Git revert requires a clean working tree.  NAP's design leaves
352        // head-pointer updates uncommitted.  Stash them, do the revert,
353        // then drop the stash — the caller (Repository::revert_commit)
354        // regenerates fresh head pointers afterward.
355        let status = Self::run_git(path, &["status", "--porcelain"])?;
356        let had_dirty = !status.is_empty();
357        if had_dirty {
358            Self::run_git(path, &["stash", "push", "-m", "nap-revert-stash"])?;
359        }
360
361        Self::run_git(path, &["revert", "--no-edit", commit_hash])?;
362
363        // Discard the stash — the caller will regenerate head pointers
364        if had_dirty {
365            Self::run_git(path, &["stash", "drop"]).ok();
366        }
367
368        let hash = Self::run_git(path, &["rev-parse", "HEAD"])?;
369        debug!(revert_commit = %hash, "git revert created");
370        Ok(hash)
371    }
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377    use tempfile::TempDir;
378
379    #[test]
380    fn test_git_init_and_commit() {
381        let tmp = TempDir::new().unwrap();
382        let backend = GitBackend::new();
383
384        // Init repo
385        backend.init(tmp.path()).unwrap();
386
387        // Create a file
388        std::fs::write(tmp.path().join("test.txt"), "hello").unwrap();
389
390        // Commit
391        let hash = backend
392            .commit(tmp.path(), "initial commit", "test-user")
393            .unwrap();
394        assert!(!hash.is_empty());
395
396        // Verify HEAD
397        let head = backend.head_hash(tmp.path()).unwrap();
398        assert_eq!(hash, head);
399    }
400
401    #[test]
402    fn test_git_read_file_at_ref() {
403        let tmp = TempDir::new().unwrap();
404        let backend = GitBackend::new();
405        backend.init(tmp.path()).unwrap();
406
407        // Commit v1
408        std::fs::write(tmp.path().join("data.txt"), "version 1").unwrap();
409        let hash_v1 = backend.commit(tmp.path(), "v1", "user").unwrap();
410
411        // Commit v2
412        std::fs::write(tmp.path().join("data.txt"), "version 2").unwrap();
413        backend.commit(tmp.path(), "v2", "user").unwrap();
414
415        // Read current (v2)
416        let current = backend
417            .read_file_at_ref(tmp.path(), "data.txt", None)
418            .unwrap();
419        assert_eq!(current, "version 2");
420
421        // Read at v1
422        let at_v1 = backend
423            .read_file_at_ref(tmp.path(), "data.txt", Some(&hash_v1))
424            .unwrap();
425        assert_eq!(at_v1, "version 1");
426    }
427
428    #[test]
429    fn test_git_log() {
430        let tmp = TempDir::new().unwrap();
431        let backend = GitBackend::new();
432        backend.init(tmp.path()).unwrap();
433
434        std::fs::write(tmp.path().join("a.txt"), "a").unwrap();
435        backend.commit(tmp.path(), "first", "user").unwrap();
436
437        std::fs::write(tmp.path().join("b.txt"), "b").unwrap();
438        backend.commit(tmp.path(), "second", "user").unwrap();
439
440        let log = backend.log(tmp.path(), None, 10).unwrap();
441        assert_eq!(log.len(), 2);
442        assert_eq!(log[0].message, "second");
443        assert_eq!(log[1].message, "first");
444    }
445
446    #[test]
447    fn test_git_branches() {
448        let tmp = TempDir::new().unwrap();
449        let backend = GitBackend::new();
450        backend.init(tmp.path()).unwrap();
451
452        std::fs::write(tmp.path().join("init.txt"), "init").unwrap();
453        backend.commit(tmp.path(), "init", "user").unwrap();
454
455        backend.create_branch(tmp.path(), "canon").unwrap();
456        let branches = backend.list_branches(tmp.path()).unwrap();
457        assert!(branches.contains(&"main".to_string()));
458        assert!(branches.contains(&"canon".to_string()));
459    }
460
461    #[test]
462    fn test_git_tags() {
463        let tmp = TempDir::new().unwrap();
464        let backend = GitBackend::new();
465        backend.init(tmp.path()).unwrap();
466
467        std::fs::write(tmp.path().join("init.txt"), "init").unwrap();
468        backend.commit(tmp.path(), "init", "user").unwrap();
469
470        backend.create_tag(tmp.path(), "v1.0").unwrap();
471        let tags = backend.list_tags(tmp.path()).unwrap();
472        assert!(tags.contains(&"v1.0".to_string()));
473    }
474
475    #[test]
476    fn test_git_add_and_list_remotes() {
477        let tmp = TempDir::new().unwrap();
478        let backend = GitBackend::new();
479        backend.init(tmp.path()).unwrap();
480
481        // Add a remote
482        backend
483            .add_remote(tmp.path(), "origin", "git@github.com:user/repo.git")
484            .unwrap();
485
486        let remotes = backend.list_remotes(tmp.path()).unwrap();
487        assert_eq!(remotes.len(), 1);
488        assert_eq!(remotes[0].0, "origin");
489        assert_eq!(remotes[0].1, "git@github.com:user/repo.git");
490    }
491
492    #[test]
493    fn test_git_remove_remote() {
494        let tmp = TempDir::new().unwrap();
495        let backend = GitBackend::new();
496        backend.init(tmp.path()).unwrap();
497
498        backend
499            .add_remote(tmp.path(), "origin", "git@github.com:user/repo.git")
500            .unwrap();
501        backend.remove_remote(tmp.path(), "origin").unwrap();
502
503        let remotes = backend.list_remotes(tmp.path()).unwrap();
504        assert!(remotes.is_empty());
505    }
506
507    #[test]
508    fn test_git_clone_repo() {
509        // Clone from a local repo (file://) to test clone_repo end-to-end
510        let tmp = TempDir::new().unwrap();
511        let backend = GitBackend::new();
512
513        // Create a source repo with content
514        let src = tmp.path().join("source");
515        backend.init(&src).unwrap();
516        std::fs::write(src.join("hello.txt"), "world").unwrap();
517        backend.commit(&src, "init", "user").unwrap();
518
519        // Clone it
520        let dest = tmp.path().join("clone");
521        GitBackend::clone_repo(src.to_str().unwrap(), &dest).unwrap();
522
523        // Verify content
524        assert!(dest.join("hello.txt").exists());
525        assert_eq!(
526            std::fs::read_to_string(dest.join("hello.txt")).unwrap(),
527            "world"
528        );
529    }
530}