1use anyhow::{Context as _, Result, bail};
2use std::path::{Path, PathBuf};
3use std::process::Command;
4
5use super::git_output_in;
6
7#[derive(Debug, Clone)]
8pub struct Commit {
9 pub hash: String,
10 pub short_hash: String,
11 pub message: String,
12 pub author_name: String,
13 pub author_email: String,
14 pub body: String,
17}
18
19fn parse_commit_output(output: &str) -> Vec<Commit> {
25 if output.is_empty() {
26 return vec![];
27 }
28 output
29 .split('\x1e')
30 .filter(|record| !record.trim().is_empty())
31 .filter_map(|record| {
32 let fields: Vec<&str> = record.split('\x1f').collect();
33 if fields.len() >= 5 {
34 Some(Commit {
35 hash: fields[0].trim().to_string(),
36 short_hash: fields[1].to_string(),
37 message: fields[2].to_string(),
38 author_name: fields[3].to_string(),
39 author_email: fields[4].to_string(),
40 body: fields.get(5).unwrap_or(&"").trim().to_string(),
41 })
42 } else {
43 None
44 }
45 })
46 .collect()
47}
48
49fn cwd_or_dot() -> PathBuf {
50 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
51}
52
53pub fn get_commits_between(from: &str, to: &str, path_filter: Option<&str>) -> Result<Vec<Commit>> {
55 get_commits_between_in(&cwd_or_dot(), from, to, path_filter)
56}
57
58pub fn get_commits_between_in(
60 cwd: &Path,
61 from: &str,
62 to: &str,
63 path_filter: Option<&str>,
64) -> Result<Vec<Commit>> {
65 get_commits_between_paths_in(
66 cwd,
67 from,
68 to,
69 &path_filter
70 .into_iter()
71 .map(String::from)
72 .collect::<Vec<_>>(),
73 )
74}
75
76pub fn get_commits_between_paths(from: &str, to: &str, paths: &[String]) -> Result<Vec<Commit>> {
78 get_commits_between_paths_in(&cwd_or_dot(), from, to, paths)
79}
80
81pub fn get_commits_between_paths_in(
83 cwd: &Path,
84 from: &str,
85 to: &str,
86 paths: &[String],
87) -> Result<Vec<Commit>> {
88 let range = format!("{}..{}", from, to);
89 let mut args = vec![
90 "-c".to_string(),
91 "log.showSignature=false".to_string(),
92 "log".to_string(),
93 "--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e".to_string(),
94 range,
95 ];
96 if !paths.is_empty() {
97 args.push("--".to_string());
98 for p in paths {
99 args.push(p.clone());
100 }
101 }
102 let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
103 let output = git_output_in(cwd, &arg_refs)?;
104 Ok(parse_commit_output(&output))
105}
106
107pub fn get_all_commits(path_filter: Option<&str>) -> Result<Vec<Commit>> {
110 get_all_commits_in(&cwd_or_dot(), path_filter)
111}
112
113pub fn get_all_commits_in(cwd: &Path, path_filter: Option<&str>) -> Result<Vec<Commit>> {
115 get_all_commits_paths_in(
116 cwd,
117 &path_filter
118 .into_iter()
119 .map(String::from)
120 .collect::<Vec<_>>(),
121 )
122}
123
124pub fn get_all_commits_paths(paths: &[String]) -> Result<Vec<Commit>> {
126 get_all_commits_paths_in(&cwd_or_dot(), paths)
127}
128
129pub fn get_all_commits_paths_in(cwd: &Path, paths: &[String]) -> Result<Vec<Commit>> {
131 let mut args = vec![
132 "-c".to_string(),
133 "log.showSignature=false".to_string(),
134 "log".to_string(),
135 "--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e".to_string(),
136 "HEAD".to_string(),
137 ];
138 if !paths.is_empty() {
139 args.push("--".to_string());
140 for p in paths {
141 args.push(p.clone());
142 }
143 }
144 let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
145 let output = git_output_in(cwd, &arg_refs)?;
146 Ok(parse_commit_output(&output))
147}
148
149pub fn get_last_commit_messages(count: usize) -> Result<Vec<String>> {
151 get_last_commit_messages_in(&cwd_or_dot(), count)
152}
153
154pub fn get_last_commit_messages_in(cwd: &Path, count: usize) -> Result<Vec<String>> {
156 let output = git_output_in(
157 cwd,
158 &[
159 "-c",
160 "log.showSignature=false",
161 "log",
162 &format!("-{count}"),
163 "--pretty=format:%s",
164 ],
165 )?;
166 Ok(output.lines().map(str::to_string).collect())
167}
168
169pub fn get_commit_messages_between(from: &str, to: &str) -> Result<Vec<String>> {
171 get_commit_messages_between_in(&cwd_or_dot(), from, to)
172}
173
174pub fn get_commit_messages_between_in(cwd: &Path, from: &str, to: &str) -> Result<Vec<String>> {
176 let output = git_output_in(
177 cwd,
178 &[
179 "-c",
180 "log.showSignature=false",
181 "log",
182 "--pretty=format:%s",
183 &format!("{from}..{to}"),
184 ],
185 )?;
186 Ok(output.lines().map(str::to_string).collect())
187}
188
189pub fn get_current_branch() -> Result<String> {
191 get_current_branch_in(&cwd_or_dot())
192}
193
194pub fn get_current_branch_in(cwd: &Path) -> Result<String> {
196 git_output_in(cwd, &["rev-parse", "--abbrev-ref", "HEAD"])
197}
198
199pub fn has_commits_since_tag(tag: &str) -> Result<bool> {
201 has_commits_since_tag_in(&cwd_or_dot(), tag)
202}
203
204pub fn has_commits_since_tag_in(cwd: &Path, tag: &str) -> Result<bool> {
206 let range = format!("{}..HEAD", tag);
207 let output = git_output_in(
208 cwd,
209 &["-c", "log.showSignature=false", "log", "--oneline", &range],
210 )?;
211 Ok(!output.is_empty())
212}
213
214pub fn get_short_commit() -> Result<String> {
216 get_short_commit_in(&cwd_or_dot())
217}
218
219pub fn get_short_commit_in(cwd: &Path) -> Result<String> {
221 git_output_in(cwd, &["rev-parse", "--short", "HEAD"])
222}
223
224pub const SHORT_COMMIT_LEN: usize = 7;
230
231pub fn short_commit_str(commit: &str) -> String {
242 if commit.len() > SHORT_COMMIT_LEN {
243 commit[..SHORT_COMMIT_LEN].to_string()
244 } else {
245 commit.to_string()
246 }
247}
248
249pub fn get_head_commit() -> Result<String> {
256 get_head_commit_in(&cwd_or_dot())
257}
258
259pub fn get_head_commit_in(cwd: &Path) -> Result<String> {
261 git_output_in(cwd, &["rev-parse", "HEAD"])
262}
263
264pub fn has_changes_since(tag: &str, path: &str) -> Result<bool> {
266 has_changes_since_in(&cwd_or_dot(), tag, path)
267}
268
269pub fn has_changes_since_in(cwd: &Path, tag: &str, path: &str) -> Result<bool> {
271 let output = git_output_in(
272 cwd,
273 &["diff", "--name-only", &format!("{}..HEAD", tag), "--", path],
274 )?;
275 Ok(!output.is_empty())
276}
277
278pub fn get_last_commit_messages_path(count: usize, path: &str) -> Result<Vec<String>> {
280 get_last_commit_messages_path_in(&cwd_or_dot(), count, path)
281}
282
283pub fn get_last_commit_messages_path_in(
285 cwd: &Path,
286 count: usize,
287 path: &str,
288) -> Result<Vec<String>> {
289 let output = git_output_in(
290 cwd,
291 &[
292 "-c",
293 "log.showSignature=false",
294 "log",
295 &format!("-{count}"),
296 "--pretty=format:%s",
297 "--",
298 path,
299 ],
300 )?;
301 Ok(output.lines().map(str::to_string).collect())
302}
303
304pub fn get_commit_messages_between_path(from: &str, to: &str, path: &str) -> Result<Vec<String>> {
306 get_commit_messages_between_path_in(&cwd_or_dot(), from, to, path)
307}
308
309pub fn get_commit_messages_between_path_in(
311 cwd: &Path,
312 from: &str,
313 to: &str,
314 path: &str,
315) -> Result<Vec<String>> {
316 let output = git_output_in(
317 cwd,
318 &[
319 "-c",
320 "log.showSignature=false",
321 "log",
322 "--pretty=format:%s",
323 &format!("{from}..{to}"),
324 "--",
325 path,
326 ],
327 )?;
328 Ok(output.lines().map(str::to_string).collect())
329}
330
331pub fn stage_and_commit(files: &[&str], message: &str) -> Result<()> {
333 stage_and_commit_in(&cwd_or_dot(), files, message)
334}
335
336pub fn stage_and_commit_in(cwd: &Path, files: &[&str], message: &str) -> Result<()> {
338 let mut args = vec!["add", "--"];
339 args.extend(files.iter().copied());
340 git_output_in(cwd, &args)?;
341 git_output_in(cwd, &["commit", "-m", message])?;
342 Ok(())
343}
344
345pub fn log_subjects_for_range(
356 workspace_root: &std::path::Path,
357 range: &str,
358 rel_path: &str,
359) -> Result<Vec<String>> {
360 let out = Command::new("git")
361 .arg("-C")
362 .arg(workspace_root)
363 .args([
364 "-c",
365 "log.showSignature=false",
366 "log",
367 "--pretty=format:%B%x1e",
368 range,
369 "--",
370 rel_path,
371 ])
372 .output()?;
373 if !out.status.success() {
374 return Ok(Vec::new());
376 }
377 let text = String::from_utf8_lossy(&out.stdout);
378 Ok(text
379 .split('\x1e')
380 .map(|s| s.trim().to_string())
381 .filter(|s| !s.is_empty())
382 .collect())
383}
384
385pub fn add_path_in(workspace_root: &std::path::Path, rel: &std::path::Path) -> Result<()> {
387 let out = Command::new("git")
388 .arg("-C")
389 .arg(workspace_root)
390 .arg("add")
391 .arg(rel)
392 .output()
393 .context("failed to invoke git add")?;
394 if !out.status.success() {
395 let stderr_raw = String::from_utf8_lossy(&out.stderr);
396 let raw = format!("git add {} failed: {}", rel.display(), stderr_raw.trim());
397 bail!("{}", crate::redact::redact_process_env(&raw));
398 }
399 Ok(())
400}
401
402pub fn commit_in(workspace_root: &std::path::Path, message: &str, sign: bool) -> Result<()> {
405 let mut cmd = Command::new("git");
406 cmd.arg("-C").arg(workspace_root).arg("commit");
407 if sign {
408 cmd.arg("-S");
409 }
410 cmd.arg("-m").arg(message);
411 let out = cmd.output().context("failed to invoke git commit")?;
412 if !out.status.success() {
413 let stderr_raw = String::from_utf8_lossy(&out.stderr);
414 let raw = format!("git commit failed: {}", stderr_raw.trim());
415 bail!("{}", crate::redact::redact_process_env(&raw));
416 }
417 Ok(())
418}
419
420pub fn paths_changed_since_tag(tag: &str, paths: &[&str]) -> Result<bool> {
425 paths_changed_since_tag_in(&cwd_or_dot(), tag, paths)
426}
427
428pub fn paths_changed_since_tag_in(cwd: &Path, tag: &str, paths: &[&str]) -> Result<bool> {
430 let mut args: Vec<String> = vec![
431 "diff".to_string(),
432 "--name-only".to_string(),
433 format!("{tag}..HEAD"),
434 "--".to_string(),
435 ];
436 for p in paths {
437 args.push((*p).to_string());
438 }
439 let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
440 let output = Command::new("git")
441 .current_dir(cwd)
442 .args(&arg_refs)
443 .output()?;
444 if output.status.success() {
445 Ok(!String::from_utf8_lossy(&output.stdout).trim().is_empty())
446 } else {
447 Ok(false)
448 }
449}
450
451pub fn head_commit_hash_in(repo: &std::path::Path) -> Result<String> {
456 let out = Command::new("git")
457 .arg("-C")
458 .arg(repo)
459 .args(["rev-parse", "HEAD"])
460 .output()
461 .context("failed to invoke git rev-parse HEAD")?;
462 if !out.status.success() {
463 let stderr_raw = String::from_utf8_lossy(&out.stderr);
464 let raw = format!("git rev-parse HEAD failed: {}", stderr_raw.trim());
465 bail!("{}", crate::redact::redact_process_env(&raw));
466 }
467 Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
468}
469
470pub fn head_commit_timestamp_in(repo: &std::path::Path) -> Result<i64> {
474 let out = Command::new("git")
475 .arg("-C")
476 .arg(repo)
477 .args(["log", "-1", "--format=%ct", "HEAD"])
478 .output()
479 .context("failed to invoke git log -1 --format=%ct HEAD")?;
480 if !out.status.success() {
481 let stderr_raw = String::from_utf8_lossy(&out.stderr);
482 let raw = format!("git log -1 --format=%ct HEAD failed: {}", stderr_raw.trim());
483 bail!("{}", crate::redact::redact_process_env(&raw));
484 }
485 let text = String::from_utf8_lossy(&out.stdout).trim().to_string();
486 text.parse::<i64>()
487 .with_context(|| format!("git log --format=%ct returned non-i64 timestamp: {}", text))
488}
489
490#[cfg(test)]
491mod tests {
492 use super::*;
493 use std::process::Command;
494
495 fn init_repo_with_commits(dir: &Path, files: &[&str]) {
496 let run = |args: &[&str]| {
497 let out = Command::new("git")
498 .args(args)
499 .current_dir(dir)
500 .env("GIT_AUTHOR_NAME", "t")
501 .env("GIT_AUTHOR_EMAIL", "t@t.com")
502 .env("GIT_COMMITTER_NAME", "t")
503 .env("GIT_COMMITTER_EMAIL", "t@t.com")
504 .output()
505 .unwrap();
506 assert!(out.status.success(), "git {args:?} failed");
507 };
508 run(&["init"]);
509 run(&["config", "user.email", "t@t.com"]);
510 run(&["config", "user.name", "t"]);
511 for (i, f) in files.iter().enumerate() {
512 std::fs::write(dir.join(f), format!("c{i}")).unwrap();
513 run(&["add", "."]);
514 run(&["commit", "-m", &format!("commit-{i}: {f}")]);
515 }
516 }
517
518 #[test]
519 fn get_head_commit_in_returns_tempdirs_head_sha() {
520 let tmp = tempfile::tempdir().unwrap();
521 init_repo_with_commits(tmp.path(), &["a"]);
522 let expected = String::from_utf8(
523 Command::new("git")
524 .args(["rev-parse", "HEAD"])
525 .current_dir(tmp.path())
526 .output()
527 .unwrap()
528 .stdout,
529 )
530 .unwrap()
531 .trim()
532 .to_string();
533 let sha = get_head_commit_in(tmp.path()).unwrap();
534 assert_eq!(sha, expected);
535 }
536
537 #[test]
538 fn get_short_commit_in_returns_tempdirs_short_sha() {
539 let tmp = tempfile::tempdir().unwrap();
540 init_repo_with_commits(tmp.path(), &["a"]);
541 let expected = String::from_utf8(
542 Command::new("git")
543 .args(["rev-parse", "--short", "HEAD"])
544 .current_dir(tmp.path())
545 .output()
546 .unwrap()
547 .stdout,
548 )
549 .unwrap()
550 .trim()
551 .to_string();
552 let short = get_short_commit_in(tmp.path()).unwrap();
553 assert_eq!(short, expected);
554 }
555
556 #[test]
557 fn has_commits_since_tag_in_returns_false_when_tag_is_head() {
558 let tmp = tempfile::tempdir().unwrap();
559 let dir = tmp.path();
560 init_repo_with_commits(dir, &["a"]);
561 let run = |args: &[&str]| {
562 Command::new("git")
563 .args(args)
564 .current_dir(dir)
565 .env("GIT_AUTHOR_NAME", "t")
566 .env("GIT_AUTHOR_EMAIL", "t@t.com")
567 .env("GIT_COMMITTER_NAME", "t")
568 .env("GIT_COMMITTER_EMAIL", "t@t.com")
569 .output()
570 .unwrap();
571 };
572 run(&["tag", "v1.0.0"]);
573 assert!(!has_commits_since_tag_in(dir, "v1.0.0").unwrap());
574 }
575
576 #[test]
577 fn get_current_branch_in_returns_branch_name() {
578 let tmp = tempfile::tempdir().unwrap();
579 let dir = tmp.path();
580 let run = |args: &[&str]| {
581 let out = Command::new("git")
582 .args(args)
583 .current_dir(dir)
584 .env("GIT_AUTHOR_NAME", "t")
585 .env("GIT_AUTHOR_EMAIL", "t@t.com")
586 .env("GIT_COMMITTER_NAME", "t")
587 .env("GIT_COMMITTER_EMAIL", "t@t.com")
588 .output()
589 .unwrap();
590 assert!(out.status.success(), "git {args:?} failed");
591 };
592 run(&["-c", "init.defaultBranch=t1-test-branch", "init"]);
593 run(&["config", "user.email", "t@t.com"]);
594 run(&["config", "user.name", "t"]);
595 std::fs::write(dir.join("a"), "1").unwrap();
596 run(&["add", "."]);
597 run(&["commit", "-m", "c1"]);
598 let branch = get_current_branch_in(dir).unwrap();
599 assert_eq!(branch, "t1-test-branch");
600 }
601}