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
19pub fn parse_commit_output(output: &str) -> Vec<Commit> {
30 if output.is_empty() {
31 return vec![];
32 }
33 output
34 .split('\x1e')
35 .filter(|record| !record.trim().is_empty())
36 .filter_map(|record| {
37 let fields: Vec<&str> = record.split('\x1f').collect();
38 if fields.len() >= 5 {
39 Some(Commit {
40 hash: fields[0].trim().to_string(),
41 short_hash: fields[1].to_string(),
42 message: fields[2].to_string(),
43 author_name: fields[3].to_string(),
44 author_email: fields[4].to_string(),
45 body: fields.get(5).unwrap_or(&"").trim().to_string(),
46 })
47 } else {
48 None
49 }
50 })
51 .collect()
52}
53
54fn cwd_or_dot() -> PathBuf {
55 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
56}
57
58pub fn get_commits_between(from: &str, to: &str, path_filter: Option<&str>) -> Result<Vec<Commit>> {
60 get_commits_between_in(&cwd_or_dot(), from, to, path_filter)
61}
62
63pub fn get_commits_between_in(
65 cwd: &Path,
66 from: &str,
67 to: &str,
68 path_filter: Option<&str>,
69) -> Result<Vec<Commit>> {
70 get_commits_between_paths_in(
71 cwd,
72 from,
73 to,
74 &path_filter
75 .into_iter()
76 .map(String::from)
77 .collect::<Vec<_>>(),
78 )
79}
80
81pub fn get_commits_between_paths(from: &str, to: &str, paths: &[String]) -> Result<Vec<Commit>> {
83 get_commits_between_paths_in(&cwd_or_dot(), from, to, paths)
84}
85
86pub fn get_commits_between_paths_in(
88 cwd: &Path,
89 from: &str,
90 to: &str,
91 paths: &[String],
92) -> Result<Vec<Commit>> {
93 let range = format!("{}..{}", from, to);
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 range,
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_in(cwd, &arg_refs)?;
109 Ok(parse_commit_output(&output))
110}
111
112pub fn get_all_commits(path_filter: Option<&str>) -> Result<Vec<Commit>> {
115 get_all_commits_in(&cwd_or_dot(), path_filter)
116}
117
118pub fn get_all_commits_in(cwd: &Path, path_filter: Option<&str>) -> Result<Vec<Commit>> {
120 get_all_commits_paths_in(
121 cwd,
122 &path_filter
123 .into_iter()
124 .map(String::from)
125 .collect::<Vec<_>>(),
126 )
127}
128
129pub fn get_all_commits_paths(paths: &[String]) -> Result<Vec<Commit>> {
131 get_all_commits_paths_in(&cwd_or_dot(), paths)
132}
133
134pub fn get_all_commits_paths_in(cwd: &Path, paths: &[String]) -> Result<Vec<Commit>> {
136 let mut args = vec![
137 "-c".to_string(),
138 "log.showSignature=false".to_string(),
139 "log".to_string(),
140 "--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e".to_string(),
141 "HEAD".to_string(),
142 ];
143 if !paths.is_empty() {
144 args.push("--".to_string());
145 for p in paths {
146 args.push(p.clone());
147 }
148 }
149 let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
150 let output = git_output_in(cwd, &arg_refs)?;
151 Ok(parse_commit_output(&output))
152}
153
154#[derive(Debug, Clone)]
160pub struct CommitWithFiles {
161 pub commit: Commit,
163 pub files: Vec<String>,
165}
166
167pub fn parse_commit_output_with_files(output: &str) -> Vec<CommitWithFiles> {
184 if output.is_empty() {
185 return vec![];
186 }
187 let segments: Vec<&str> = output.split('\x1e').collect();
188 let mut out: Vec<CommitWithFiles> = Vec::new();
189 for (idx, seg) in segments.iter().enumerate() {
195 let metadata = if idx == 0 {
203 seg.trim_start_matches(['\n', '\r']).to_string()
204 } else {
205 let lines: Vec<&str> = seg.split('\n').collect();
206 match lines.iter().position(|line| line.contains('\x1f')) {
207 Some(start) => lines[start..].join("\n"),
208 None => String::new(),
209 }
210 };
211 if metadata.trim().is_empty() {
212 continue;
213 }
214 let commits = parse_commit_output(&metadata);
215 let Some(commit) = commits.into_iter().next() else {
216 continue;
217 };
218 let files = match segments.get(idx + 1) {
221 Some(next) => next
222 .split('\n')
223 .map(str::trim)
224 .take_while(|line| !line.contains('\x1f'))
225 .filter(|line| !line.is_empty())
226 .map(str::to_string)
227 .collect(),
228 None => Vec::new(),
229 };
230 out.push(CommitWithFiles { commit, files });
231 }
232 out
233}
234
235pub fn get_commits_between_paths_with_files_in(
239 cwd: &Path,
240 from: &str,
241 to: &str,
242 paths: &[String],
243) -> Result<Vec<CommitWithFiles>> {
244 let range = format!("{}..{}", from, to);
245 let mut args = vec![
246 "-c".to_string(),
247 "log.showSignature=false".to_string(),
248 "log".to_string(),
249 "--name-only".to_string(),
250 "--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e".to_string(),
251 range,
252 ];
253 if !paths.is_empty() {
254 args.push("--".to_string());
255 for p in paths {
256 args.push(p.clone());
257 }
258 }
259 let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
260 let output = git_output_in(cwd, &arg_refs)?;
261 Ok(parse_commit_output_with_files(&output))
262}
263
264pub fn get_all_commits_paths_with_files_in(
266 cwd: &Path,
267 paths: &[String],
268) -> Result<Vec<CommitWithFiles>> {
269 let mut args = vec![
270 "-c".to_string(),
271 "log.showSignature=false".to_string(),
272 "log".to_string(),
273 "--name-only".to_string(),
274 "--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e".to_string(),
275 "HEAD".to_string(),
276 ];
277 if !paths.is_empty() {
278 args.push("--".to_string());
279 for p in paths {
280 args.push(p.clone());
281 }
282 }
283 let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
284 let output = git_output_in(cwd, &arg_refs)?;
285 Ok(parse_commit_output_with_files(&output))
286}
287
288pub fn get_commits_reachable_paths_in(
293 cwd: &Path,
294 rev: &str,
295 paths: &[String],
296) -> Result<Vec<Commit>> {
297 let mut args = vec![
298 "-c".to_string(),
299 "log.showSignature=false".to_string(),
300 "log".to_string(),
301 "--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e".to_string(),
302 rev.to_string(),
303 ];
304 if !paths.is_empty() {
305 args.push("--".to_string());
306 for p in paths {
307 args.push(p.clone());
308 }
309 }
310 let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
311 let output = git_output_in(cwd, &arg_refs)?;
312 Ok(parse_commit_output(&output))
313}
314
315pub fn get_commits_reachable_paths_with_files_in(
317 cwd: &Path,
318 rev: &str,
319 paths: &[String],
320) -> Result<Vec<CommitWithFiles>> {
321 let mut args = vec![
322 "-c".to_string(),
323 "log.showSignature=false".to_string(),
324 "log".to_string(),
325 "--name-only".to_string(),
326 "--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e".to_string(),
327 rev.to_string(),
328 ];
329 if !paths.is_empty() {
330 args.push("--".to_string());
331 for p in paths {
332 args.push(p.clone());
333 }
334 }
335 let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
336 let output = git_output_in(cwd, &arg_refs)?;
337 Ok(parse_commit_output_with_files(&output))
338}
339
340pub fn get_last_commit_messages(count: usize) -> Result<Vec<String>> {
342 get_last_commit_messages_in(&cwd_or_dot(), count)
343}
344
345pub fn get_last_commit_messages_in(cwd: &Path, count: usize) -> Result<Vec<String>> {
347 let output = git_output_in(
348 cwd,
349 &[
350 "-c",
351 "log.showSignature=false",
352 "log",
353 &format!("-{count}"),
354 "--pretty=format:%s",
355 ],
356 )?;
357 Ok(output.lines().map(str::to_string).collect())
358}
359
360pub fn get_commit_messages_between(from: &str, to: &str) -> Result<Vec<String>> {
362 get_commit_messages_between_in(&cwd_or_dot(), from, to)
363}
364
365pub fn get_commit_messages_between_in(cwd: &Path, from: &str, to: &str) -> Result<Vec<String>> {
367 let output = git_output_in(
368 cwd,
369 &[
370 "-c",
371 "log.showSignature=false",
372 "log",
373 "--pretty=format:%s",
374 &format!("{from}..{to}"),
375 ],
376 )?;
377 Ok(output.lines().map(str::to_string).collect())
378}
379
380pub fn get_current_branch() -> Result<String> {
382 get_current_branch_in(&cwd_or_dot())
383}
384
385pub fn is_branchlike(name: &str) -> bool {
410 use regex::Regex;
411 use std::sync::OnceLock;
412 static LOCKSTEP: OnceLock<Regex> = OnceLock::new();
413 static PER_CRATE: OnceLock<Regex> = OnceLock::new();
414 let lockstep = LOCKSTEP.get_or_init(|| Regex::new(r"^v\d+\.\d+\.\d+").expect("static regex"));
415 let per_crate =
416 PER_CRATE.get_or_init(|| Regex::new(r"^[^/]+-v\d+\.\d+\.\d+").expect("static regex"));
417 !(lockstep.is_match(name) || per_crate.is_match(name))
418}
419
420pub fn get_current_branch_in(cwd: &Path) -> Result<String> {
434 get_current_branch_in_with_env(cwd, &crate::ProcessEnvSource)
435}
436
437pub fn get_current_branch_in_with_env<E: crate::EnvSource + ?Sized>(
443 cwd: &Path,
444 env: &E,
445) -> Result<String> {
446 if let Ok(name) = git_output_in(cwd, &["symbolic-ref", "--short", "HEAD"]) {
447 return Ok(name);
448 }
449 if let Ok(out) = git_output_in(
450 cwd,
451 &[
452 "for-each-ref",
453 "--points-at",
454 "HEAD",
455 "--format=%(refname:short)",
456 "refs/heads/",
457 ],
458 ) && !out.is_empty()
459 {
460 let branches: Vec<&str> = out.lines().collect();
461 for preferred in ["master", "main"] {
462 if branches.contains(&preferred) {
463 return Ok(preferred.to_string());
464 }
465 }
466 if let Some(first) = branches.first() {
467 return Ok((*first).to_string());
468 }
469 }
470 if let Ok(out) = git_output_in(
471 cwd,
472 &["symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
473 ) && let Some(name) = out.strip_prefix("origin/")
474 {
475 return Ok(name.to_string());
476 }
477 if let Some(name) = env.var("GITHUB_REF_NAME")
478 && !name.is_empty()
479 && is_branchlike(&name)
480 {
481 return Ok(name);
482 }
483 anyhow::bail!(
484 "could not resolve current branch: HEAD is detached and no fallback (points-at-HEAD branches, origin/HEAD, GITHUB_REF_NAME) succeeded"
485 )
486}
487
488pub fn branches_containing_sha_in(cwd: &Path, sha: &str) -> Result<Vec<String>> {
494 let out = git_output_in(
495 cwd,
496 &[
497 "branch",
498 "-r",
499 "--contains",
500 sha,
501 "--format=%(refname:short)",
502 ],
503 )?;
504 Ok(out
505 .lines()
506 .filter_map(|line| line.trim().strip_prefix("origin/").map(str::to_string))
507 .filter(|name| !name.is_empty() && name != "HEAD")
508 .collect())
509}
510
511pub fn has_commits_since_tag(tag: &str) -> Result<bool> {
513 has_commits_since_tag_in(&cwd_or_dot(), tag)
514}
515
516pub fn has_commits_since_tag_in(cwd: &Path, tag: &str) -> Result<bool> {
518 let range = format!("{}..HEAD", tag);
519 let output = git_output_in(
520 cwd,
521 &["-c", "log.showSignature=false", "log", "--oneline", &range],
522 )?;
523 Ok(!output.is_empty())
524}
525
526pub fn count_commits_since_last_tag_in(cwd: &Path, monorepo_prefix: Option<&str>) -> Result<u64> {
546 let match_arg;
555 let mut describe_args: Vec<&str> = vec!["describe", "--tags", "--abbrev=0"];
556 if let Some(prefix) = monorepo_prefix {
557 match_arg = format!("--match={}*", prefix);
558 describe_args.push(&match_arg);
559 }
560 describe_args.push("HEAD");
561 let range = match git_output_in(cwd, &describe_args) {
562 Ok(tag) if !tag.is_empty() => format!("{tag}..HEAD"),
563 _ => "HEAD".to_string(),
564 };
565 let count = match git_output_in(cwd, &["rev-list", "--count", &range]) {
567 Ok(s) => s.trim().parse::<u64>().unwrap_or(0),
568 Err(_) => 0,
569 };
570 Ok(count)
571}
572
573pub fn get_short_commit() -> Result<String> {
575 get_short_commit_in(&cwd_or_dot())
576}
577
578pub fn get_short_commit_in(cwd: &Path) -> Result<String> {
580 git_output_in(cwd, &["rev-parse", "--short", "HEAD"])
581}
582
583pub const SHORT_COMMIT_LEN: usize = 7;
589
590pub fn short_commit_str(commit: &str) -> String {
601 if commit.len() > SHORT_COMMIT_LEN {
602 commit[..SHORT_COMMIT_LEN].to_string()
603 } else {
604 commit.to_string()
605 }
606}
607
608pub fn get_head_commit() -> Result<String> {
615 get_head_commit_in(&cwd_or_dot())
616}
617
618pub fn get_head_commit_in(cwd: &Path) -> Result<String> {
620 git_output_in(cwd, &["rev-parse", "HEAD"])
621}
622
623pub fn has_changes_since(tag: &str, path: &str) -> Result<bool> {
625 has_changes_since_in(&cwd_or_dot(), tag, path)
626}
627
628pub fn has_changes_since_in(cwd: &Path, tag: &str, path: &str) -> Result<bool> {
630 let output = git_output_in(
631 cwd,
632 &["diff", "--name-only", &format!("{}..HEAD", tag), "--", path],
633 )?;
634 Ok(!output.is_empty())
635}
636
637pub fn get_last_commit_messages_path(count: usize, path: &str) -> Result<Vec<String>> {
639 get_last_commit_messages_path_in(&cwd_or_dot(), count, path)
640}
641
642pub fn get_last_commit_messages_path_in(
644 cwd: &Path,
645 count: usize,
646 path: &str,
647) -> Result<Vec<String>> {
648 let output = git_output_in(
649 cwd,
650 &[
651 "-c",
652 "log.showSignature=false",
653 "log",
654 &format!("-{count}"),
655 "--pretty=format:%s",
656 "--",
657 path,
658 ],
659 )?;
660 Ok(output.lines().map(str::to_string).collect())
661}
662
663pub fn get_commit_messages_between_path(from: &str, to: &str, path: &str) -> Result<Vec<String>> {
665 get_commit_messages_between_path_in(&cwd_or_dot(), from, to, path)
666}
667
668pub fn get_commit_messages_between_path_in(
670 cwd: &Path,
671 from: &str,
672 to: &str,
673 path: &str,
674) -> Result<Vec<String>> {
675 let output = git_output_in(
676 cwd,
677 &[
678 "-c",
679 "log.showSignature=false",
680 "log",
681 "--pretty=format:%s",
682 &format!("{from}..{to}"),
683 "--",
684 path,
685 ],
686 )?;
687 Ok(output.lines().map(str::to_string).collect())
688}
689
690pub fn stage_and_commit(files: &[&str], message: &str) -> Result<bool> {
698 stage_and_commit_in(&cwd_or_dot(), files, message)
699}
700
701pub fn stage_and_commit_in(cwd: &Path, files: &[&str], message: &str) -> Result<bool> {
703 let mut args = vec!["add", "--"];
704 args.extend(files.iter().copied());
705 git_output_in(cwd, &args)?;
706 let diff = Command::new("git")
712 .current_dir(cwd)
713 .args(["diff", "--cached", "--quiet", "--"])
714 .args(files)
715 .env("GIT_TERMINAL_PROMPT", "0")
716 .env("LC_ALL", "C")
717 .status()?;
718 if diff.success() {
719 return Ok(false);
720 }
721 git_output_in(cwd, &["commit", "-m", message])?;
722 Ok(true)
723}
724
725pub fn log_subjects_for_range(
736 workspace_root: &std::path::Path,
737 range: &str,
738 rel_path: &str,
739) -> Result<Vec<String>> {
740 let out = Command::new("git")
741 .arg("-C")
742 .arg(workspace_root)
743 .args([
744 "-c",
745 "log.showSignature=false",
746 "log",
747 "--pretty=format:%B%x1e",
748 range,
749 "--",
750 rel_path,
751 ])
752 .env("GIT_TERMINAL_PROMPT", "0")
753 .env("LC_ALL", "C")
754 .output()?;
755 if !out.status.success() {
756 return Ok(Vec::new());
758 }
759 let text = String::from_utf8_lossy(&out.stdout);
760 Ok(text
761 .split('\x1e')
762 .map(|s| s.trim().to_string())
763 .filter(|s| !s.is_empty())
764 .collect())
765}
766
767pub fn add_path_in(workspace_root: &std::path::Path, rel: &std::path::Path) -> Result<()> {
769 let out = Command::new("git")
770 .arg("-C")
771 .arg(workspace_root)
772 .arg("add")
773 .arg(rel)
774 .env("GIT_TERMINAL_PROMPT", "0")
775 .env("LC_ALL", "C")
776 .output()
777 .context("failed to invoke git add")?;
778 if !out.status.success() {
779 let stderr_raw = String::from_utf8_lossy(&out.stderr);
780 let raw = format!("git add {} failed: {}", rel.display(), stderr_raw.trim());
781 bail!("{}", crate::redact::redact_process_env(&raw));
782 }
783 Ok(())
784}
785
786pub fn commit_in(workspace_root: &std::path::Path, message: &str, sign: bool) -> Result<()> {
789 let mut cmd = Command::new("git");
790 cmd.arg("-C").arg(workspace_root).arg("commit");
791 if sign {
792 cmd.arg("-S");
793 }
794 cmd.arg("-m")
795 .arg(message)
796 .env("GIT_TERMINAL_PROMPT", "0")
797 .env("LC_ALL", "C");
798 let out = cmd.output().context("failed to invoke git commit")?;
799 if !out.status.success() {
800 let stderr_raw = String::from_utf8_lossy(&out.stderr);
801 let raw = format!("git commit failed: {}", stderr_raw.trim());
802 bail!("{}", crate::redact::redact_process_env(&raw));
803 }
804 Ok(())
805}
806
807pub fn paths_changed_since_tag(tag: &str, paths: &[&str]) -> Result<bool> {
812 paths_changed_since_tag_in(&cwd_or_dot(), tag, paths)
813}
814
815pub fn paths_changed_since_tag_in(cwd: &Path, tag: &str, paths: &[&str]) -> Result<bool> {
817 let mut args: Vec<String> = vec![
818 "diff".to_string(),
819 "--name-only".to_string(),
820 format!("{tag}..HEAD"),
821 "--".to_string(),
822 ];
823 for p in paths {
824 args.push((*p).to_string());
825 }
826 let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
827 let output = Command::new("git")
828 .current_dir(cwd)
829 .args(&arg_refs)
830 .env("GIT_TERMINAL_PROMPT", "0")
831 .env("LC_ALL", "C")
832 .output()?;
833 if output.status.success() {
834 Ok(!String::from_utf8_lossy(&output.stdout).trim().is_empty())
835 } else {
836 Ok(false)
837 }
838}
839
840pub fn head_commit_hash_in(repo: &std::path::Path) -> Result<String> {
845 let out = Command::new("git")
846 .arg("-C")
847 .arg(repo)
848 .args(["rev-parse", "HEAD"])
849 .env("GIT_TERMINAL_PROMPT", "0")
850 .env("LC_ALL", "C")
851 .output()
852 .context("failed to invoke git rev-parse HEAD")?;
853 if !out.status.success() {
854 let stderr_raw = String::from_utf8_lossy(&out.stderr);
855 let raw = format!("git rev-parse HEAD failed: {}", stderr_raw.trim());
856 bail!("{}", crate::redact::redact_process_env(&raw));
857 }
858 Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
859}
860
861pub fn rev_parse_in(cwd: &Path, rev: &str) -> Result<String> {
866 git_output_in(cwd, &["rev-parse", rev])
867}
868
869pub fn rev_verify_commit_in(cwd: &Path, rev: &str) -> Result<String> {
875 git_output_in(
876 cwd,
877 &["rev-parse", "--verify", &format!("{}^{{commit}}", rev)],
878 )
879}
880
881pub fn commits_between_in(cwd: &Path, sha: &str) -> Result<Vec<String>> {
886 let range = format!("{}..HEAD", sha);
887 let out = git_output_in(cwd, &["rev-list", &range])?;
888 if out.is_empty() {
889 return Ok(Vec::new());
890 }
891 Ok(out.lines().map(|s| s.trim().to_string()).collect())
892}
893
894pub fn commit_subject_in(cwd: &Path, sha: &str) -> Result<String> {
898 git_output_in(
899 cwd,
900 &[
901 "-c",
902 "log.showSignature=false",
903 "log",
904 "-1",
905 "--format=%s",
906 sha,
907 ],
908 )
909}
910
911pub fn commits_with_subjects_in(cwd: &Path, sha: &str) -> Result<Vec<(String, String)>> {
918 let range = format!("{}..HEAD", sha);
919 let out = git_output_in(
920 cwd,
921 &[
922 "-c",
923 "log.showSignature=false",
924 "log",
925 "--format=%H%x1f%s",
926 &range,
927 ],
928 )?;
929 if out.is_empty() {
930 return Ok(Vec::new());
931 }
932 Ok(out
933 .lines()
934 .filter_map(|line| {
935 let mut parts = line.splitn(2, '\x1f');
936 let sha = parts.next()?.trim().to_string();
937 let subj = parts.next().unwrap_or("").to_string();
938 if sha.is_empty() {
939 None
940 } else {
941 Some((sha, subj))
942 }
943 })
944 .collect())
945}
946
947#[derive(Debug, Clone, Default)]
960pub struct CommitterIdentity {
961 pub name: Option<String>,
962 pub email: Option<String>,
963}
964
965impl CommitterIdentity {
966 pub fn default_for_rollback() -> Self {
972 let host = std::env::var("HOSTNAME")
973 .ok()
974 .or_else(|| std::env::var("COMPUTERNAME").ok())
975 .and_then(|h| h.split('.').next().map(str::to_string))
976 .filter(|h| !h.is_empty())
977 .unwrap_or_else(|| "localhost".to_string());
978 Self {
979 name: Some("anodize-rollback".to_string()),
980 email: Some(format!("anodize-rollback@{host}")),
981 }
982 }
983
984 fn apply_to(&self, cmd: &mut Command) {
985 if let Some(n) = &self.name {
986 cmd.env("GIT_AUTHOR_NAME", n).env("GIT_COMMITTER_NAME", n);
987 }
988 if let Some(e) = &self.email {
989 cmd.env("GIT_AUTHOR_EMAIL", e).env("GIT_COMMITTER_EMAIL", e);
990 }
991 }
992}
993
994fn read_git_identity(cwd: &Path) -> (Option<String>, Option<String>) {
1000 let one = |key: &str| -> Option<String> {
1001 let out = Command::new("git")
1002 .current_dir(cwd)
1003 .args(["config", "--get", key])
1004 .env("LC_ALL", "C")
1005 .env("GIT_TERMINAL_PROMPT", "0")
1006 .output()
1007 .ok()?;
1008 if !out.status.success() {
1009 return None;
1010 }
1011 let value = String::from_utf8_lossy(&out.stdout).trim().to_string();
1012 if value.is_empty() { None } else { Some(value) }
1013 };
1014 (one("user.name"), one("user.email"))
1015}
1016
1017pub fn resolve_rollback_identity(cwd: &Path) -> CommitterIdentity {
1024 let env_author_set =
1025 std::env::var("GIT_AUTHOR_EMAIL").is_ok() && std::env::var("GIT_AUTHOR_NAME").is_ok();
1026 let env_committer_set =
1027 std::env::var("GIT_COMMITTER_EMAIL").is_ok() && std::env::var("GIT_COMMITTER_NAME").is_ok();
1028 if env_author_set && env_committer_set {
1029 return CommitterIdentity::default();
1030 }
1031 let (name, email) = read_git_identity(cwd);
1032 if name.is_some() && email.is_some() {
1033 return CommitterIdentity::default();
1034 }
1035 CommitterIdentity::default_for_rollback()
1036}
1037
1038pub fn revert_commit_in(
1064 cwd: &Path,
1065 sha: &str,
1066 message: Option<&str>,
1067 identity: &CommitterIdentity,
1068) -> Result<()> {
1069 let status = Command::new("git")
1070 .args(["status", "--porcelain", "--untracked-files=no"])
1071 .current_dir(cwd)
1072 .env("LC_ALL", "C")
1073 .env("GIT_TERMINAL_PROMPT", "0")
1074 .output()
1075 .with_context(|| format!("revert_commit_in: git status in {}", cwd.display()))?;
1076 if !status.status.success() {
1077 let stderr_raw = String::from_utf8_lossy(&status.stderr);
1078 let raw = format!("git status failed: {}", stderr_raw.trim());
1079 bail!("{}", crate::redact::redact_process_env(&raw));
1080 }
1081 if !status.stdout.is_empty() {
1082 bail!(
1083 "refusing to revert in a dirty working tree at {}\nstatus:\n{}",
1084 cwd.display(),
1085 String::from_utf8_lossy(&status.stdout),
1086 );
1087 }
1088
1089 let mut revert_cmd = Command::new("git");
1090 revert_cmd
1091 .current_dir(cwd)
1092 .args(["revert", "--no-edit", sha])
1093 .env("LC_ALL", "C")
1094 .env("GIT_TERMINAL_PROMPT", "0");
1095 identity.apply_to(&mut revert_cmd);
1096 let out = revert_cmd
1097 .output()
1098 .with_context(|| format!("revert_commit_in: git revert in {}", cwd.display()))?;
1099 if !out.status.success() {
1100 let stderr_raw = String::from_utf8_lossy(&out.stderr);
1101 let _ = Command::new("git")
1104 .current_dir(cwd)
1105 .args(["revert", "--abort"])
1106 .env("LC_ALL", "C")
1107 .env("GIT_TERMINAL_PROMPT", "0")
1108 .output();
1109 let raw = format!(
1110 "git revert {sha} hit conflicts and was aborted (working tree restored). \
1111 The bump commit overlaps with later changes — resolve manually, \
1112 or re-run with --mode=reset to force.\nstderr: {}",
1113 stderr_raw.trim()
1114 );
1115 bail!("{}", crate::redact::redact_process_env(&raw));
1116 }
1117 if let Some(msg) = message {
1118 let mut amend_cmd = Command::new("git");
1119 amend_cmd
1120 .current_dir(cwd)
1121 .args(["commit", "--amend", "-m", msg])
1122 .env("LC_ALL", "C")
1123 .env("GIT_TERMINAL_PROMPT", "0");
1124 identity.apply_to(&mut amend_cmd);
1125 let out = amend_cmd.output().with_context(|| {
1126 format!("revert_commit_in: git commit --amend in {}", cwd.display())
1127 })?;
1128 if !out.status.success() {
1129 let stderr_raw = String::from_utf8_lossy(&out.stderr);
1130 let raw = format!("git commit --amend failed: {}", stderr_raw.trim());
1131 bail!("{}", crate::redact::redact_process_env(&raw));
1132 }
1133 }
1134 Ok(())
1135}
1136
1137pub fn reset_hard_in(cwd: &Path, sha: &str) -> Result<()> {
1140 git_output_in(cwd, &["reset", "--hard", sha])?;
1141 Ok(())
1142}
1143
1144pub fn push_branch_in(cwd: &Path, branch: &str) -> Result<()> {
1149 if !super::has_remote_in(cwd, "origin") {
1150 bail!("no 'origin' remote configured, cannot push branch '{branch}'");
1151 }
1152 let refspec = format!("HEAD:refs/heads/{}", branch);
1153 let out = Command::new("git")
1154 .current_dir(cwd)
1155 .args(["push", "origin", &refspec])
1156 .env("GIT_TERMINAL_PROMPT", "0")
1157 .env("LC_ALL", "C")
1158 .output()
1159 .with_context(|| format!("push_branch_in: git push origin {refspec}"))?;
1160 if !out.status.success() {
1161 let stderr_raw = String::from_utf8_lossy(&out.stderr);
1162 let raw = format!("git push origin {} failed: {}", refspec, stderr_raw.trim());
1163 bail!("{}", crate::redact::redact_process_env(&raw));
1164 }
1165 Ok(())
1166}
1167
1168pub fn head_commit_timestamp_in(repo: &std::path::Path) -> Result<i64> {
1172 let out = Command::new("git")
1173 .arg("-C")
1174 .arg(repo)
1175 .args(["log", "-1", "--format=%ct", "HEAD"])
1176 .env("GIT_TERMINAL_PROMPT", "0")
1177 .env("LC_ALL", "C")
1178 .output()
1179 .context("failed to invoke git log -1 --format=%ct HEAD")?;
1180 if !out.status.success() {
1181 let stderr_raw = String::from_utf8_lossy(&out.stderr);
1182 let raw = format!("git log -1 --format=%ct HEAD failed: {}", stderr_raw.trim());
1183 bail!("{}", crate::redact::redact_process_env(&raw));
1184 }
1185 let text = String::from_utf8_lossy(&out.stdout).trim().to_string();
1186 text.parse::<i64>()
1187 .with_context(|| format!("git log --format=%ct returned non-i64 timestamp: {}", text))
1188}
1189
1190#[cfg(test)]
1191mod tests {
1192 use super::*;
1193 use std::process::Command;
1194
1195 fn init_repo_with_commits(dir: &Path, files: &[&str]) {
1196 let run = |args: &[&str]| {
1197 let out = anodizer_core::test_helpers::output_with_spawn_retry(
1198 || {
1199 let mut cmd = Command::new("git");
1200 cmd.args(args)
1201 .current_dir(dir)
1202 .env("GIT_AUTHOR_NAME", "t")
1203 .env("GIT_AUTHOR_EMAIL", "t@t.com")
1204 .env("GIT_COMMITTER_NAME", "t")
1205 .env("GIT_COMMITTER_EMAIL", "t@t.com");
1206 cmd
1207 },
1208 "git",
1209 );
1210 assert!(out.status.success(), "git {args:?} failed");
1211 };
1212 run(&["init"]);
1213 run(&["config", "user.email", "t@t.com"]);
1214 run(&["config", "user.name", "t"]);
1215 for (i, f) in files.iter().enumerate() {
1216 std::fs::write(dir.join(f), format!("c{i}")).unwrap();
1217 run(&["add", "."]);
1218 run(&["commit", "-m", &format!("commit-{i}: {f}")]);
1219 }
1220 }
1221
1222 #[test]
1223 fn get_head_commit_in_returns_tempdirs_head_sha() {
1224 let tmp = tempfile::tempdir().unwrap();
1225 init_repo_with_commits(tmp.path(), &["a"]);
1226 let expected = String::from_utf8(
1227 anodizer_core::test_helpers::output_with_spawn_retry(
1228 || {
1229 let mut cmd = Command::new("git");
1230 cmd.args(["rev-parse", "HEAD"]).current_dir(tmp.path());
1231 cmd
1232 },
1233 "git",
1234 )
1235 .stdout,
1236 )
1237 .unwrap()
1238 .trim()
1239 .to_string();
1240 let sha = get_head_commit_in(tmp.path()).unwrap();
1241 assert_eq!(sha, expected);
1242 }
1243
1244 #[test]
1245 fn get_short_commit_in_returns_tempdirs_short_sha() {
1246 let tmp = tempfile::tempdir().unwrap();
1247 init_repo_with_commits(tmp.path(), &["a"]);
1248 let expected = String::from_utf8(
1249 anodizer_core::test_helpers::output_with_spawn_retry(
1250 || {
1251 let mut cmd = Command::new("git");
1252 cmd.args(["rev-parse", "--short", "HEAD"])
1253 .current_dir(tmp.path());
1254 cmd
1255 },
1256 "git",
1257 )
1258 .stdout,
1259 )
1260 .unwrap()
1261 .trim()
1262 .to_string();
1263 let short = get_short_commit_in(tmp.path()).unwrap();
1264 assert_eq!(short, expected);
1265 }
1266
1267 #[test]
1268 fn has_commits_since_tag_in_returns_false_when_tag_is_head() {
1269 let tmp = tempfile::tempdir().unwrap();
1270 let dir = tmp.path();
1271 init_repo_with_commits(dir, &["a"]);
1272 let run = |args: &[&str]| {
1273 anodizer_core::test_helpers::output_with_spawn_retry(
1274 || {
1275 let mut cmd = Command::new("git");
1276 cmd.args(args)
1277 .current_dir(dir)
1278 .env("GIT_AUTHOR_NAME", "t")
1279 .env("GIT_AUTHOR_EMAIL", "t@t.com")
1280 .env("GIT_COMMITTER_NAME", "t")
1281 .env("GIT_COMMITTER_EMAIL", "t@t.com");
1282 cmd
1283 },
1284 "git",
1285 );
1286 };
1287 run(&["tag", "v1.0.0"]);
1288 assert!(!has_commits_since_tag_in(dir, "v1.0.0").unwrap());
1289 }
1290
1291 fn git_in(dir: &Path, args: &[&str]) {
1292 let out = anodizer_core::test_helpers::output_with_spawn_retry(
1293 || {
1294 let mut cmd = Command::new("git");
1295 cmd.args(args)
1296 .current_dir(dir)
1297 .env("GIT_AUTHOR_NAME", "t")
1298 .env("GIT_AUTHOR_EMAIL", "t@t.com")
1299 .env("GIT_COMMITTER_NAME", "t")
1300 .env("GIT_COMMITTER_EMAIL", "t@t.com");
1301 cmd
1302 },
1303 "git",
1304 );
1305 assert!(out.status.success(), "git {args:?} failed");
1306 }
1307
1308 #[test]
1309 fn count_commits_since_last_tag_counts_commits_after_tag() {
1310 let tmp = tempfile::tempdir().unwrap();
1311 let dir = tmp.path();
1312 init_repo_with_commits(dir, &["a", "b"]);
1314 git_in(dir, &["tag", "v1.0.0"]);
1315 for f in ["c", "d", "e"] {
1316 std::fs::write(dir.join(f), "x").unwrap();
1317 git_in(dir, &["add", "."]);
1318 git_in(dir, &["commit", "-m", f]);
1319 }
1320 assert_eq!(count_commits_since_last_tag_in(dir, None).unwrap(), 3);
1321 }
1322
1323 #[test]
1324 fn count_commits_since_last_tag_resets_on_newer_tag() {
1325 let tmp = tempfile::tempdir().unwrap();
1326 let dir = tmp.path();
1327 init_repo_with_commits(dir, &["a"]);
1328 git_in(dir, &["tag", "v1.0.0"]);
1329 for f in ["b", "c"] {
1330 std::fs::write(dir.join(f), "x").unwrap();
1331 git_in(dir, &["add", "."]);
1332 git_in(dir, &["commit", "-m", f]);
1333 }
1334 assert_eq!(count_commits_since_last_tag_in(dir, None).unwrap(), 2);
1335 git_in(dir, &["tag", "v1.1.0"]);
1337 assert_eq!(count_commits_since_last_tag_in(dir, None).unwrap(), 0);
1338 std::fs::write(dir.join("d"), "x").unwrap();
1339 git_in(dir, &["add", "."]);
1340 git_in(dir, &["commit", "-m", "d"]);
1341 assert_eq!(count_commits_since_last_tag_in(dir, None).unwrap(), 1);
1342 }
1343
1344 #[test]
1345 fn count_commits_since_last_tag_counts_all_when_no_tag() {
1346 let tmp = tempfile::tempdir().unwrap();
1347 let dir = tmp.path();
1348 init_repo_with_commits(dir, &["a", "b", "c"]);
1349 assert_eq!(count_commits_since_last_tag_in(dir, None).unwrap(), 3);
1351 }
1352
1353 #[test]
1354 fn count_commits_since_last_tag_respects_monorepo_prefix() {
1355 let tmp = tempfile::tempdir().unwrap();
1359 let dir = tmp.path();
1360 init_repo_with_commits(dir, &["a"]);
1361 git_in(dir, &["tag", "core/v1.0.0"]); for f in ["b", "c"] {
1363 std::fs::write(dir.join(f), "x").unwrap();
1364 git_in(dir, &["add", "."]);
1365 git_in(dir, &["commit", "-m", f]);
1366 }
1367 git_in(dir, &["tag", "api/v2.0.0"]); std::fs::write(dir.join("d"), "x").unwrap();
1369 git_in(dir, &["add", "."]);
1370 git_in(dir, &["commit", "-m", "d"]);
1371
1372 assert_eq!(
1374 count_commits_since_last_tag_in(dir, Some("core/")).unwrap(),
1375 3,
1376 "must count since the matching-prefix tag, ignoring api/v2.0.0",
1377 );
1378 assert_eq!(
1382 count_commits_since_last_tag_in(dir, None).unwrap(),
1383 1,
1384 "unfiltered count picks the nearest (wrong) subproject tag",
1385 );
1386 }
1387
1388 #[test]
1389 fn get_current_branch_in_returns_branch_name() {
1390 let tmp = tempfile::tempdir().unwrap();
1391 let dir = tmp.path();
1392 let run = |args: &[&str]| {
1393 let out = anodizer_core::test_helpers::output_with_spawn_retry(
1394 || {
1395 let mut cmd = Command::new("git");
1396 cmd.args(args)
1397 .current_dir(dir)
1398 .env("GIT_AUTHOR_NAME", "t")
1399 .env("GIT_AUTHOR_EMAIL", "t@t.com")
1400 .env("GIT_COMMITTER_NAME", "t")
1401 .env("GIT_COMMITTER_EMAIL", "t@t.com");
1402 cmd
1403 },
1404 "git",
1405 );
1406 assert!(out.status.success(), "git {args:?} failed");
1407 };
1408 run(&["-c", "init.defaultBranch=t1-test-branch", "init"]);
1409 run(&["config", "user.email", "t@t.com"]);
1410 run(&["config", "user.name", "t"]);
1411 std::fs::write(dir.join("a"), "1").unwrap();
1412 run(&["add", "."]);
1413 run(&["commit", "-m", "c1"]);
1414 let branch = get_current_branch_in(dir).unwrap();
1415 assert_eq!(branch, "t1-test-branch");
1416 }
1417
1418 #[test]
1419 fn get_current_branch_in_resolves_detached_head_via_points_at() {
1420 let tmp = tempfile::tempdir().unwrap();
1421 let dir = tmp.path();
1422 let run = |args: &[&str]| {
1423 let out = anodizer_core::test_helpers::output_with_spawn_retry(
1424 || {
1425 let mut cmd = Command::new("git");
1426 cmd.args(args)
1427 .current_dir(dir)
1428 .env("GIT_AUTHOR_NAME", "t")
1429 .env("GIT_AUTHOR_EMAIL", "t@t.com")
1430 .env("GIT_COMMITTER_NAME", "t")
1431 .env("GIT_COMMITTER_EMAIL", "t@t.com");
1432 cmd
1433 },
1434 "git",
1435 );
1436 assert!(out.status.success(), "git {args:?} failed");
1437 };
1438 run(&["-c", "init.defaultBranch=master", "init"]);
1439 run(&["config", "user.email", "t@t.com"]);
1440 run(&["config", "user.name", "t"]);
1441 std::fs::write(dir.join("a"), "1").unwrap();
1442 run(&["add", "."]);
1443 run(&["commit", "-m", "c1"]);
1444 let sha = get_head_commit_in(dir).unwrap();
1445 run(&["checkout", "--detach", &sha]);
1446 let branch = get_current_branch_in(dir).unwrap();
1447 assert_eq!(
1448 branch, "master",
1449 "detached HEAD pointing at master must resolve to master, not literal HEAD"
1450 );
1451 }
1452
1453 #[test]
1454 fn is_branchlike_rejects_lockstep_tag_shapes() {
1455 assert!(!is_branchlike("v0.4.5"));
1456 assert!(!is_branchlike("v1.2.3"));
1457 assert!(!is_branchlike("v10.20.30"));
1458 assert!(!is_branchlike("v1.2.3-rc.1"));
1459 assert!(!is_branchlike("v1.2.3+build.42"));
1460 }
1461
1462 #[test]
1463 fn is_branchlike_rejects_per_crate_tag_shapes() {
1464 assert!(!is_branchlike("mycrate-v1.2.3"));
1465 assert!(!is_branchlike("cfgd-operator-v0.4.0"));
1466 assert!(!is_branchlike("anodize-core-v1.2.3-rc.1"));
1467 }
1468
1469 #[test]
1470 fn is_branchlike_accepts_real_branch_names() {
1471 assert!(is_branchlike("master"));
1472 assert!(is_branchlike("main"));
1473 assert!(is_branchlike("publisher-required-config"));
1474 assert!(is_branchlike("release/v1.2.3-prep"));
1475 assert!(is_branchlike("dependabot/cargo/serde-1.0.200"));
1476 }
1477
1478 #[test]
1479 fn is_branchlike_accepts_slashed_branch_with_embedded_version() {
1480 assert!(is_branchlike("feature/fix-v2.0.0"));
1485 assert!(is_branchlike("hotfix/release-v1.0.0-blocker"));
1486 assert!(is_branchlike("user/wip-v3.1.4"));
1487 }
1488
1489 #[test]
1490 fn get_current_branch_in_rejects_tag_shaped_github_ref_name() {
1491 let tmp = tempfile::tempdir().unwrap();
1492 let dir = tmp.path();
1493 let run = |args: &[&str]| {
1494 let out = anodizer_core::test_helpers::output_with_spawn_retry(
1495 || {
1496 let mut cmd = Command::new("git");
1497 cmd.args(args)
1498 .current_dir(dir)
1499 .env("GIT_AUTHOR_NAME", "t")
1500 .env("GIT_AUTHOR_EMAIL", "t@t.com")
1501 .env("GIT_COMMITTER_NAME", "t")
1502 .env("GIT_COMMITTER_EMAIL", "t@t.com");
1503 cmd
1504 },
1505 "git",
1506 );
1507 assert!(out.status.success(), "git {args:?} failed");
1508 };
1509 run(&["-c", "init.defaultBranch=master", "init"]);
1513 run(&["config", "user.email", "t@t.com"]);
1514 run(&["config", "user.name", "t"]);
1515 std::fs::write(dir.join("a"), "1").unwrap();
1516 run(&["add", "."]);
1517 run(&["commit", "-m", "c1"]);
1518 let sha = get_head_commit_in(dir).unwrap();
1519 std::fs::write(dir.join("a"), "2").unwrap();
1522 run(&["add", "."]);
1523 run(&["commit", "-m", "c2"]);
1524 run(&["checkout", "--detach", &sha]);
1525
1526 let env = crate::MapEnvSource::new().with("GITHUB_REF_NAME", "v0.4.5");
1531 let err = get_current_branch_in_with_env(dir, &env).unwrap_err();
1532 assert!(
1533 err.to_string().contains("could not resolve current branch"),
1534 "tag-shaped GITHUB_REF_NAME must trigger bail: {err}"
1535 );
1536
1537 let env = crate::MapEnvSource::new().with("GITHUB_REF_NAME", "mycrate-v1.2.3");
1539 let err = get_current_branch_in_with_env(dir, &env).unwrap_err();
1540 assert!(
1541 err.to_string().contains("could not resolve current branch"),
1542 "per-crate tag GITHUB_REF_NAME must trigger bail: {err}"
1543 );
1544
1545 let env = crate::MapEnvSource::new().with("GITHUB_REF_NAME", "master");
1547 let branch = get_current_branch_in_with_env(dir, &env).unwrap();
1548 assert_eq!(branch, "master");
1549 }
1550
1551 #[test]
1552 fn branches_containing_sha_in_returns_empty_without_remote() {
1553 let tmp = tempfile::tempdir().unwrap();
1554 let dir = tmp.path();
1555 let run = |args: &[&str]| {
1556 let out = anodizer_core::test_helpers::output_with_spawn_retry(
1557 || {
1558 let mut cmd = Command::new("git");
1559 cmd.args(args)
1560 .current_dir(dir)
1561 .env("GIT_AUTHOR_NAME", "t")
1562 .env("GIT_AUTHOR_EMAIL", "t@t.com")
1563 .env("GIT_COMMITTER_NAME", "t")
1564 .env("GIT_COMMITTER_EMAIL", "t@t.com");
1565 cmd
1566 },
1567 "git",
1568 );
1569 assert!(out.status.success(), "git {args:?} failed");
1570 };
1571 run(&["-c", "init.defaultBranch=master", "init"]);
1572 run(&["config", "user.email", "t@t.com"]);
1573 run(&["config", "user.name", "t"]);
1574 std::fs::write(dir.join("a"), "1").unwrap();
1575 run(&["add", "."]);
1576 run(&["commit", "-m", "c1"]);
1577 let sha = get_head_commit_in(dir).unwrap();
1578 let branches = branches_containing_sha_in(dir, &sha).unwrap();
1582 assert!(branches.is_empty(), "no remote → no remote branches");
1583 }
1584
1585 #[test]
1586 fn branches_containing_sha_in_finds_remote_branch_after_push() {
1587 let tmp = tempfile::tempdir().unwrap();
1588 let bare = tempfile::tempdir().unwrap();
1589 let dir = tmp.path();
1590 let run_in = |cwd: &Path, args: &[&str]| {
1591 let out = anodizer_core::test_helpers::output_with_spawn_retry(
1592 || {
1593 let mut cmd = Command::new("git");
1594 cmd.args(args)
1595 .current_dir(cwd)
1596 .env("GIT_AUTHOR_NAME", "t")
1597 .env("GIT_AUTHOR_EMAIL", "t@t.com")
1598 .env("GIT_COMMITTER_NAME", "t")
1599 .env("GIT_COMMITTER_EMAIL", "t@t.com");
1600 cmd
1601 },
1602 "git",
1603 );
1604 assert!(out.status.success(), "git {args:?} failed");
1605 };
1606 run_in(
1607 bare.path(),
1608 &["-c", "init.defaultBranch=master", "init", "--bare"],
1609 );
1610 run_in(dir, &["-c", "init.defaultBranch=master", "init"]);
1611 run_in(dir, &["config", "user.email", "t@t.com"]);
1612 run_in(dir, &["config", "user.name", "t"]);
1613 run_in(
1614 dir,
1615 &["remote", "add", "origin", bare.path().to_str().unwrap()],
1616 );
1617 std::fs::write(dir.join("a"), "1").unwrap();
1618 run_in(dir, &["add", "."]);
1619 run_in(dir, &["commit", "-m", "c1"]);
1620 let sha = get_head_commit_in(dir).unwrap();
1621 run_in(dir, &["push", "-u", "origin", "master"]);
1622
1623 let branches = branches_containing_sha_in(dir, &sha).unwrap();
1624 assert_eq!(branches, vec!["master".to_string()]);
1625 }
1626
1627 #[test]
1628 fn stage_and_commit_in_returns_false_when_no_diff() {
1629 let tmp = tempfile::tempdir().unwrap();
1630 let dir = tmp.path();
1631 init_repo_with_commits(dir, &["a"]);
1632 let created = stage_and_commit_in(dir, &["a"], "chore: should be a no-op").unwrap();
1636 assert!(!created, "no diff → no commit should be created");
1637 let log = anodizer_core::test_helpers::output_with_spawn_retry(
1638 || {
1639 let mut cmd = Command::new("git");
1640 cmd.args(["log", "--oneline"]).current_dir(dir);
1641 cmd
1642 },
1643 "git",
1644 );
1645 let log_text = String::from_utf8_lossy(&log.stdout);
1646 assert!(
1647 !log_text.contains("should be a no-op"),
1648 "stage_and_commit_in must not create a commit when no diff: {log_text}"
1649 );
1650 }
1651
1652 #[test]
1653 fn stage_and_commit_in_returns_true_when_file_changed() {
1654 let tmp = tempfile::tempdir().unwrap();
1655 let dir = tmp.path();
1656 init_repo_with_commits(dir, &["a"]);
1657 std::fs::write(dir.join("a"), "changed").unwrap();
1658 let created = stage_and_commit_in(dir, &["a"], "chore: real change").unwrap();
1659 assert!(created, "real change → commit must be created");
1660 let log = anodizer_core::test_helpers::output_with_spawn_retry(
1661 || {
1662 let mut cmd = Command::new("git");
1663 cmd.args(["log", "-1", "--pretty=%s"]).current_dir(dir);
1664 cmd
1665 },
1666 "git",
1667 );
1668 let subject = String::from_utf8_lossy(&log.stdout).trim().to_string();
1669 assert_eq!(subject, "chore: real change");
1670 }
1671
1672 #[test]
1673 fn git_output_in_error_falls_back_to_stdout_when_stderr_empty() {
1674 let tmp = tempfile::tempdir().unwrap();
1675 let dir = tmp.path();
1676 init_repo_with_commits(dir, &["a"]);
1677 let err = git_output_in(dir, &["commit", "-m", "no-op"]).unwrap_err();
1681 let msg = err.to_string();
1682 assert!(
1683 msg.contains("nothing to commit") || msg.contains("clean"),
1684 "error must include stdout detail when stderr is empty: {msg}"
1685 );
1686 }
1687
1688 #[test]
1694 fn default_for_rollback_populates_both_name_and_email() {
1695 let id = CommitterIdentity::default_for_rollback();
1696 assert_eq!(id.name.as_deref(), Some("anodize-rollback"));
1697 let email = id.email.expect("email must be Some");
1698 assert!(
1699 email.starts_with("anodize-rollback@"),
1700 "email must use the anodize-rollback@<host> shape; got {email}"
1701 );
1702 assert!(!email.ends_with('@'), "host portion must not be empty");
1703 }
1704
1705 #[test]
1712 fn revert_commit_in_uses_injected_identity_envs() {
1713 let tmp = tempfile::tempdir().unwrap();
1714 let dir = tmp.path();
1715 let run_env = |args: &[&str], extra: &[(&str, &str)]| {
1716 let out = anodizer_core::test_helpers::output_with_spawn_retry(
1717 || {
1718 let mut cmd = Command::new("git");
1719 cmd.args(args)
1720 .current_dir(dir)
1721 .env("GIT_AUTHOR_NAME", "bootstrap")
1722 .env("GIT_AUTHOR_EMAIL", "bootstrap@b.com")
1723 .env("GIT_COMMITTER_NAME", "bootstrap")
1724 .env("GIT_COMMITTER_EMAIL", "bootstrap@b.com");
1725 for (k, v) in extra {
1726 cmd.env(k, v);
1727 }
1728 cmd
1729 },
1730 "git",
1731 );
1732 assert!(
1733 out.status.success(),
1734 "git {args:?} failed: {}",
1735 String::from_utf8_lossy(&out.stderr)
1736 );
1737 };
1738 run_env(&["init", "-b", "master"], &[]);
1739 std::fs::write(dir.join("a"), "0").unwrap();
1740 run_env(&["add", "."], &[]);
1741 run_env(&["commit", "-m", "initial"], &[]);
1742 std::fs::write(dir.join("a"), "1").unwrap();
1743 run_env(&["add", "."], &[]);
1744 run_env(&["commit", "-m", "chore(release): v1.0.0"], &[]);
1745 let bump_sha = get_head_commit_in(dir).unwrap();
1746
1747 let identity = CommitterIdentity {
1751 name: Some("rollback-bot".to_string()),
1752 email: Some("rollback-bot@anodize.test".to_string()),
1753 };
1754 revert_commit_in(dir, &bump_sha, Some("chore(release): rollback"), &identity)
1755 .expect("revert with injected identity must succeed");
1756
1757 let out = anodizer_core::test_helpers::output_with_spawn_retry(
1760 || {
1761 let mut cmd = Command::new("git");
1762 cmd.current_dir(dir)
1763 .args(["log", "-1", "--format=%ae"])
1764 .env("GIT_TERMINAL_PROMPT", "0")
1765 .env("LC_ALL", "C");
1766 cmd
1767 },
1768 "git",
1769 );
1770 let author_email = String::from_utf8_lossy(&out.stdout).trim().to_string();
1771 assert_eq!(
1772 author_email, "rollback-bot@anodize.test",
1773 "revert commit must carry the injected committer identity"
1774 );
1775
1776 let cfg = anodizer_core::test_helpers::output_with_spawn_retry(
1779 || {
1780 let mut cmd = Command::new("git");
1781 cmd.current_dir(dir)
1782 .args(["config", "--local", "--get", "user.email"])
1783 .env("GIT_TERMINAL_PROMPT", "0")
1784 .env("LC_ALL", "C");
1785 cmd
1786 },
1787 "git",
1788 );
1789 assert!(
1790 !cfg.status.success() || cfg.stdout.is_empty(),
1791 "revert must not write user.email into the repo's local config; got: {}",
1792 String::from_utf8_lossy(&cfg.stdout)
1793 );
1794 }
1795
1796 #[test]
1801 fn revert_commit_in_aborts_on_conflict_and_leaves_tree_clean() {
1802 let tmp = tempfile::tempdir().unwrap();
1803 let dir = tmp.path();
1804 let run = |args: &[&str]| {
1805 let out = anodizer_core::test_helpers::output_with_spawn_retry(
1806 || {
1807 let mut cmd = Command::new("git");
1808 cmd.args(args)
1809 .current_dir(dir)
1810 .env("GIT_AUTHOR_NAME", "t")
1811 .env("GIT_AUTHOR_EMAIL", "t@t.com")
1812 .env("GIT_COMMITTER_NAME", "t")
1813 .env("GIT_COMMITTER_EMAIL", "t@t.com");
1814 cmd
1815 },
1816 "git",
1817 );
1818 assert!(
1819 out.status.success(),
1820 "git {args:?} failed: {}",
1821 String::from_utf8_lossy(&out.stderr)
1822 );
1823 };
1824 run(&["init", "-b", "master"]);
1825 run(&["config", "user.email", "t@t.com"]);
1826 run(&["config", "user.name", "t"]);
1827 std::fs::write(dir.join("x"), "v1\n").unwrap();
1829 run(&["add", "."]);
1830 run(&["commit", "-m", "initial"]);
1831 std::fs::write(dir.join("x"), "v2\n").unwrap();
1833 run(&["add", "."]);
1834 run(&["commit", "-m", "chore(release): v2"]);
1835 let bump_sha = get_head_commit_in(dir).unwrap();
1836 std::fs::write(dir.join("x"), "v3\n").unwrap();
1840 run(&["add", "."]);
1841 run(&["commit", "-m", "feat: overlap"]);
1842
1843 let identity = CommitterIdentity::default();
1844 let err = revert_commit_in(dir, &bump_sha, None, &identity)
1845 .expect_err("revert against overlapping HEAD must conflict and bail");
1846 let msg = format!("{err}");
1847 assert!(
1848 msg.contains("aborted"),
1849 "bail message must mention abort recovery: {msg}"
1850 );
1851
1852 assert!(
1856 !dir.join(".git/REVERT_HEAD").exists(),
1857 ".git/REVERT_HEAD must be cleaned up after --abort"
1858 );
1859 let status_out = anodizer_core::test_helpers::output_with_spawn_retry(
1860 || {
1861 let mut cmd = Command::new("git");
1862 cmd.args(["status", "--porcelain"]).current_dir(dir);
1863 cmd
1864 },
1865 "git",
1866 );
1867 assert!(
1868 status_out.stdout.is_empty(),
1869 "working tree must be clean after revert --abort; got:\n{}",
1870 String::from_utf8_lossy(&status_out.stdout)
1871 );
1872 }
1873
1874 #[test]
1879 fn commits_with_subjects_in_returns_all_pairs_in_one_call() {
1880 let tmp = tempfile::tempdir().unwrap();
1881 let dir = tmp.path();
1882 let run = |args: &[&str]| {
1883 let out = anodizer_core::test_helpers::output_with_spawn_retry(
1884 || {
1885 let mut cmd = Command::new("git");
1886 cmd.args(args)
1887 .current_dir(dir)
1888 .env("GIT_AUTHOR_NAME", "t")
1889 .env("GIT_AUTHOR_EMAIL", "t@t.com")
1890 .env("GIT_COMMITTER_NAME", "t")
1891 .env("GIT_COMMITTER_EMAIL", "t@t.com");
1892 cmd
1893 },
1894 "git",
1895 );
1896 assert!(out.status.success(), "git {args:?} failed");
1897 };
1898 run(&["init", "-b", "master"]);
1899 run(&["config", "user.email", "t@t.com"]);
1900 run(&["config", "user.name", "t"]);
1901 std::fs::write(dir.join("a"), "0").unwrap();
1902 run(&["add", "."]);
1903 run(&["commit", "-m", "initial"]);
1904 let base = get_head_commit_in(dir).unwrap();
1905 std::fs::write(dir.join("a"), "1").unwrap();
1906 run(&["add", "."]);
1907 run(&["commit", "-m", "feat: A with extra detail"]);
1908 std::fs::write(dir.join("a"), "2").unwrap();
1909 run(&["add", "."]);
1910 run(&["commit", "-m", "fix: B"]);
1911
1912 let pairs = commits_with_subjects_in(dir, &base).unwrap();
1913 assert_eq!(pairs.len(), 2, "two commits sit on top of base");
1914 assert_eq!(pairs[0].1, "fix: B");
1916 assert_eq!(pairs[1].1, "feat: A with extra detail");
1917
1918 let head = get_head_commit_in(dir).unwrap();
1920 assert!(commits_with_subjects_in(dir, &head).unwrap().is_empty());
1921 }
1922
1923 #[test]
1924 fn parse_commit_output_with_files_pairs_each_commit_with_its_files() {
1925 let raw = "h1\x1fs1\x1ffix: B\x1ft\x1ft@t\x1f\x1e\ncrates/cli/main.rs\n\nh0\x1fs0\x1ffeat: A\x1ft\x1ft@t\x1f\x1e\ncrates/core/lib.rs\nCargo.toml\n";
1928 let parsed = parse_commit_output_with_files(raw);
1929 assert_eq!(parsed.len(), 2);
1930 assert_eq!(parsed[0].commit.message, "fix: B");
1931 assert_eq!(parsed[0].files, vec!["crates/cli/main.rs".to_string()]);
1932 assert_eq!(parsed[1].commit.message, "feat: A");
1933 assert_eq!(
1934 parsed[1].files,
1935 vec!["crates/core/lib.rs".to_string(), "Cargo.toml".to_string()]
1936 );
1937 }
1938
1939 #[test]
1940 fn parse_commit_output_with_files_preserves_multiline_body_at_idx_gt_0() {
1941 let body0 = "detail line one\ndetail line two\n\nCo-Authored-By: Bob <bob@b.com>";
1946 let raw = format!(
1947 "h1\x1fs1\x1ffix: B\x1ft\x1ft@t\x1f\x1e\ncrates/cli/main.rs\n\n\
1948 h0\x1fs0\x1ffeat: A\x1ft\x1ft@t\x1f{body0}\x1e\ncrates/core/lib.rs\n"
1949 );
1950 let parsed = parse_commit_output_with_files(&raw);
1951 assert_eq!(parsed.len(), 2);
1952 assert_eq!(parsed[1].commit.message, "feat: A");
1954 assert_eq!(parsed[1].commit.body, body0);
1955 assert!(
1956 parsed[1]
1957 .commit
1958 .body
1959 .contains("Co-Authored-By: Bob <bob@b.com>"),
1960 "multi-line body trailer dropped: {:?}",
1961 parsed[1].commit.body
1962 );
1963 assert_eq!(parsed[1].files, vec!["crates/core/lib.rs".to_string()]);
1964 }
1965
1966 #[test]
1967 fn get_commits_between_paths_with_files_in_reports_touched_files() {
1968 let tmp = tempfile::tempdir().unwrap();
1969 let dir = tmp.path();
1970 let run = |args: &[&str]| {
1971 assert!(
1972 anodizer_core::test_helpers::output_with_spawn_retry(
1973 || {
1974 let mut cmd = Command::new("git");
1975 cmd.args(args)
1976 .current_dir(dir)
1977 .env("GIT_AUTHOR_NAME", "t")
1978 .env("GIT_AUTHOR_EMAIL", "t@t.com")
1979 .env("GIT_COMMITTER_NAME", "t")
1980 .env("GIT_COMMITTER_EMAIL", "t@t.com");
1981 cmd
1982 },
1983 "git",
1984 )
1985 .status
1986 .success()
1987 );
1988 };
1989 run(&["init"]);
1990 run(&["config", "user.email", "t@t.com"]);
1991 run(&["config", "user.name", "t"]);
1992 std::fs::write(dir.join("base"), "0").unwrap();
1993 run(&["add", "."]);
1994 run(&["commit", "-m", "initial"]);
1995 let base = get_head_commit_in(dir).unwrap();
1996 std::fs::create_dir_all(dir.join("crates/core")).unwrap();
1997 std::fs::write(dir.join("crates/core/lib.rs"), "1").unwrap();
1998 run(&["add", "."]);
1999 run(&["commit", "-m", "feat: core"]);
2000
2001 let pairs = get_commits_between_paths_with_files_in(dir, &base, "HEAD", &[]).unwrap();
2002 assert_eq!(pairs.len(), 1);
2003 assert_eq!(pairs[0].commit.message, "feat: core");
2004 assert_eq!(pairs[0].files, vec!["crates/core/lib.rs".to_string()]);
2005 }
2006
2007 #[test]
2008 fn get_commits_between_paths_with_files_in_preserves_multiline_body_for_later_commits() {
2009 let tmp = tempfile::tempdir().unwrap();
2015 let dir = tmp.path();
2016 let run = |args: &[&str]| {
2017 assert!(
2018 anodizer_core::test_helpers::output_with_spawn_retry(
2019 || {
2020 let mut cmd = Command::new("git");
2021 cmd.args(args)
2022 .current_dir(dir)
2023 .env("GIT_AUTHOR_NAME", "t")
2024 .env("GIT_AUTHOR_EMAIL", "t@t.com")
2025 .env("GIT_COMMITTER_NAME", "t")
2026 .env("GIT_COMMITTER_EMAIL", "t@t.com");
2027 cmd
2028 },
2029 "git",
2030 )
2031 .status
2032 .success()
2033 );
2034 };
2035 run(&["init"]);
2036 run(&["config", "user.email", "t@t.com"]);
2037 run(&["config", "user.name", "t"]);
2038 std::fs::write(dir.join("base"), "0").unwrap();
2039 run(&["add", "."]);
2040 run(&["commit", "-m", "initial"]);
2041 let base = get_head_commit_in(dir).unwrap();
2042
2043 std::fs::write(dir.join("a.rs"), "1").unwrap();
2045 run(&["add", "."]);
2046 run(&[
2047 "commit",
2048 "-m",
2049 "feat: with body\n\nfirst body line\nsecond body line\n\nCo-Authored-By: Bob <bob@b.com>",
2050 ]);
2051 std::fs::write(dir.join("b.rs"), "2").unwrap();
2053 run(&["add", "."]);
2054 run(&["commit", "-m", "fix: later"]);
2055
2056 let pairs = get_commits_between_paths_with_files_in(dir, &base, "HEAD", &[]).unwrap();
2057 assert_eq!(pairs.len(), 2);
2058 assert_eq!(pairs[0].commit.message, "fix: later");
2060 let body = &pairs[1].commit.body;
2061 assert!(
2062 body.contains("first body line") && body.contains("second body line"),
2063 "multi-line body truncated for idx>0 commit: {body:?}"
2064 );
2065 assert!(
2066 body.contains("Co-Authored-By: Bob <bob@b.com>"),
2067 "Co-Authored-By trailer dropped for idx>0 commit: {body:?}"
2068 );
2069 }
2070
2071 #[test]
2074 fn parse_commit_output_empty_input_yields_no_commits() {
2075 assert!(parse_commit_output("").is_empty());
2076 }
2077
2078 #[test]
2079 fn parse_commit_output_decodes_all_six_fields() {
2080 let raw =
2082 "abc123def\x1fabc123d\x1ffeat: add thing\x1fAlice\x1falice@x.com\x1fbody text\x1e";
2083 let commits = parse_commit_output(raw);
2084 assert_eq!(commits.len(), 1);
2085 let c = &commits[0];
2086 assert_eq!(c.hash, "abc123def");
2087 assert_eq!(c.short_hash, "abc123d");
2088 assert_eq!(c.message, "feat: add thing");
2089 assert_eq!(c.author_name, "Alice");
2090 assert_eq!(c.author_email, "alice@x.com");
2091 assert_eq!(c.body, "body text");
2092 }
2093
2094 #[test]
2095 fn parse_commit_output_trims_hash_and_body_but_keeps_inner_subject() {
2096 let raw = " abc \x1fabc\x1ffix: keep spaces\x1ft\x1ft@t\x1f\n\nbody\n\x1e";
2099 let commits = parse_commit_output(raw);
2100 assert_eq!(commits.len(), 1);
2101 assert_eq!(commits[0].hash, "abc", "hash is trimmed");
2102 assert_eq!(commits[0].message, "fix: keep spaces", "subject verbatim");
2103 assert_eq!(commits[0].body, "body", "body is trimmed");
2104 }
2105
2106 #[test]
2107 fn parse_commit_output_absent_body_field_defaults_to_empty() {
2108 let raw = "h\x1fh\x1fsubject\x1fname\x1fmail\x1e";
2110 let commits = parse_commit_output(raw);
2111 assert_eq!(commits.len(), 1);
2112 assert_eq!(commits[0].body, "");
2113 assert_eq!(commits[0].message, "subject");
2114 }
2115
2116 #[test]
2117 fn parse_commit_output_skips_records_with_too_few_fields() {
2118 let raw = "only\x1ftwo\x1e\
2121 h\x1fh\x1fgood: subject\x1fn\x1fe\x1fbody\x1e";
2122 let commits = parse_commit_output(raw);
2123 assert_eq!(commits.len(), 1, "malformed record dropped, good one kept");
2124 assert_eq!(commits[0].message, "good: subject");
2125 }
2126
2127 #[test]
2128 fn parse_commit_output_multiline_body_survives_record_separator_split() {
2129 let raw = "h1\x1fh1\x1ffeat: A\x1fA\x1fa@x\x1fline one\nline two\n\nCo-Authored-By: B <b@x>\x1e\
2132 h0\x1fh0\x1ffix: B\x1fB\x1fb@x\x1f\x1e";
2133 let commits = parse_commit_output(raw);
2134 assert_eq!(commits.len(), 2);
2135 assert_eq!(commits[0].message, "feat: A");
2136 assert!(commits[0].body.contains("line one\nline two"));
2137 assert!(commits[0].body.contains("Co-Authored-By: B <b@x>"));
2138 assert_eq!(commits[1].message, "fix: B");
2139 assert_eq!(commits[1].body, "");
2140 }
2141
2142 #[test]
2145 fn short_commit_str_truncates_long_sha_to_seven() {
2146 assert_eq!(short_commit_str("abcdef0123456789"), "abcdef0");
2147 assert_eq!(short_commit_str("abcdef0123456789").len(), SHORT_COMMIT_LEN);
2148 }
2149
2150 #[test]
2151 fn short_commit_str_returns_shorter_or_equal_input_unchanged() {
2152 assert_eq!(short_commit_str("abc"), "abc", "shorter than 7 unchanged");
2153 assert_eq!(
2154 short_commit_str("abcdefg"),
2155 "abcdefg",
2156 "exactly 7 unchanged"
2157 );
2158 assert_eq!(short_commit_str(""), "", "empty stays empty");
2159 }
2160
2161 fn g(dir: &Path, args: &[&str]) {
2165 let out = anodizer_core::test_helpers::output_with_spawn_retry(
2166 || {
2167 let mut cmd = Command::new("git");
2168 cmd.args(args)
2169 .current_dir(dir)
2170 .env("GIT_AUTHOR_NAME", "Ada")
2171 .env("GIT_AUTHOR_EMAIL", "ada@x.com")
2172 .env("GIT_COMMITTER_NAME", "Ada")
2173 .env("GIT_COMMITTER_EMAIL", "ada@x.com")
2174 .env("GIT_AUTHOR_DATE", "1715000000 +0000")
2175 .env("GIT_COMMITTER_DATE", "1715000000 +0000");
2176 cmd
2177 },
2178 "git",
2179 );
2180 assert!(
2181 out.status.success(),
2182 "git {args:?} failed: {}",
2183 String::from_utf8_lossy(&out.stderr)
2184 );
2185 }
2186
2187 fn init_bare_repo(dir: &Path) {
2189 g(dir, &["init", "-b", "master"]);
2190 g(dir, &["config", "user.email", "ada@x.com"]);
2191 g(dir, &["config", "user.name", "Ada"]);
2192 }
2193
2194 fn commit_file(dir: &Path, path: &str, content: &str, subject: &str) {
2196 let full = dir.join(path);
2197 if let Some(parent) = full.parent() {
2198 std::fs::create_dir_all(parent).unwrap();
2199 }
2200 std::fs::write(full, content).unwrap();
2201 g(dir, &["add", "."]);
2202 g(dir, &["commit", "-m", subject]);
2203 }
2204
2205 #[test]
2208 fn get_commits_between_in_returns_only_post_base_commits() {
2209 let tmp = tempfile::tempdir().unwrap();
2210 let dir = tmp.path();
2211 init_bare_repo(dir);
2212 commit_file(dir, "a", "0", "initial");
2213 let base = get_head_commit_in(dir).unwrap();
2214 commit_file(dir, "a", "1", "feat: one");
2215 commit_file(dir, "a", "2", "fix: two");
2216
2217 let commits = get_commits_between_in(dir, &base, "HEAD", None).unwrap();
2218 assert_eq!(commits.len(), 2, "two commits sit above base");
2219 assert_eq!(commits[0].message, "fix: two");
2221 assert_eq!(commits[1].message, "feat: one");
2222 assert_eq!(commits[1].author_name, "Ada");
2223 assert_eq!(commits[1].author_email, "ada@x.com");
2224 }
2225
2226 #[test]
2227 fn get_commits_between_in_path_filter_excludes_untouched_files() {
2228 let tmp = tempfile::tempdir().unwrap();
2229 let dir = tmp.path();
2230 init_bare_repo(dir);
2231 commit_file(dir, "base", "0", "initial");
2232 let base = get_head_commit_in(dir).unwrap();
2233 commit_file(dir, "src/lib.rs", "1", "feat: touch lib");
2234 commit_file(dir, "docs/readme", "2", "docs: touch docs only");
2235
2236 let commits = get_commits_between_in(dir, &base, "HEAD", Some("src")).unwrap();
2238 assert_eq!(commits.len(), 1, "only the src-touching commit survives");
2239 assert_eq!(commits[0].message, "feat: touch lib");
2240 }
2241
2242 #[test]
2243 fn get_commits_between_paths_in_unions_multiple_paths() {
2244 let tmp = tempfile::tempdir().unwrap();
2245 let dir = tmp.path();
2246 init_bare_repo(dir);
2247 commit_file(dir, "base", "0", "initial");
2248 let base = get_head_commit_in(dir).unwrap();
2249 commit_file(dir, "a/x", "1", "feat: a");
2250 commit_file(dir, "b/y", "2", "feat: b");
2251 commit_file(dir, "c/z", "3", "feat: c");
2252
2253 let commits =
2255 get_commits_between_paths_in(dir, &base, "HEAD", &["a".into(), "b".into()]).unwrap();
2256 let subjects: Vec<&str> = commits.iter().map(|c| c.message.as_str()).collect();
2257 assert_eq!(
2258 commits.len(),
2259 2,
2260 "a and b touched, c excluded: {subjects:?}"
2261 );
2262 assert!(subjects.contains(&"feat: a"));
2263 assert!(subjects.contains(&"feat: b"));
2264 assert!(!subjects.contains(&"feat: c"));
2265 }
2266
2267 #[test]
2270 fn get_all_commits_in_returns_every_commit_on_head() {
2271 let tmp = tempfile::tempdir().unwrap();
2272 let dir = tmp.path();
2273 init_bare_repo(dir);
2274 commit_file(dir, "a", "0", "first");
2275 commit_file(dir, "a", "1", "second");
2276 commit_file(dir, "a", "2", "third");
2277
2278 let commits = get_all_commits_in(dir, None).unwrap();
2279 assert_eq!(commits.len(), 3);
2280 assert_eq!(commits[0].message, "third", "newest-first");
2281 assert_eq!(commits[2].message, "first");
2282 }
2283
2284 #[test]
2285 fn get_all_commits_paths_in_filters_to_path() {
2286 let tmp = tempfile::tempdir().unwrap();
2287 let dir = tmp.path();
2288 init_bare_repo(dir);
2289 commit_file(dir, "keep/x", "0", "feat: keep");
2290 commit_file(dir, "drop/y", "1", "feat: drop");
2291
2292 let commits = get_all_commits_paths_in(dir, &["keep".into()]).unwrap();
2293 assert_eq!(commits.len(), 1);
2294 assert_eq!(commits[0].message, "feat: keep");
2295 }
2296
2297 #[test]
2298 fn get_all_commits_paths_with_files_in_pairs_files() {
2299 let tmp = tempfile::tempdir().unwrap();
2300 let dir = tmp.path();
2301 init_bare_repo(dir);
2302 commit_file(dir, "crates/core/lib.rs", "0", "feat: core");
2303
2304 let pairs = get_all_commits_paths_with_files_in(dir, &[]).unwrap();
2305 assert_eq!(pairs.len(), 1);
2306 assert_eq!(pairs[0].commit.message, "feat: core");
2307 assert_eq!(pairs[0].files, vec!["crates/core/lib.rs".to_string()]);
2308 }
2309
2310 #[test]
2313 fn get_commits_reachable_paths_in_stops_at_the_given_rev() {
2314 let tmp = tempfile::tempdir().unwrap();
2315 let dir = tmp.path();
2316 init_bare_repo(dir);
2317 commit_file(dir, "a", "0", "first");
2318 commit_file(dir, "a", "1", "second");
2319 let mid = get_head_commit_in(dir).unwrap();
2320 commit_file(dir, "a", "2", "third-after-mid");
2321
2322 let commits = get_commits_reachable_paths_in(dir, &mid, &[]).unwrap();
2324 let subjects: Vec<&str> = commits.iter().map(|c| c.message.as_str()).collect();
2325 assert_eq!(commits.len(), 2, "only ancestors of mid: {subjects:?}");
2326 assert!(subjects.contains(&"first"));
2327 assert!(subjects.contains(&"second"));
2328 assert!(!subjects.contains(&"third-after-mid"));
2329 }
2330
2331 #[test]
2332 fn get_commits_reachable_paths_with_files_in_pairs_touched_files() {
2333 let tmp = tempfile::tempdir().unwrap();
2334 let dir = tmp.path();
2335 init_bare_repo(dir);
2336 commit_file(dir, "src/main.rs", "0", "feat: main");
2337 let head = get_head_commit_in(dir).unwrap();
2338
2339 let pairs = get_commits_reachable_paths_with_files_in(dir, &head, &[]).unwrap();
2340 assert_eq!(pairs.len(), 1);
2341 assert_eq!(pairs[0].commit.message, "feat: main");
2342 assert_eq!(pairs[0].files, vec!["src/main.rs".to_string()]);
2343 }
2344
2345 #[test]
2348 fn get_last_commit_messages_in_returns_n_subjects_newest_first() {
2349 let tmp = tempfile::tempdir().unwrap();
2350 let dir = tmp.path();
2351 init_bare_repo(dir);
2352 commit_file(dir, "a", "0", "one");
2353 commit_file(dir, "a", "1", "two");
2354 commit_file(dir, "a", "2", "three");
2355
2356 let msgs = get_last_commit_messages_in(dir, 2).unwrap();
2357 assert_eq!(msgs, vec!["three".to_string(), "two".to_string()]);
2358 }
2359
2360 #[test]
2361 fn get_commit_messages_between_in_lists_post_base_subjects() {
2362 let tmp = tempfile::tempdir().unwrap();
2363 let dir = tmp.path();
2364 init_bare_repo(dir);
2365 commit_file(dir, "a", "0", "initial");
2366 let base = get_head_commit_in(dir).unwrap();
2367 commit_file(dir, "a", "1", "feat: x");
2368 commit_file(dir, "a", "2", "fix: y");
2369
2370 let msgs = get_commit_messages_between_in(dir, &base, "HEAD").unwrap();
2371 assert_eq!(msgs, vec!["fix: y".to_string(), "feat: x".to_string()]);
2372 }
2373
2374 #[test]
2375 fn get_last_commit_messages_path_in_filters_to_path() {
2376 let tmp = tempfile::tempdir().unwrap();
2377 let dir = tmp.path();
2378 init_bare_repo(dir);
2379 commit_file(dir, "keep/a", "0", "feat: keep");
2380 commit_file(dir, "other/b", "1", "feat: other");
2381
2382 let msgs = get_last_commit_messages_path_in(dir, 10, "keep").unwrap();
2383 assert_eq!(msgs, vec!["feat: keep".to_string()]);
2384 }
2385
2386 #[test]
2387 fn get_commit_messages_between_path_in_filters_range_and_path() {
2388 let tmp = tempfile::tempdir().unwrap();
2389 let dir = tmp.path();
2390 init_bare_repo(dir);
2391 commit_file(dir, "base", "0", "initial");
2392 let base = get_head_commit_in(dir).unwrap();
2393 commit_file(dir, "src/x", "1", "feat: src");
2394 commit_file(dir, "doc/y", "2", "docs: doc");
2395
2396 let msgs = get_commit_messages_between_path_in(dir, &base, "HEAD", "src").unwrap();
2397 assert_eq!(msgs, vec!["feat: src".to_string()]);
2398 }
2399
2400 #[test]
2403 fn has_changes_since_in_detects_path_touched_after_tag() {
2404 let tmp = tempfile::tempdir().unwrap();
2405 let dir = tmp.path();
2406 init_bare_repo(dir);
2407 commit_file(dir, "watched", "0", "initial");
2408 g(dir, &["tag", "v1.0.0"]);
2409 assert!(!has_changes_since_in(dir, "v1.0.0", "watched").unwrap());
2411 commit_file(dir, "watched", "1", "feat: change watched");
2412 assert!(has_changes_since_in(dir, "v1.0.0", "watched").unwrap());
2414 assert!(!has_changes_since_in(dir, "v1.0.0", "unrelated").unwrap());
2416 }
2417
2418 #[test]
2419 fn paths_changed_since_tag_in_true_when_any_path_changed() {
2420 let tmp = tempfile::tempdir().unwrap();
2421 let dir = tmp.path();
2422 init_bare_repo(dir);
2423 commit_file(dir, "a", "0", "initial");
2424 g(dir, &["tag", "v1.0.0"]);
2425 commit_file(dir, "b", "1", "feat: add b");
2426
2427 assert!(paths_changed_since_tag_in(dir, "v1.0.0", &["a", "b"]).unwrap());
2429 assert!(!paths_changed_since_tag_in(dir, "v1.0.0", &["a"]).unwrap());
2431 }
2432
2433 #[test]
2434 fn paths_changed_since_tag_in_returns_false_when_git_fails() {
2435 let tmp = tempfile::tempdir().unwrap();
2438 let dir = tmp.path();
2439 init_bare_repo(dir);
2440 commit_file(dir, "a", "0", "initial");
2441 assert!(!paths_changed_since_tag_in(dir, "nope-no-such-tag", &["a"]).unwrap());
2442 }
2443
2444 #[test]
2447 fn head_commit_hash_in_matches_rev_parse_head() {
2448 let tmp = tempfile::tempdir().unwrap();
2449 let dir = tmp.path();
2450 init_bare_repo(dir);
2451 commit_file(dir, "a", "0", "initial");
2452 let expected = get_head_commit_in(dir).unwrap();
2453 assert_eq!(head_commit_hash_in(dir).unwrap(), expected);
2454 }
2455
2456 #[test]
2457 fn head_commit_hash_in_errors_on_non_repo() {
2458 let tmp = tempfile::tempdir().unwrap();
2459 assert!(head_commit_hash_in(tmp.path()).is_err());
2461 }
2462
2463 #[test]
2464 fn rev_parse_in_resolves_branch_to_full_sha() {
2465 let tmp = tempfile::tempdir().unwrap();
2466 let dir = tmp.path();
2467 init_bare_repo(dir);
2468 commit_file(dir, "a", "0", "initial");
2469 let head = get_head_commit_in(dir).unwrap();
2470 assert_eq!(rev_parse_in(dir, "master").unwrap(), head);
2471 }
2472
2473 #[test]
2474 fn rev_verify_commit_in_accepts_commit_rejects_unknown() {
2475 let tmp = tempfile::tempdir().unwrap();
2476 let dir = tmp.path();
2477 init_bare_repo(dir);
2478 commit_file(dir, "a", "0", "initial");
2479 let head = get_head_commit_in(dir).unwrap();
2480 assert_eq!(rev_verify_commit_in(dir, "HEAD").unwrap(), head);
2481 assert!(rev_verify_commit_in(dir, "deadbeefdeadbeef").is_err());
2483 }
2484
2485 #[test]
2486 fn commits_between_in_lists_shas_above_base_and_empty_at_head() {
2487 let tmp = tempfile::tempdir().unwrap();
2488 let dir = tmp.path();
2489 init_bare_repo(dir);
2490 commit_file(dir, "a", "0", "initial");
2491 let base = get_head_commit_in(dir).unwrap();
2492 commit_file(dir, "a", "1", "second");
2493 let head = get_head_commit_in(dir).unwrap();
2494
2495 let shas = commits_between_in(dir, &base).unwrap();
2496 assert_eq!(
2497 shas,
2498 vec![head.clone()],
2499 "exactly the one commit above base"
2500 );
2501 assert!(commits_between_in(dir, &head).unwrap().is_empty());
2503 }
2504
2505 #[test]
2506 fn commit_subject_in_returns_single_commit_subject() {
2507 let tmp = tempfile::tempdir().unwrap();
2508 let dir = tmp.path();
2509 init_bare_repo(dir);
2510 commit_file(dir, "a", "0", "feat: only-subject\n\nignored body");
2511 let head = get_head_commit_in(dir).unwrap();
2512 assert_eq!(commit_subject_in(dir, &head).unwrap(), "feat: only-subject");
2513 }
2514
2515 #[test]
2516 fn head_commit_timestamp_in_returns_pinned_committer_epoch() {
2517 let tmp = tempfile::tempdir().unwrap();
2518 let dir = tmp.path();
2519 init_bare_repo(dir);
2520 commit_file(dir, "a", "0", "initial");
2522 assert_eq!(head_commit_timestamp_in(dir).unwrap(), 1_715_000_000);
2523 }
2524
2525 #[test]
2528 fn log_subjects_for_range_returns_full_bodies_for_path() {
2529 let tmp = tempfile::tempdir().unwrap();
2530 let dir = tmp.path();
2531 init_bare_repo(dir);
2532 commit_file(dir, "watched", "0", "feat: A\n\nbody of A");
2533 commit_file(dir, "watched", "1", "fix: B");
2534
2535 let bodies = log_subjects_for_range(dir, "HEAD", "watched").unwrap();
2536 assert_eq!(bodies.len(), 2);
2537 assert!(bodies[0].starts_with("fix: B"));
2539 assert!(bodies[1].contains("feat: A") && bodies[1].contains("body of A"));
2540 }
2541
2542 #[test]
2543 fn log_subjects_for_range_returns_empty_when_range_invalid() {
2544 let tmp = tempfile::tempdir().unwrap();
2545 let dir = tmp.path();
2546 init_bare_repo(dir);
2547 commit_file(dir, "a", "0", "initial");
2548 let bodies = log_subjects_for_range(dir, "no-such-ref..HEAD", "a").unwrap();
2551 assert!(bodies.is_empty());
2552 }
2553
2554 #[test]
2557 fn add_path_in_then_commit_in_creates_commit() {
2558 let tmp = tempfile::tempdir().unwrap();
2559 let dir = tmp.path();
2560 init_bare_repo(dir);
2561 commit_file(dir, "seed", "0", "initial");
2562 std::fs::write(dir.join("new.txt"), "hello").unwrap();
2563
2564 add_path_in(dir, std::path::Path::new("new.txt")).unwrap();
2565 commit_in(dir, "feat: add new.txt", false).unwrap();
2566
2567 let subject = String::from_utf8(
2568 anodizer_core::test_helpers::output_with_spawn_retry(
2569 || {
2570 let mut cmd = Command::new("git");
2571 cmd.args(["log", "-1", "--pretty=%s"]).current_dir(dir);
2572 cmd
2573 },
2574 "git",
2575 )
2576 .stdout,
2577 )
2578 .unwrap()
2579 .trim()
2580 .to_string();
2581 assert_eq!(subject, "feat: add new.txt");
2582 }
2583
2584 #[test]
2585 fn add_path_in_errors_on_missing_file() {
2586 let tmp = tempfile::tempdir().unwrap();
2587 let dir = tmp.path();
2588 init_bare_repo(dir);
2589 let err = add_path_in(dir, std::path::Path::new("does-not-exist")).unwrap_err();
2590 assert!(
2591 err.to_string().contains("git add"),
2592 "error must name the failing git add: {err}"
2593 );
2594 }
2595
2596 #[test]
2599 fn reset_hard_in_moves_head_and_restores_tree() {
2600 let tmp = tempfile::tempdir().unwrap();
2601 let dir = tmp.path();
2602 init_bare_repo(dir);
2603 commit_file(dir, "a", "first", "first");
2604 let target = get_head_commit_in(dir).unwrap();
2605 commit_file(dir, "a", "second", "second");
2606 assert_ne!(get_head_commit_in(dir).unwrap(), target);
2607
2608 reset_hard_in(dir, &target).unwrap();
2609 assert_eq!(get_head_commit_in(dir).unwrap(), target, "HEAD moved back");
2610 assert_eq!(
2611 std::fs::read_to_string(dir.join("a")).unwrap(),
2612 "first",
2613 "working tree restored to target content"
2614 );
2615 }
2616
2617 #[test]
2620 fn push_branch_in_bails_without_origin_remote() {
2621 let tmp = tempfile::tempdir().unwrap();
2622 let dir = tmp.path();
2623 init_bare_repo(dir);
2624 commit_file(dir, "a", "0", "initial");
2625 let err = push_branch_in(dir, "master").unwrap_err();
2626 assert!(
2627 err.to_string().contains("no 'origin' remote"),
2628 "missing-remote bail must be explicit: {err}"
2629 );
2630 }
2631
2632 #[test]
2635 #[serial_test::serial(git_env)]
2636 fn resolve_rollback_identity_inherits_when_repo_has_identity() {
2637 let tmp = tempfile::tempdir().unwrap();
2638 let dir = tmp.path();
2639 init_bare_repo(dir); struct EnvGuard(Vec<(&'static str, Option<String>)>);
2644 impl Drop for EnvGuard {
2645 fn drop(&mut self) {
2646 for (k, v) in &self.0 {
2647 match v {
2648 Some(val) => unsafe { std::env::set_var(k, val) },
2650 None => unsafe { std::env::remove_var(k) },
2652 }
2653 }
2654 }
2655 }
2656 let keys = [
2657 "GIT_AUTHOR_NAME",
2658 "GIT_AUTHOR_EMAIL",
2659 "GIT_COMMITTER_NAME",
2660 "GIT_COMMITTER_EMAIL",
2661 ];
2662 let _g = EnvGuard(keys.iter().map(|k| (*k, std::env::var(k).ok())).collect());
2663 for k in keys {
2664 unsafe { std::env::remove_var(k) };
2666 }
2667
2668 let id = resolve_rollback_identity(dir);
2670 assert!(
2671 id.name.is_none() && id.email.is_none(),
2672 "configured repo identity must be inherited, not overridden: {id:?}"
2673 );
2674 }
2675
2676 #[test]
2677 #[serial_test::serial(git_env)]
2678 fn resolve_rollback_identity_synthesizes_when_no_identity_anywhere() {
2679 let tmp = tempfile::tempdir().unwrap();
2680 let dir = tmp.path();
2681 g(dir, &["init", "-b", "master"]);
2683
2684 struct EnvGuard(Vec<(&'static str, Option<String>)>);
2685 impl Drop for EnvGuard {
2686 fn drop(&mut self) {
2687 for (k, v) in &self.0 {
2688 match v {
2689 Some(val) => unsafe { std::env::set_var(k, val) },
2691 None => unsafe { std::env::remove_var(k) },
2693 }
2694 }
2695 }
2696 }
2697 let keys = [
2698 "GIT_AUTHOR_NAME",
2699 "GIT_AUTHOR_EMAIL",
2700 "GIT_COMMITTER_NAME",
2701 "GIT_COMMITTER_EMAIL",
2702 ];
2703 let _g = EnvGuard(keys.iter().map(|k| (*k, std::env::var(k).ok())).collect());
2704 for k in keys {
2705 unsafe { std::env::remove_var(k) };
2707 }
2708
2709 let (n, e) = read_git_identity(dir);
2712 if n.is_none() || e.is_none() {
2713 let id = resolve_rollback_identity(dir);
2714 assert_eq!(id.name.as_deref(), Some("anodize-rollback"));
2715 assert!(
2716 id.email
2717 .as_deref()
2718 .unwrap_or("")
2719 .starts_with("anodize-rollback@"),
2720 "synthetic identity required when no config present: {id:?}"
2721 );
2722 }
2723 }
2724
2725 #[test]
2726 fn read_git_identity_reads_configured_values() {
2727 let tmp = tempfile::tempdir().unwrap();
2728 let dir = tmp.path();
2729 g(dir, &["init", "-b", "master"]);
2730 g(dir, &["config", "user.name", "Configured Name"]);
2731 g(dir, &["config", "user.email", "configured@x.com"]);
2732
2733 let (name, email) = read_git_identity(dir);
2734 assert_eq!(name.as_deref(), Some("Configured Name"));
2735 assert_eq!(email.as_deref(), Some("configured@x.com"));
2736 }
2737}