1use anyhow::{Context as _, Result, bail};
2use std::process::Command;
3
4use super::git_output;
5
6#[derive(Debug, Clone)]
7pub struct Commit {
8 pub hash: String,
9 pub short_hash: String,
10 pub message: String,
11 pub author_name: String,
12 pub author_email: String,
13 pub body: String,
16}
17
18fn parse_commit_output(output: &str) -> Vec<Commit> {
24 if output.is_empty() {
25 return vec![];
26 }
27 output
28 .split('\x1e')
29 .filter(|record| !record.trim().is_empty())
30 .filter_map(|record| {
31 let fields: Vec<&str> = record.split('\x1f').collect();
32 if fields.len() >= 5 {
33 Some(Commit {
34 hash: fields[0].trim().to_string(),
35 short_hash: fields[1].to_string(),
36 message: fields[2].to_string(),
37 author_name: fields[3].to_string(),
38 author_email: fields[4].to_string(),
39 body: fields.get(5).unwrap_or(&"").trim().to_string(),
40 })
41 } else {
42 None
43 }
44 })
45 .collect()
46}
47
48pub fn get_commits_between(from: &str, to: &str, path_filter: Option<&str>) -> Result<Vec<Commit>> {
50 get_commits_between_paths(
51 from,
52 to,
53 &path_filter
54 .into_iter()
55 .map(String::from)
56 .collect::<Vec<_>>(),
57 )
58}
59
60pub fn get_commits_between_paths(from: &str, to: &str, paths: &[String]) -> Result<Vec<Commit>> {
62 let range = format!("{}..{}", from, to);
63 let mut args = vec![
64 "-c".to_string(),
65 "log.showSignature=false".to_string(),
66 "log".to_string(),
67 "--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e".to_string(),
68 range,
69 ];
70 if !paths.is_empty() {
71 args.push("--".to_string());
72 for p in paths {
73 args.push(p.clone());
74 }
75 }
76 let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
77 let output = git_output(&arg_refs)?;
78 Ok(parse_commit_output(&output))
79}
80
81pub fn get_all_commits(path_filter: Option<&str>) -> Result<Vec<Commit>> {
84 get_all_commits_paths(
85 &path_filter
86 .into_iter()
87 .map(String::from)
88 .collect::<Vec<_>>(),
89 )
90}
91
92pub fn get_all_commits_paths(paths: &[String]) -> Result<Vec<Commit>> {
94 let mut args = vec![
95 "-c".to_string(),
96 "log.showSignature=false".to_string(),
97 "log".to_string(),
98 "--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e".to_string(),
99 "HEAD".to_string(),
100 ];
101 if !paths.is_empty() {
102 args.push("--".to_string());
103 for p in paths {
104 args.push(p.clone());
105 }
106 }
107 let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
108 let output = git_output(&arg_refs)?;
109 Ok(parse_commit_output(&output))
110}
111
112pub fn get_last_commit_messages(count: usize) -> Result<Vec<String>> {
114 let output = git_output(&[
115 "-c",
116 "log.showSignature=false",
117 "log",
118 &format!("-{count}"),
119 "--pretty=format:%s",
120 ])?;
121 Ok(output.lines().map(str::to_string).collect())
122}
123
124pub fn get_commit_messages_between(from: &str, to: &str) -> Result<Vec<String>> {
126 let output = git_output(&[
127 "-c",
128 "log.showSignature=false",
129 "log",
130 "--pretty=format:%s",
131 &format!("{from}..{to}"),
132 ])?;
133 Ok(output.lines().map(str::to_string).collect())
134}
135
136pub fn get_current_branch() -> Result<String> {
138 git_output(&["rev-parse", "--abbrev-ref", "HEAD"])
139}
140
141pub fn has_commits_since_tag(tag: &str) -> Result<bool> {
143 let range = format!("{}..HEAD", tag);
144 let output = git_output(&["-c", "log.showSignature=false", "log", "--oneline", &range])?;
145 Ok(!output.is_empty())
146}
147
148pub fn get_short_commit() -> Result<String> {
150 git_output(&["rev-parse", "--short", "HEAD"])
151}
152
153pub const SHORT_COMMIT_LEN: usize = 7;
159
160pub fn short_commit_str(commit: &str) -> String {
171 if commit.len() > SHORT_COMMIT_LEN {
172 commit[..SHORT_COMMIT_LEN].to_string()
173 } else {
174 commit.to_string()
175 }
176}
177
178pub fn get_head_commit() -> Result<String> {
185 git_output(&["rev-parse", "HEAD"])
186}
187
188pub fn has_changes_since(tag: &str, path: &str) -> Result<bool> {
190 let output = git_output(&["diff", "--name-only", &format!("{}..HEAD", tag), "--", path])?;
191 Ok(!output.is_empty())
192}
193
194pub fn get_last_commit_messages_path(count: usize, path: &str) -> Result<Vec<String>> {
196 let output = git_output(&[
197 "-c",
198 "log.showSignature=false",
199 "log",
200 &format!("-{count}"),
201 "--pretty=format:%s",
202 "--",
203 path,
204 ])?;
205 Ok(output.lines().map(str::to_string).collect())
206}
207
208pub fn get_commit_messages_between_path(from: &str, to: &str, path: &str) -> Result<Vec<String>> {
210 let output = git_output(&[
211 "-c",
212 "log.showSignature=false",
213 "log",
214 "--pretty=format:%s",
215 &format!("{from}..{to}"),
216 "--",
217 path,
218 ])?;
219 Ok(output.lines().map(str::to_string).collect())
220}
221
222pub fn stage_and_commit(files: &[&str], message: &str) -> Result<()> {
224 let mut args = vec!["add", "--"];
225 args.extend(files.iter().copied());
226 git_output(&args)?;
227 git_output(&["commit", "-m", message])?;
228 Ok(())
229}
230
231pub fn log_subjects_for_range(
242 workspace_root: &std::path::Path,
243 range: &str,
244 rel_path: &str,
245) -> Result<Vec<String>> {
246 let out = Command::new("git")
247 .arg("-C")
248 .arg(workspace_root)
249 .args([
250 "-c",
251 "log.showSignature=false",
252 "log",
253 "--pretty=format:%B%x1e",
254 range,
255 "--",
256 rel_path,
257 ])
258 .output()?;
259 if !out.status.success() {
260 return Ok(Vec::new());
262 }
263 let text = String::from_utf8_lossy(&out.stdout);
264 Ok(text
265 .split('\x1e')
266 .map(|s| s.trim().to_string())
267 .filter(|s| !s.is_empty())
268 .collect())
269}
270
271pub fn add_path_in(workspace_root: &std::path::Path, rel: &std::path::Path) -> Result<()> {
273 let out = Command::new("git")
274 .arg("-C")
275 .arg(workspace_root)
276 .arg("add")
277 .arg(rel)
278 .output()
279 .context("failed to invoke git add")?;
280 if !out.status.success() {
281 let stderr_raw = String::from_utf8_lossy(&out.stderr);
282 let raw = format!("git add {} failed: {}", rel.display(), stderr_raw.trim());
283 bail!("{}", crate::redact::redact_process_env(&raw));
284 }
285 Ok(())
286}
287
288pub fn commit_in(workspace_root: &std::path::Path, message: &str, sign: bool) -> Result<()> {
291 let mut cmd = Command::new("git");
292 cmd.arg("-C").arg(workspace_root).arg("commit");
293 if sign {
294 cmd.arg("-S");
295 }
296 cmd.arg("-m").arg(message);
297 let out = cmd.output().context("failed to invoke git commit")?;
298 if !out.status.success() {
299 let stderr_raw = String::from_utf8_lossy(&out.stderr);
300 let raw = format!("git commit failed: {}", stderr_raw.trim());
301 bail!("{}", crate::redact::redact_process_env(&raw));
302 }
303 Ok(())
304}
305
306pub fn paths_changed_since_tag(tag: &str, paths: &[&str]) -> Result<bool> {
311 let mut args: Vec<String> = vec![
312 "diff".to_string(),
313 "--name-only".to_string(),
314 format!("{tag}..HEAD"),
315 "--".to_string(),
316 ];
317 for p in paths {
318 args.push((*p).to_string());
319 }
320 let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
321 let output = Command::new("git").args(&arg_refs).output()?;
322 if output.status.success() {
323 Ok(!String::from_utf8_lossy(&output.stdout).trim().is_empty())
324 } else {
325 Ok(false)
326 }
327}
328
329pub fn head_commit_hash_in(repo: &std::path::Path) -> Result<String> {
334 let out = Command::new("git")
335 .arg("-C")
336 .arg(repo)
337 .args(["rev-parse", "HEAD"])
338 .output()
339 .context("failed to invoke git rev-parse HEAD")?;
340 if !out.status.success() {
341 let stderr_raw = String::from_utf8_lossy(&out.stderr);
342 let raw = format!("git rev-parse HEAD failed: {}", stderr_raw.trim());
343 bail!("{}", crate::redact::redact_process_env(&raw));
344 }
345 Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
346}
347
348pub fn head_commit_timestamp_in(repo: &std::path::Path) -> Result<i64> {
352 let out = Command::new("git")
353 .arg("-C")
354 .arg(repo)
355 .args(["log", "-1", "--format=%ct", "HEAD"])
356 .output()
357 .context("failed to invoke git log -1 --format=%ct HEAD")?;
358 if !out.status.success() {
359 let stderr_raw = String::from_utf8_lossy(&out.stderr);
360 let raw = format!("git log -1 --format=%ct HEAD failed: {}", stderr_raw.trim());
361 bail!("{}", crate::redact::redact_process_env(&raw));
362 }
363 let text = String::from_utf8_lossy(&out.stdout).trim().to_string();
364 text.parse::<i64>()
365 .with_context(|| format!("git log --format=%ct returned non-i64 timestamp: {}", text))
366}