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 resolve_branch_head(&self, path: &Path, branch: &str) -> Result<String, NapError> {
225        let output = Self::run_git(path, &["rev-parse", branch])?;
226        let hash = output.trim().to_string();
227        if hash.is_empty() {
228            return Err(NapError::VcsError(format!(
229                "branch '{branch}' has no commits"
230            )));
231        }
232        Ok(hash)
233    }
234
235    fn list_branches(&self, path: &Path) -> Result<Vec<String>, NapError> {
236        let output = Self::run_git(path, &["branch", "--format=%(refname:short)"])?;
237        Ok(output
238            .lines()
239            .map(|l| l.trim().to_string())
240            .filter(|l| !l.is_empty())
241            .collect())
242    }
243
244    fn list_tags(&self, path: &Path) -> Result<Vec<String>, NapError> {
245        let output = Self::run_git(path, &["tag", "--list"])?;
246        Ok(output
247            .lines()
248            .map(|l| l.trim().to_string())
249            .filter(|l| !l.is_empty())
250            .collect())
251    }
252
253    // ── Remote operations ────────────────────────────────────────
254
255    fn add_remote(&self, path: &Path, name: &str, url: &str) -> Result<(), NapError> {
256        debug!(
257            path = %path.display(),
258            remote = %name,
259            url = %url,
260            "adding git remote"
261        );
262        Self::run_git(path, &["remote", "add", name, url])?;
263        Ok(())
264    }
265
266    fn remove_remote(&self, path: &Path, name: &str) -> Result<(), NapError> {
267        debug!(
268            path = %path.display(),
269            remote = %name,
270            "removing git remote"
271        );
272        Self::run_git(path, &["remote", "remove", name])?;
273        Ok(())
274    }
275
276    fn list_remotes(&self, path: &Path) -> Result<Vec<(String, String)>, NapError> {
277        debug!(path = %path.display(), "listing git remotes");
278        let output = Self::run_git(path, &["remote", "-v"])?;
279        let mut remotes = Vec::new();
280        for line in output.lines() {
281            let line = line.trim();
282            if line.is_empty() {
283                continue;
284            }
285            // Format: "origin\tgit@github.com:user/repo.git (fetch)"
286            if let Some(tab_pos) = line.find('\t') {
287                let name = line[..tab_pos].to_string();
288                let rest = &line[tab_pos + 1..];
289                if let Some(space_pos) = rest.rfind(" (") {
290                    let url = rest[..space_pos].to_string();
291                    // Only add once per remote (skip the (push) entry)
292                    if !remotes.iter().any(|(n, _): &(String, String)| n == &name) {
293                        remotes.push((name, url));
294                    }
295                }
296            }
297        }
298        Ok(remotes)
299    }
300
301    fn push(
302        &self,
303        path: &Path,
304        remote: Option<&str>,
305        branch: Option<&str>,
306    ) -> Result<(), NapError> {
307        let remote_display = remote.unwrap_or("origin");
308        let branch_display = branch.unwrap_or("current");
309        debug!(
310            path = %path.display(),
311            remote = %remote_display,
312            branch = %branch_display,
313            "pushing to git remote"
314        );
315
316        let mut args = vec!["push"];
317        if let Some(r) = remote {
318            args.push(r);
319        }
320        if let Some(b) = branch {
321            args.push(b);
322        }
323
324        Self::run_git(path, &args)?;
325        Ok(())
326    }
327
328    fn pull(
329        &self,
330        path: &Path,
331        remote: Option<&str>,
332        branch: Option<&str>,
333    ) -> Result<(), NapError> {
334        let remote_display = remote.unwrap_or("origin");
335        let branch_display = branch.unwrap_or("current");
336        debug!(
337            path = %path.display(),
338            remote = %remote_display,
339            branch = %branch_display,
340            "pulling from git remote"
341        );
342
343        let mut args = vec!["pull"];
344        if let Some(r) = remote {
345            args.push(r);
346        }
347        if let Some(b) = branch {
348            args.push(b);
349        }
350
351        Self::run_git(path, &args)?;
352        Ok(())
353    }
354
355    fn revert(&self, path: &Path, commit_hash: &str) -> Result<String, NapError> {
356        debug!(
357            path = %path.display(),
358            commit = %commit_hash,
359            "reverting git commit"
360        );
361
362        // Git revert requires a clean working tree.  NAP's design leaves
363        // head-pointer updates uncommitted.  Stash them, do the revert,
364        // then drop the stash — the caller (Repository::revert_commit)
365        // regenerates fresh head pointers afterward.
366        let status = Self::run_git(path, &["status", "--porcelain"])?;
367        let had_dirty = !status.is_empty();
368        if had_dirty {
369            Self::run_git(path, &["stash", "push", "-m", "nap-revert-stash"])?;
370        }
371
372        Self::run_git(path, &["revert", "--no-edit", commit_hash])?;
373
374        // Discard the stash — the caller will regenerate head pointers
375        if had_dirty {
376            Self::run_git(path, &["stash", "drop"]).ok();
377        }
378
379        let hash = Self::run_git(path, &["rev-parse", "HEAD"])?;
380        debug!(revert_commit = %hash, "git revert created");
381        Ok(hash)
382    }
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388    use tempfile::TempDir;
389
390    #[test]
391    fn test_git_init_and_commit() {
392        let tmp = TempDir::new().unwrap();
393        let backend = GitBackend::new();
394
395        // Init repo
396        backend.init(tmp.path()).unwrap();
397
398        // Create a file
399        std::fs::write(tmp.path().join("test.txt"), "hello").unwrap();
400
401        // Commit
402        let hash = backend
403            .commit(tmp.path(), "initial commit", "test-user")
404            .unwrap();
405        assert!(!hash.is_empty());
406
407        // Verify HEAD
408        let head = backend.head_hash(tmp.path()).unwrap();
409        assert_eq!(hash, head);
410    }
411
412    #[test]
413    fn test_git_read_file_at_ref() {
414        let tmp = TempDir::new().unwrap();
415        let backend = GitBackend::new();
416        backend.init(tmp.path()).unwrap();
417
418        // Commit v1
419        std::fs::write(tmp.path().join("data.txt"), "version 1").unwrap();
420        let hash_v1 = backend.commit(tmp.path(), "v1", "user").unwrap();
421
422        // Commit v2
423        std::fs::write(tmp.path().join("data.txt"), "version 2").unwrap();
424        backend.commit(tmp.path(), "v2", "user").unwrap();
425
426        // Read current (v2)
427        let current = backend
428            .read_file_at_ref(tmp.path(), "data.txt", None)
429            .unwrap();
430        assert_eq!(current, "version 2");
431
432        // Read at v1
433        let at_v1 = backend
434            .read_file_at_ref(tmp.path(), "data.txt", Some(&hash_v1))
435            .unwrap();
436        assert_eq!(at_v1, "version 1");
437    }
438
439    #[test]
440    fn test_git_log() {
441        let tmp = TempDir::new().unwrap();
442        let backend = GitBackend::new();
443        backend.init(tmp.path()).unwrap();
444
445        std::fs::write(tmp.path().join("a.txt"), "a").unwrap();
446        backend.commit(tmp.path(), "first", "user").unwrap();
447
448        std::fs::write(tmp.path().join("b.txt"), "b").unwrap();
449        backend.commit(tmp.path(), "second", "user").unwrap();
450
451        let log = backend.log(tmp.path(), None, 10).unwrap();
452        assert_eq!(log.len(), 2);
453        assert_eq!(log[0].message, "second");
454        assert_eq!(log[1].message, "first");
455    }
456
457    #[test]
458    fn test_git_branches() {
459        let tmp = TempDir::new().unwrap();
460        let backend = GitBackend::new();
461        backend.init(tmp.path()).unwrap();
462
463        std::fs::write(tmp.path().join("init.txt"), "init").unwrap();
464        backend.commit(tmp.path(), "init", "user").unwrap();
465
466        backend.create_branch(tmp.path(), "canon").unwrap();
467        let branches = backend.list_branches(tmp.path()).unwrap();
468        assert!(branches.contains(&"main".to_string()));
469        assert!(branches.contains(&"canon".to_string()));
470    }
471
472    #[test]
473    fn test_git_tags() {
474        let tmp = TempDir::new().unwrap();
475        let backend = GitBackend::new();
476        backend.init(tmp.path()).unwrap();
477
478        std::fs::write(tmp.path().join("init.txt"), "init").unwrap();
479        backend.commit(tmp.path(), "init", "user").unwrap();
480
481        backend.create_tag(tmp.path(), "v1.0").unwrap();
482        let tags = backend.list_tags(tmp.path()).unwrap();
483        assert!(tags.contains(&"v1.0".to_string()));
484    }
485
486    #[test]
487    fn test_git_add_and_list_remotes() {
488        let tmp = TempDir::new().unwrap();
489        let backend = GitBackend::new();
490        backend.init(tmp.path()).unwrap();
491
492        // Add a remote
493        backend
494            .add_remote(tmp.path(), "origin", "git@github.com:user/repo.git")
495            .unwrap();
496
497        let remotes = backend.list_remotes(tmp.path()).unwrap();
498        assert_eq!(remotes.len(), 1);
499        assert_eq!(remotes[0].0, "origin");
500        assert_eq!(remotes[0].1, "git@github.com:user/repo.git");
501    }
502
503    #[test]
504    fn test_git_remove_remote() {
505        let tmp = TempDir::new().unwrap();
506        let backend = GitBackend::new();
507        backend.init(tmp.path()).unwrap();
508
509        backend
510            .add_remote(tmp.path(), "origin", "git@github.com:user/repo.git")
511            .unwrap();
512        backend.remove_remote(tmp.path(), "origin").unwrap();
513
514        let remotes = backend.list_remotes(tmp.path()).unwrap();
515        assert!(remotes.is_empty());
516    }
517
518    #[test]
519    fn test_git_clone_repo() {
520        // Clone from a local repo (file://) to test clone_repo end-to-end
521        let tmp = TempDir::new().unwrap();
522        let backend = GitBackend::new();
523
524        // Create a source repo with content
525        let src = tmp.path().join("source");
526        backend.init(&src).unwrap();
527        std::fs::write(src.join("hello.txt"), "world").unwrap();
528        backend.commit(&src, "init", "user").unwrap();
529
530        // Clone it
531        let dest = tmp.path().join("clone");
532        GitBackend::clone_repo(src.to_str().unwrap(), &dest).unwrap();
533
534        // Verify content
535        assert!(dest.join("hello.txt").exists());
536        assert_eq!(
537            std::fs::read_to_string(dest.join("hello.txt")).unwrap(),
538            "world"
539        );
540    }
541}