Skip to main content

chronicle/git/
cli_ops.rs

1use std::path::{Path, PathBuf};
2use std::process::Command;
3
4use crate::error::git_error::{CommandFailedSnafu, CommitNotFoundSnafu, FileNotFoundSnafu};
5use crate::error::GitError;
6use crate::git::diff::parse_diff;
7use crate::git::{CommitInfo, FileDiff, GitOps};
8
9/// Git operations implemented by shelling out to the `git` CLI.
10pub struct CliOps {
11    pub repo_dir: PathBuf,
12    pub notes_ref: String,
13}
14
15impl CliOps {
16    pub fn new(repo_dir: PathBuf) -> Self {
17        Self {
18            repo_dir,
19            notes_ref: "refs/notes/chronicle".to_string(),
20        }
21    }
22
23    pub fn with_notes_ref(mut self, notes_ref: String) -> Self {
24        self.notes_ref = notes_ref;
25        self
26    }
27
28    /// Run a git command and return stdout on success, or an error with stderr.
29    fn run_git(&self, args: &[&str]) -> Result<String, GitError> {
30        let output = Command::new("git")
31            .args(args)
32            .current_dir(&self.repo_dir)
33            .output()
34            .map_err(|e| {
35                CommandFailedSnafu {
36                    message: format!("failed to run git: {e}"),
37                }
38                .build()
39            })?;
40
41        if output.status.success() {
42            Ok(String::from_utf8_lossy(&output.stdout).to_string())
43        } else {
44            let stderr = String::from_utf8_lossy(&output.stderr).to_string();
45            Err(CommandFailedSnafu {
46                message: stderr.trim().to_string(),
47            }
48            .build())
49        }
50    }
51
52    /// Run git and return (success, stdout, stderr) without failing on non-zero exit.
53    fn run_git_raw(&self, args: &[&str]) -> Result<(bool, String, String), GitError> {
54        let output = Command::new("git")
55            .args(args)
56            .current_dir(&self.repo_dir)
57            .output()
58            .map_err(|e| {
59                CommandFailedSnafu {
60                    message: format!("failed to run git: {e}"),
61                }
62                .build()
63            })?;
64
65        let stdout = String::from_utf8_lossy(&output.stdout).to_string();
66        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
67        Ok((output.status.success(), stdout, stderr))
68    }
69}
70
71impl GitOps for CliOps {
72    fn diff(&self, commit: &str) -> Result<Vec<FileDiff>, GitError> {
73        // Check if this is a root commit (no parents)
74        let info = self.commit_info(commit)?;
75        let diff_output = if info.parent_shas.is_empty() {
76            // Root commit: use --root flag
77            self.run_git(&["diff-tree", "--root", "-p", "--no-color", "-M", commit])?
78        } else {
79            self.run_git(&["diff-tree", "-p", "--no-color", "-M", commit])?
80        };
81
82        parse_diff(&diff_output)
83    }
84
85    fn note_read(&self, commit: &str) -> Result<Option<String>, GitError> {
86        let (success, stdout, _stderr) =
87            self.run_git_raw(&["notes", "--ref", &self.notes_ref, "show", commit])?;
88
89        if success {
90            Ok(Some(stdout))
91        } else {
92            Ok(None)
93        }
94    }
95
96    fn note_write(&self, commit: &str, content: &str) -> Result<(), GitError> {
97        // Use a tempfile to avoid shell escaping issues with note content
98        let tmp_dir = self.repo_dir.join(".git").join("chronicle");
99        std::fs::create_dir_all(&tmp_dir).map_err(|e| {
100            CommandFailedSnafu {
101                message: format!("failed to create temp dir: {e}"),
102            }
103            .build()
104        })?;
105
106        let tmp_path = tmp_dir.join("note-tmp.json");
107        std::fs::write(&tmp_path, content).map_err(|e| {
108            CommandFailedSnafu {
109                message: format!("failed to write temp file: {e}"),
110            }
111            .build()
112        })?;
113
114        let tmp_path_str = tmp_path.to_string_lossy();
115
116        // Try add first, if that fails (note exists), use add --force
117        let result = self.run_git(&[
118            "notes",
119            "--ref",
120            &self.notes_ref,
121            "add",
122            "-f",
123            "-F",
124            &tmp_path_str,
125            commit,
126        ]);
127
128        // Clean up temp file regardless of result
129        let _ = std::fs::remove_file(&tmp_path);
130
131        result?;
132        Ok(())
133    }
134
135    fn note_exists(&self, commit: &str) -> Result<bool, GitError> {
136        let (success, _stdout, _stderr) =
137            self.run_git_raw(&["notes", "--ref", &self.notes_ref, "show", commit])?;
138        Ok(success)
139    }
140
141    fn file_at_commit(&self, path: &Path, commit: &str) -> Result<String, GitError> {
142        let path_str = path.to_string_lossy();
143        let object = format!("{commit}:{path_str}");
144        let (success, stdout, stderr) = self.run_git_raw(&["show", &object])?;
145
146        if success {
147            Ok(stdout)
148        } else {
149            if stderr.contains("does not exist") || stderr.contains("fatal: path") {
150                return Err(FileNotFoundSnafu {
151                    path: path_str.to_string(),
152                    commit: commit.to_string(),
153                }
154                .build());
155            }
156            Err(CommandFailedSnafu {
157                message: stderr.trim().to_string(),
158            }
159            .build())
160        }
161    }
162
163    fn commit_info(&self, commit: &str) -> Result<CommitInfo, GitError> {
164        // Use a custom format to get all info in one call
165        // %H = sha, %s = subject, %an = author name, %ae = author email, %aI = author date ISO, %P = parent hashes
166        let (success, stdout, stderr) =
167            self.run_git_raw(&["log", "-1", "--format=%H%n%s%n%an%n%ae%n%aI%n%P", commit])?;
168
169        if !success {
170            if stderr.contains("unknown revision") || stderr.contains("bad object") {
171                return Err(CommitNotFoundSnafu {
172                    sha: commit.to_string(),
173                }
174                .build());
175            }
176            return Err(CommandFailedSnafu {
177                message: stderr.trim().to_string(),
178            }
179            .build());
180        }
181
182        let lines: Vec<&str> = stdout.lines().collect();
183        if lines.len() < 5 {
184            return Err(CommandFailedSnafu {
185                message: format!("unexpected git log output for {commit}"),
186            }
187            .build());
188        }
189
190        let parent_shas: Vec<String> = if lines.len() > 5 && !lines[5].is_empty() {
191            lines[5].split(' ').map(|s| s.to_string()).collect()
192        } else {
193            Vec::new()
194        };
195
196        Ok(CommitInfo {
197            sha: lines[0].to_string(),
198            message: lines[1].to_string(),
199            author_name: lines[2].to_string(),
200            author_email: lines[3].to_string(),
201            timestamp: lines[4].to_string(),
202            parent_shas,
203        })
204    }
205
206    fn resolve_ref(&self, refspec: &str) -> Result<String, GitError> {
207        let output = self.run_git(&["rev-parse", refspec])?;
208        Ok(output.trim().to_string())
209    }
210
211    fn config_get(&self, key: &str) -> Result<Option<String>, GitError> {
212        let (success, stdout, _stderr) = self.run_git_raw(&["config", "--get", key])?;
213        if success {
214            let val = stdout.trim().to_string();
215            if val.is_empty() {
216                Ok(None)
217            } else {
218                Ok(Some(val))
219            }
220        } else {
221            // git config --get exits with 1 when key is not found, which is not an error
222            Ok(None)
223        }
224    }
225
226    fn config_set(&self, key: &str, value: &str) -> Result<(), GitError> {
227        self.run_git(&["config", key, value])?;
228        Ok(())
229    }
230
231    fn log_for_file(&self, path: &str) -> Result<Vec<String>, GitError> {
232        let output = self.run_git(&["log", "--follow", "--format=%H", "--", path])?;
233        let shas: Vec<String> = output
234            .lines()
235            .filter(|l| !l.is_empty())
236            .map(|l| l.to_string())
237            .collect();
238        Ok(shas)
239    }
240
241    fn list_annotated_commits(&self, limit: u32) -> Result<Vec<String>, GitError> {
242        // List commits that have notes under refs/notes/chronicle.
243        // `git log --format=%H refs/notes/chronicle` lists the note tree commits,
244        // not the annotated commits. Instead, use `git notes list` which outputs
245        // "<blob-sha> <commit-sha>" for each annotated commit.
246        let (success, stdout, _stderr) =
247            self.run_git_raw(&["notes", "--ref", &self.notes_ref, "list"])?;
248        if !success {
249            // Notes ref may not exist yet — return empty
250            return Ok(Vec::new());
251        }
252        let shas: Vec<String> = stdout
253            .lines()
254            .filter(|l| !l.is_empty())
255            .filter_map(|l| l.split_whitespace().nth(1).map(|s| s.to_string()))
256            .take(limit as usize)
257            .collect();
258        Ok(shas)
259    }
260}