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
9pub 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 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 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 let info = self.commit_info(commit)?;
75 let diff_output = if info.parent_shas.is_empty() {
76 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 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 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 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 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 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 let (success, stdout, _stderr) =
247 self.run_git_raw(&["notes", "--ref", &self.notes_ref, "list"])?;
248 if !success {
249 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}