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 fn get_head_commit() -> Result<String> {
160 git_output(&["rev-parse", "HEAD"])
161}
162
163pub fn has_changes_since(tag: &str, path: &str) -> Result<bool> {
165 let output = git_output(&["diff", "--name-only", &format!("{}..HEAD", tag), "--", path])?;
166 Ok(!output.is_empty())
167}
168
169pub fn get_last_commit_messages_path(count: usize, path: &str) -> Result<Vec<String>> {
171 let output = git_output(&[
172 "-c",
173 "log.showSignature=false",
174 "log",
175 &format!("-{count}"),
176 "--pretty=format:%s",
177 "--",
178 path,
179 ])?;
180 Ok(output.lines().map(str::to_string).collect())
181}
182
183pub fn get_commit_messages_between_path(from: &str, to: &str, path: &str) -> Result<Vec<String>> {
185 let output = git_output(&[
186 "-c",
187 "log.showSignature=false",
188 "log",
189 "--pretty=format:%s",
190 &format!("{from}..{to}"),
191 "--",
192 path,
193 ])?;
194 Ok(output.lines().map(str::to_string).collect())
195}
196
197pub fn stage_and_commit(files: &[&str], message: &str) -> Result<()> {
199 let mut args = vec!["add", "--"];
200 args.extend(files.iter().copied());
201 git_output(&args)?;
202 git_output(&["commit", "-m", message])?;
203 Ok(())
204}
205
206pub fn log_subjects_for_range(
217 workspace_root: &std::path::Path,
218 range: &str,
219 rel_path: &str,
220) -> Result<Vec<String>> {
221 let out = Command::new("git")
222 .arg("-C")
223 .arg(workspace_root)
224 .args([
225 "-c",
226 "log.showSignature=false",
227 "log",
228 "--pretty=format:%B%x1e",
229 range,
230 "--",
231 rel_path,
232 ])
233 .output()?;
234 if !out.status.success() {
235 return Ok(Vec::new());
237 }
238 let text = String::from_utf8_lossy(&out.stdout);
239 Ok(text
240 .split('\x1e')
241 .map(|s| s.trim().to_string())
242 .filter(|s| !s.is_empty())
243 .collect())
244}
245
246pub fn add_path_in(workspace_root: &std::path::Path, rel: &std::path::Path) -> Result<()> {
248 let out = Command::new("git")
249 .arg("-C")
250 .arg(workspace_root)
251 .arg("add")
252 .arg(rel)
253 .output()
254 .context("failed to invoke git add")?;
255 if !out.status.success() {
256 let stderr_raw = String::from_utf8_lossy(&out.stderr);
257 let raw = format!("git add {} failed: {}", rel.display(), stderr_raw.trim());
258 bail!("{}", crate::redact::redact_process_env(&raw));
259 }
260 Ok(())
261}
262
263pub fn commit_in(workspace_root: &std::path::Path, message: &str, sign: bool) -> Result<()> {
266 let mut cmd = Command::new("git");
267 cmd.arg("-C").arg(workspace_root).arg("commit");
268 if sign {
269 cmd.arg("-S");
270 }
271 cmd.arg("-m").arg(message);
272 let out = cmd.output().context("failed to invoke git commit")?;
273 if !out.status.success() {
274 let stderr_raw = String::from_utf8_lossy(&out.stderr);
275 let raw = format!("git commit failed: {}", stderr_raw.trim());
276 bail!("{}", crate::redact::redact_process_env(&raw));
277 }
278 Ok(())
279}
280
281pub fn paths_changed_since_tag(tag: &str, paths: &[&str]) -> Result<bool> {
286 let mut args: Vec<String> = vec![
287 "diff".to_string(),
288 "--name-only".to_string(),
289 format!("{tag}..HEAD"),
290 "--".to_string(),
291 ];
292 for p in paths {
293 args.push((*p).to_string());
294 }
295 let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
296 let output = Command::new("git").args(&arg_refs).output()?;
297 if output.status.success() {
298 Ok(!String::from_utf8_lossy(&output.stdout).trim().is_empty())
299 } else {
300 Ok(false)
301 }
302}