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> {
846 get_head_commit_in(repo)
847}
848
849pub fn rev_parse_in(cwd: &Path, rev: &str) -> Result<String> {
854 git_output_in(cwd, &["rev-parse", rev])
855}
856
857pub fn rev_verify_commit_in(cwd: &Path, rev: &str) -> Result<String> {
863 git_output_in(
864 cwd,
865 &["rev-parse", "--verify", &format!("{}^{{commit}}", rev)],
866 )
867}
868
869pub fn commits_between_in(cwd: &Path, sha: &str) -> Result<Vec<String>> {
874 let range = format!("{}..HEAD", sha);
875 let out = git_output_in(cwd, &["rev-list", &range])?;
876 if out.is_empty() {
877 return Ok(Vec::new());
878 }
879 Ok(out.lines().map(|s| s.trim().to_string()).collect())
880}
881
882pub fn commit_subject_in(cwd: &Path, sha: &str) -> Result<String> {
886 git_output_in(
887 cwd,
888 &[
889 "-c",
890 "log.showSignature=false",
891 "log",
892 "-1",
893 "--format=%s",
894 sha,
895 ],
896 )
897}
898
899pub fn commits_with_subjects_in(cwd: &Path, sha: &str) -> Result<Vec<(String, String)>> {
906 let range = format!("{}..HEAD", sha);
907 let out = git_output_in(
908 cwd,
909 &[
910 "-c",
911 "log.showSignature=false",
912 "log",
913 "--format=%H%x1f%s",
914 &range,
915 ],
916 )?;
917 if out.is_empty() {
918 return Ok(Vec::new());
919 }
920 Ok(out
921 .lines()
922 .filter_map(|line| {
923 let mut parts = line.splitn(2, '\x1f');
924 let sha = parts.next()?.trim().to_string();
925 let subj = parts.next().unwrap_or("").to_string();
926 if sha.is_empty() {
927 None
928 } else {
929 Some((sha, subj))
930 }
931 })
932 .collect())
933}
934
935#[derive(Debug, Clone, Default)]
948pub struct CommitterIdentity {
949 pub name: Option<String>,
950 pub email: Option<String>,
951}
952
953impl CommitterIdentity {
954 pub fn default_for_rollback() -> Self {
960 let host = std::env::var("HOSTNAME")
961 .ok()
962 .or_else(|| std::env::var("COMPUTERNAME").ok())
963 .and_then(|h| h.split('.').next().map(str::to_string))
964 .filter(|h| !h.is_empty())
965 .unwrap_or_else(|| "localhost".to_string());
966 Self {
967 name: Some("anodize-rollback".to_string()),
968 email: Some(format!("anodize-rollback@{host}")),
969 }
970 }
971
972 fn apply_to(&self, cmd: &mut Command) {
973 if let Some(n) = &self.name {
974 cmd.env("GIT_AUTHOR_NAME", n).env("GIT_COMMITTER_NAME", n);
975 }
976 if let Some(e) = &self.email {
977 cmd.env("GIT_AUTHOR_EMAIL", e).env("GIT_COMMITTER_EMAIL", e);
978 }
979 }
980}
981
982fn read_git_identity(cwd: &Path) -> (Option<String>, Option<String>) {
988 let one = |key: &str| -> Option<String> {
989 let out = Command::new("git")
990 .current_dir(cwd)
991 .args(["config", "--get", key])
992 .env("LC_ALL", "C")
993 .env("GIT_TERMINAL_PROMPT", "0")
994 .output()
995 .ok()?;
996 if !out.status.success() {
997 return None;
998 }
999 let value = String::from_utf8_lossy(&out.stdout).trim().to_string();
1000 if value.is_empty() { None } else { Some(value) }
1001 };
1002 (one("user.name"), one("user.email"))
1003}
1004
1005pub fn resolve_rollback_identity(cwd: &Path) -> CommitterIdentity {
1012 let env_author_set =
1013 std::env::var("GIT_AUTHOR_EMAIL").is_ok() && std::env::var("GIT_AUTHOR_NAME").is_ok();
1014 let env_committer_set =
1015 std::env::var("GIT_COMMITTER_EMAIL").is_ok() && std::env::var("GIT_COMMITTER_NAME").is_ok();
1016 if env_author_set && env_committer_set {
1017 return CommitterIdentity::default();
1018 }
1019 let (name, email) = read_git_identity(cwd);
1020 if name.is_some() && email.is_some() {
1021 return CommitterIdentity::default();
1022 }
1023 CommitterIdentity::default_for_rollback()
1024}
1025
1026pub fn revert_commit_in(
1052 cwd: &Path,
1053 sha: &str,
1054 message: Option<&str>,
1055 identity: &CommitterIdentity,
1056) -> Result<()> {
1057 let status = Command::new("git")
1058 .args(["status", "--porcelain", "--untracked-files=no"])
1059 .current_dir(cwd)
1060 .env("LC_ALL", "C")
1061 .env("GIT_TERMINAL_PROMPT", "0")
1062 .output()
1063 .with_context(|| format!("revert_commit_in: git status in {}", cwd.display()))?;
1064 if !status.status.success() {
1065 let stderr_raw = String::from_utf8_lossy(&status.stderr);
1066 let raw = format!("git status failed: {}", stderr_raw.trim());
1067 bail!("{}", crate::redact::redact_process_env(&raw));
1068 }
1069 if !status.stdout.is_empty() {
1070 bail!(
1071 "refusing to revert in a dirty working tree at {}\nstatus:\n{}",
1072 cwd.display(),
1073 String::from_utf8_lossy(&status.stdout),
1074 );
1075 }
1076
1077 let mut revert_cmd = Command::new("git");
1078 revert_cmd
1079 .current_dir(cwd)
1080 .args(["revert", "--no-edit", sha])
1081 .env("LC_ALL", "C")
1082 .env("GIT_TERMINAL_PROMPT", "0");
1083 identity.apply_to(&mut revert_cmd);
1084 let out = revert_cmd
1085 .output()
1086 .with_context(|| format!("revert_commit_in: git revert in {}", cwd.display()))?;
1087 if !out.status.success() {
1088 let stderr_raw = String::from_utf8_lossy(&out.stderr);
1089 let _ = Command::new("git")
1092 .current_dir(cwd)
1093 .args(["revert", "--abort"])
1094 .env("LC_ALL", "C")
1095 .env("GIT_TERMINAL_PROMPT", "0")
1096 .output();
1097 let raw = format!(
1098 "git revert {sha} hit conflicts and was aborted (working tree restored). \
1099 The bump commit overlaps with later changes — resolve manually, \
1100 or re-run with --mode=reset to force.\nstderr: {}",
1101 stderr_raw.trim()
1102 );
1103 bail!("{}", crate::redact::redact_process_env(&raw));
1104 }
1105 if let Some(msg) = message {
1106 let mut amend_cmd = Command::new("git");
1107 amend_cmd
1108 .current_dir(cwd)
1109 .args(["commit", "--amend", "-m", msg])
1110 .env("LC_ALL", "C")
1111 .env("GIT_TERMINAL_PROMPT", "0");
1112 identity.apply_to(&mut amend_cmd);
1113 let out = amend_cmd.output().with_context(|| {
1114 format!("revert_commit_in: git commit --amend in {}", cwd.display())
1115 })?;
1116 if !out.status.success() {
1117 let stderr_raw = String::from_utf8_lossy(&out.stderr);
1118 let raw = format!("git commit --amend failed: {}", stderr_raw.trim());
1119 bail!("{}", crate::redact::redact_process_env(&raw));
1120 }
1121 }
1122 Ok(())
1123}
1124
1125pub fn reset_hard_in(cwd: &Path, sha: &str) -> Result<()> {
1128 git_output_in(cwd, &["reset", "--hard", sha])?;
1129 Ok(())
1130}
1131
1132pub fn push_branch_in(cwd: &Path, branch: &str) -> Result<()> {
1137 if !super::has_remote_in(cwd, "origin") {
1138 bail!("no 'origin' remote configured, cannot push branch '{branch}'");
1139 }
1140 let refspec = format!("HEAD:refs/heads/{}", branch);
1141 let out = Command::new("git")
1142 .current_dir(cwd)
1143 .args(["push", "origin", &refspec])
1144 .env("GIT_TERMINAL_PROMPT", "0")
1145 .env("LC_ALL", "C")
1146 .output()
1147 .with_context(|| format!("push_branch_in: git push origin {refspec}"))?;
1148 if !out.status.success() {
1149 let stderr_raw = String::from_utf8_lossy(&out.stderr);
1150 let raw = format!("git push origin {} failed: {}", refspec, stderr_raw.trim());
1151 bail!("{}", crate::redact::redact_process_env(&raw));
1152 }
1153 Ok(())
1154}
1155
1156pub fn head_commit_timestamp_in(repo: &std::path::Path) -> Result<i64> {
1160 let out = Command::new("git")
1161 .arg("-C")
1162 .arg(repo)
1163 .args(["log", "-1", "--format=%ct", "HEAD"])
1164 .env("GIT_TERMINAL_PROMPT", "0")
1165 .env("LC_ALL", "C")
1166 .output()
1167 .context("failed to invoke git log -1 --format=%ct HEAD")?;
1168 if !out.status.success() {
1169 let stderr_raw = String::from_utf8_lossy(&out.stderr);
1170 let raw = format!("git log -1 --format=%ct HEAD failed: {}", stderr_raw.trim());
1171 bail!("{}", crate::redact::redact_process_env(&raw));
1172 }
1173 let text = String::from_utf8_lossy(&out.stdout).trim().to_string();
1174 text.parse::<i64>()
1175 .with_context(|| format!("git log --format=%ct returned non-i64 timestamp: {}", text))
1176}
1177
1178#[cfg(test)]
1179mod tests {
1180 use super::*;
1181 use std::process::Command;
1182
1183 fn init_repo_with_commits(dir: &Path, files: &[&str]) {
1184 let run = |args: &[&str]| {
1185 let out = anodizer_core::test_helpers::output_with_spawn_retry(
1186 || {
1187 let mut cmd = Command::new("git");
1188 cmd.args(args)
1189 .current_dir(dir)
1190 .env("GIT_AUTHOR_NAME", "t")
1191 .env("GIT_AUTHOR_EMAIL", "t@t.com")
1192 .env("GIT_COMMITTER_NAME", "t")
1193 .env("GIT_COMMITTER_EMAIL", "t@t.com");
1194 cmd
1195 },
1196 "git",
1197 );
1198 assert!(out.status.success(), "git {args:?} failed");
1199 };
1200 run(&["init"]);
1201 run(&["config", "user.email", "t@t.com"]);
1202 run(&["config", "user.name", "t"]);
1203 for (i, f) in files.iter().enumerate() {
1204 std::fs::write(dir.join(f), format!("c{i}")).unwrap();
1205 run(&["add", "."]);
1206 run(&["commit", "-m", &format!("commit-{i}: {f}")]);
1207 }
1208 }
1209
1210 #[test]
1211 fn get_head_commit_in_returns_tempdirs_head_sha() {
1212 let tmp = tempfile::tempdir().unwrap();
1213 init_repo_with_commits(tmp.path(), &["a"]);
1214 let expected = String::from_utf8(
1215 anodizer_core::test_helpers::output_with_spawn_retry(
1216 || {
1217 let mut cmd = Command::new("git");
1218 cmd.args(["rev-parse", "HEAD"]).current_dir(tmp.path());
1219 cmd
1220 },
1221 "git",
1222 )
1223 .stdout,
1224 )
1225 .unwrap()
1226 .trim()
1227 .to_string();
1228 let sha = get_head_commit_in(tmp.path()).unwrap();
1229 assert_eq!(sha, expected);
1230 }
1231
1232 #[test]
1233 fn get_short_commit_in_returns_tempdirs_short_sha() {
1234 let tmp = tempfile::tempdir().unwrap();
1235 init_repo_with_commits(tmp.path(), &["a"]);
1236 let expected = String::from_utf8(
1237 anodizer_core::test_helpers::output_with_spawn_retry(
1238 || {
1239 let mut cmd = Command::new("git");
1240 cmd.args(["rev-parse", "--short", "HEAD"])
1241 .current_dir(tmp.path());
1242 cmd
1243 },
1244 "git",
1245 )
1246 .stdout,
1247 )
1248 .unwrap()
1249 .trim()
1250 .to_string();
1251 let short = get_short_commit_in(tmp.path()).unwrap();
1252 assert_eq!(short, expected);
1253 }
1254
1255 #[test]
1256 fn has_commits_since_tag_in_returns_false_when_tag_is_head() {
1257 let tmp = tempfile::tempdir().unwrap();
1258 let dir = tmp.path();
1259 init_repo_with_commits(dir, &["a"]);
1260 let run = |args: &[&str]| {
1261 anodizer_core::test_helpers::output_with_spawn_retry(
1262 || {
1263 let mut cmd = Command::new("git");
1264 cmd.args(args)
1265 .current_dir(dir)
1266 .env("GIT_AUTHOR_NAME", "t")
1267 .env("GIT_AUTHOR_EMAIL", "t@t.com")
1268 .env("GIT_COMMITTER_NAME", "t")
1269 .env("GIT_COMMITTER_EMAIL", "t@t.com");
1270 cmd
1271 },
1272 "git",
1273 );
1274 };
1275 run(&["tag", "v1.0.0"]);
1276 assert!(!has_commits_since_tag_in(dir, "v1.0.0").unwrap());
1277 }
1278
1279 fn git_in(dir: &Path, args: &[&str]) {
1280 let out = anodizer_core::test_helpers::output_with_spawn_retry(
1281 || {
1282 let mut cmd = Command::new("git");
1283 cmd.args(args)
1284 .current_dir(dir)
1285 .env("GIT_AUTHOR_NAME", "t")
1286 .env("GIT_AUTHOR_EMAIL", "t@t.com")
1287 .env("GIT_COMMITTER_NAME", "t")
1288 .env("GIT_COMMITTER_EMAIL", "t@t.com");
1289 cmd
1290 },
1291 "git",
1292 );
1293 assert!(out.status.success(), "git {args:?} failed");
1294 }
1295
1296 #[test]
1297 fn count_commits_since_last_tag_counts_commits_after_tag() {
1298 let tmp = tempfile::tempdir().unwrap();
1299 let dir = tmp.path();
1300 init_repo_with_commits(dir, &["a", "b"]);
1302 git_in(dir, &["tag", "v1.0.0"]);
1303 for f in ["c", "d", "e"] {
1304 std::fs::write(dir.join(f), "x").unwrap();
1305 git_in(dir, &["add", "."]);
1306 git_in(dir, &["commit", "-m", f]);
1307 }
1308 assert_eq!(count_commits_since_last_tag_in(dir, None).unwrap(), 3);
1309 }
1310
1311 #[test]
1312 fn count_commits_since_last_tag_resets_on_newer_tag() {
1313 let tmp = tempfile::tempdir().unwrap();
1314 let dir = tmp.path();
1315 init_repo_with_commits(dir, &["a"]);
1316 git_in(dir, &["tag", "v1.0.0"]);
1317 for f in ["b", "c"] {
1318 std::fs::write(dir.join(f), "x").unwrap();
1319 git_in(dir, &["add", "."]);
1320 git_in(dir, &["commit", "-m", f]);
1321 }
1322 assert_eq!(count_commits_since_last_tag_in(dir, None).unwrap(), 2);
1323 git_in(dir, &["tag", "v1.1.0"]);
1325 assert_eq!(count_commits_since_last_tag_in(dir, None).unwrap(), 0);
1326 std::fs::write(dir.join("d"), "x").unwrap();
1327 git_in(dir, &["add", "."]);
1328 git_in(dir, &["commit", "-m", "d"]);
1329 assert_eq!(count_commits_since_last_tag_in(dir, None).unwrap(), 1);
1330 }
1331
1332 #[test]
1333 fn count_commits_since_last_tag_counts_all_when_no_tag() {
1334 let tmp = tempfile::tempdir().unwrap();
1335 let dir = tmp.path();
1336 init_repo_with_commits(dir, &["a", "b", "c"]);
1337 assert_eq!(count_commits_since_last_tag_in(dir, None).unwrap(), 3);
1339 }
1340
1341 #[test]
1342 fn count_commits_since_last_tag_respects_monorepo_prefix() {
1343 let tmp = tempfile::tempdir().unwrap();
1347 let dir = tmp.path();
1348 init_repo_with_commits(dir, &["a"]);
1349 git_in(dir, &["tag", "core/v1.0.0"]); for f in ["b", "c"] {
1351 std::fs::write(dir.join(f), "x").unwrap();
1352 git_in(dir, &["add", "."]);
1353 git_in(dir, &["commit", "-m", f]);
1354 }
1355 git_in(dir, &["tag", "api/v2.0.0"]); std::fs::write(dir.join("d"), "x").unwrap();
1357 git_in(dir, &["add", "."]);
1358 git_in(dir, &["commit", "-m", "d"]);
1359
1360 assert_eq!(
1362 count_commits_since_last_tag_in(dir, Some("core/")).unwrap(),
1363 3,
1364 "must count since the matching-prefix tag, ignoring api/v2.0.0",
1365 );
1366 assert_eq!(
1370 count_commits_since_last_tag_in(dir, None).unwrap(),
1371 1,
1372 "unfiltered count picks the nearest (wrong) subproject tag",
1373 );
1374 }
1375
1376 #[test]
1377 fn get_current_branch_in_returns_branch_name() {
1378 let tmp = tempfile::tempdir().unwrap();
1379 let dir = tmp.path();
1380 let run = |args: &[&str]| {
1381 let out = anodizer_core::test_helpers::output_with_spawn_retry(
1382 || {
1383 let mut cmd = Command::new("git");
1384 cmd.args(args)
1385 .current_dir(dir)
1386 .env("GIT_AUTHOR_NAME", "t")
1387 .env("GIT_AUTHOR_EMAIL", "t@t.com")
1388 .env("GIT_COMMITTER_NAME", "t")
1389 .env("GIT_COMMITTER_EMAIL", "t@t.com");
1390 cmd
1391 },
1392 "git",
1393 );
1394 assert!(out.status.success(), "git {args:?} failed");
1395 };
1396 run(&["-c", "init.defaultBranch=t1-test-branch", "init"]);
1397 run(&["config", "user.email", "t@t.com"]);
1398 run(&["config", "user.name", "t"]);
1399 std::fs::write(dir.join("a"), "1").unwrap();
1400 run(&["add", "."]);
1401 run(&["commit", "-m", "c1"]);
1402 let branch = get_current_branch_in(dir).unwrap();
1403 assert_eq!(branch, "t1-test-branch");
1404 }
1405
1406 #[test]
1407 fn get_current_branch_in_resolves_detached_head_via_points_at() {
1408 let tmp = tempfile::tempdir().unwrap();
1409 let dir = tmp.path();
1410 let run = |args: &[&str]| {
1411 let out = anodizer_core::test_helpers::output_with_spawn_retry(
1412 || {
1413 let mut cmd = Command::new("git");
1414 cmd.args(args)
1415 .current_dir(dir)
1416 .env("GIT_AUTHOR_NAME", "t")
1417 .env("GIT_AUTHOR_EMAIL", "t@t.com")
1418 .env("GIT_COMMITTER_NAME", "t")
1419 .env("GIT_COMMITTER_EMAIL", "t@t.com");
1420 cmd
1421 },
1422 "git",
1423 );
1424 assert!(out.status.success(), "git {args:?} failed");
1425 };
1426 run(&["-c", "init.defaultBranch=master", "init"]);
1427 run(&["config", "user.email", "t@t.com"]);
1428 run(&["config", "user.name", "t"]);
1429 std::fs::write(dir.join("a"), "1").unwrap();
1430 run(&["add", "."]);
1431 run(&["commit", "-m", "c1"]);
1432 let sha = get_head_commit_in(dir).unwrap();
1433 run(&["checkout", "--detach", &sha]);
1434 let branch = get_current_branch_in(dir).unwrap();
1435 assert_eq!(
1436 branch, "master",
1437 "detached HEAD pointing at master must resolve to master, not literal HEAD"
1438 );
1439 }
1440
1441 #[test]
1442 fn is_branchlike_rejects_lockstep_tag_shapes() {
1443 assert!(!is_branchlike("v0.4.5"));
1444 assert!(!is_branchlike("v1.2.3"));
1445 assert!(!is_branchlike("v10.20.30"));
1446 assert!(!is_branchlike("v1.2.3-rc.1"));
1447 assert!(!is_branchlike("v1.2.3+build.42"));
1448 }
1449
1450 #[test]
1451 fn is_branchlike_rejects_per_crate_tag_shapes() {
1452 assert!(!is_branchlike("mycrate-v1.2.3"));
1453 assert!(!is_branchlike("cfgd-operator-v0.4.0"));
1454 assert!(!is_branchlike("anodize-core-v1.2.3-rc.1"));
1455 }
1456
1457 #[test]
1458 fn is_branchlike_accepts_real_branch_names() {
1459 assert!(is_branchlike("master"));
1460 assert!(is_branchlike("main"));
1461 assert!(is_branchlike("publisher-required-config"));
1462 assert!(is_branchlike("release/v1.2.3-prep"));
1463 assert!(is_branchlike("dependabot/cargo/serde-1.0.200"));
1464 }
1465
1466 #[test]
1467 fn is_branchlike_accepts_slashed_branch_with_embedded_version() {
1468 assert!(is_branchlike("feature/fix-v2.0.0"));
1473 assert!(is_branchlike("hotfix/release-v1.0.0-blocker"));
1474 assert!(is_branchlike("user/wip-v3.1.4"));
1475 }
1476
1477 #[test]
1478 fn get_current_branch_in_rejects_tag_shaped_github_ref_name() {
1479 let tmp = tempfile::tempdir().unwrap();
1480 let dir = tmp.path();
1481 let run = |args: &[&str]| {
1482 let out = anodizer_core::test_helpers::output_with_spawn_retry(
1483 || {
1484 let mut cmd = Command::new("git");
1485 cmd.args(args)
1486 .current_dir(dir)
1487 .env("GIT_AUTHOR_NAME", "t")
1488 .env("GIT_AUTHOR_EMAIL", "t@t.com")
1489 .env("GIT_COMMITTER_NAME", "t")
1490 .env("GIT_COMMITTER_EMAIL", "t@t.com");
1491 cmd
1492 },
1493 "git",
1494 );
1495 assert!(out.status.success(), "git {args:?} failed");
1496 };
1497 run(&["-c", "init.defaultBranch=master", "init"]);
1501 run(&["config", "user.email", "t@t.com"]);
1502 run(&["config", "user.name", "t"]);
1503 std::fs::write(dir.join("a"), "1").unwrap();
1504 run(&["add", "."]);
1505 run(&["commit", "-m", "c1"]);
1506 let sha = get_head_commit_in(dir).unwrap();
1507 std::fs::write(dir.join("a"), "2").unwrap();
1510 run(&["add", "."]);
1511 run(&["commit", "-m", "c2"]);
1512 run(&["checkout", "--detach", &sha]);
1513
1514 let env = crate::MapEnvSource::new().with("GITHUB_REF_NAME", "v0.4.5");
1519 let err = get_current_branch_in_with_env(dir, &env).unwrap_err();
1520 assert!(
1521 err.to_string().contains("could not resolve current branch"),
1522 "tag-shaped GITHUB_REF_NAME must trigger bail: {err}"
1523 );
1524
1525 let env = crate::MapEnvSource::new().with("GITHUB_REF_NAME", "mycrate-v1.2.3");
1527 let err = get_current_branch_in_with_env(dir, &env).unwrap_err();
1528 assert!(
1529 err.to_string().contains("could not resolve current branch"),
1530 "per-crate tag GITHUB_REF_NAME must trigger bail: {err}"
1531 );
1532
1533 let env = crate::MapEnvSource::new().with("GITHUB_REF_NAME", "master");
1535 let branch = get_current_branch_in_with_env(dir, &env).unwrap();
1536 assert_eq!(branch, "master");
1537 }
1538
1539 #[test]
1540 fn branches_containing_sha_in_returns_empty_without_remote() {
1541 let tmp = tempfile::tempdir().unwrap();
1542 let dir = tmp.path();
1543 let run = |args: &[&str]| {
1544 let out = anodizer_core::test_helpers::output_with_spawn_retry(
1545 || {
1546 let mut cmd = Command::new("git");
1547 cmd.args(args)
1548 .current_dir(dir)
1549 .env("GIT_AUTHOR_NAME", "t")
1550 .env("GIT_AUTHOR_EMAIL", "t@t.com")
1551 .env("GIT_COMMITTER_NAME", "t")
1552 .env("GIT_COMMITTER_EMAIL", "t@t.com");
1553 cmd
1554 },
1555 "git",
1556 );
1557 assert!(out.status.success(), "git {args:?} failed");
1558 };
1559 run(&["-c", "init.defaultBranch=master", "init"]);
1560 run(&["config", "user.email", "t@t.com"]);
1561 run(&["config", "user.name", "t"]);
1562 std::fs::write(dir.join("a"), "1").unwrap();
1563 run(&["add", "."]);
1564 run(&["commit", "-m", "c1"]);
1565 let sha = get_head_commit_in(dir).unwrap();
1566 let branches = branches_containing_sha_in(dir, &sha).unwrap();
1570 assert!(branches.is_empty(), "no remote → no remote branches");
1571 }
1572
1573 #[test]
1574 fn branches_containing_sha_in_finds_remote_branch_after_push() {
1575 let tmp = tempfile::tempdir().unwrap();
1576 let bare = tempfile::tempdir().unwrap();
1577 let dir = tmp.path();
1578 let run_in = |cwd: &Path, args: &[&str]| {
1579 let out = anodizer_core::test_helpers::output_with_spawn_retry(
1580 || {
1581 let mut cmd = Command::new("git");
1582 cmd.args(args)
1583 .current_dir(cwd)
1584 .env("GIT_AUTHOR_NAME", "t")
1585 .env("GIT_AUTHOR_EMAIL", "t@t.com")
1586 .env("GIT_COMMITTER_NAME", "t")
1587 .env("GIT_COMMITTER_EMAIL", "t@t.com");
1588 cmd
1589 },
1590 "git",
1591 );
1592 assert!(out.status.success(), "git {args:?} failed");
1593 };
1594 run_in(
1595 bare.path(),
1596 &["-c", "init.defaultBranch=master", "init", "--bare"],
1597 );
1598 run_in(dir, &["-c", "init.defaultBranch=master", "init"]);
1599 run_in(dir, &["config", "user.email", "t@t.com"]);
1600 run_in(dir, &["config", "user.name", "t"]);
1601 run_in(
1602 dir,
1603 &["remote", "add", "origin", bare.path().to_str().unwrap()],
1604 );
1605 std::fs::write(dir.join("a"), "1").unwrap();
1606 run_in(dir, &["add", "."]);
1607 run_in(dir, &["commit", "-m", "c1"]);
1608 let sha = get_head_commit_in(dir).unwrap();
1609 run_in(dir, &["push", "-u", "origin", "master"]);
1610
1611 let branches = branches_containing_sha_in(dir, &sha).unwrap();
1612 assert_eq!(branches, vec!["master".to_string()]);
1613 }
1614
1615 #[test]
1616 fn stage_and_commit_in_returns_false_when_no_diff() {
1617 let tmp = tempfile::tempdir().unwrap();
1618 let dir = tmp.path();
1619 init_repo_with_commits(dir, &["a"]);
1620 let created = stage_and_commit_in(dir, &["a"], "chore: should be a no-op").unwrap();
1624 assert!(!created, "no diff → no commit should be created");
1625 let log = anodizer_core::test_helpers::output_with_spawn_retry(
1626 || {
1627 let mut cmd = Command::new("git");
1628 cmd.args(["log", "--oneline"]).current_dir(dir);
1629 cmd
1630 },
1631 "git",
1632 );
1633 let log_text = String::from_utf8_lossy(&log.stdout);
1634 assert!(
1635 !log_text.contains("should be a no-op"),
1636 "stage_and_commit_in must not create a commit when no diff: {log_text}"
1637 );
1638 }
1639
1640 #[test]
1641 fn stage_and_commit_in_returns_true_when_file_changed() {
1642 let tmp = tempfile::tempdir().unwrap();
1643 let dir = tmp.path();
1644 init_repo_with_commits(dir, &["a"]);
1645 std::fs::write(dir.join("a"), "changed").unwrap();
1646 let created = stage_and_commit_in(dir, &["a"], "chore: real change").unwrap();
1647 assert!(created, "real change → commit must be created");
1648 let log = anodizer_core::test_helpers::output_with_spawn_retry(
1649 || {
1650 let mut cmd = Command::new("git");
1651 cmd.args(["log", "-1", "--pretty=%s"]).current_dir(dir);
1652 cmd
1653 },
1654 "git",
1655 );
1656 let subject = String::from_utf8_lossy(&log.stdout).trim().to_string();
1657 assert_eq!(subject, "chore: real change");
1658 }
1659
1660 #[test]
1661 fn git_output_in_error_falls_back_to_stdout_when_stderr_empty() {
1662 let tmp = tempfile::tempdir().unwrap();
1663 let dir = tmp.path();
1664 init_repo_with_commits(dir, &["a"]);
1665 let err = git_output_in(dir, &["commit", "-m", "no-op"]).unwrap_err();
1669 let msg = err.to_string();
1670 assert!(
1671 msg.contains("nothing to commit") || msg.contains("clean"),
1672 "error must include stdout detail when stderr is empty: {msg}"
1673 );
1674 }
1675
1676 #[test]
1682 fn default_for_rollback_populates_both_name_and_email() {
1683 let id = CommitterIdentity::default_for_rollback();
1684 assert_eq!(id.name.as_deref(), Some("anodize-rollback"));
1685 let email = id.email.expect("email must be Some");
1686 assert!(
1687 email.starts_with("anodize-rollback@"),
1688 "email must use the anodize-rollback@<host> shape; got {email}"
1689 );
1690 assert!(!email.ends_with('@'), "host portion must not be empty");
1691 }
1692
1693 #[test]
1700 fn revert_commit_in_uses_injected_identity_envs() {
1701 let tmp = tempfile::tempdir().unwrap();
1702 let dir = tmp.path();
1703 let run_env = |args: &[&str], extra: &[(&str, &str)]| {
1704 let out = anodizer_core::test_helpers::output_with_spawn_retry(
1705 || {
1706 let mut cmd = Command::new("git");
1707 cmd.args(args)
1708 .current_dir(dir)
1709 .env("GIT_AUTHOR_NAME", "bootstrap")
1710 .env("GIT_AUTHOR_EMAIL", "bootstrap@b.com")
1711 .env("GIT_COMMITTER_NAME", "bootstrap")
1712 .env("GIT_COMMITTER_EMAIL", "bootstrap@b.com");
1713 for (k, v) in extra {
1714 cmd.env(k, v);
1715 }
1716 cmd
1717 },
1718 "git",
1719 );
1720 assert!(
1721 out.status.success(),
1722 "git {args:?} failed: {}",
1723 String::from_utf8_lossy(&out.stderr)
1724 );
1725 };
1726 run_env(&["init", "-b", "master"], &[]);
1727 std::fs::write(dir.join("a"), "0").unwrap();
1728 run_env(&["add", "."], &[]);
1729 run_env(&["commit", "-m", "initial"], &[]);
1730 std::fs::write(dir.join("a"), "1").unwrap();
1731 run_env(&["add", "."], &[]);
1732 run_env(&["commit", "-m", "chore(release): v1.0.0"], &[]);
1733 let bump_sha = get_head_commit_in(dir).unwrap();
1734
1735 let identity = CommitterIdentity {
1739 name: Some("rollback-bot".to_string()),
1740 email: Some("rollback-bot@anodize.test".to_string()),
1741 };
1742 revert_commit_in(dir, &bump_sha, Some("chore(release): rollback"), &identity)
1743 .expect("revert with injected identity must succeed");
1744
1745 let out = anodizer_core::test_helpers::output_with_spawn_retry(
1748 || {
1749 let mut cmd = Command::new("git");
1750 cmd.current_dir(dir)
1751 .args(["log", "-1", "--format=%ae"])
1752 .env("GIT_TERMINAL_PROMPT", "0")
1753 .env("LC_ALL", "C");
1754 cmd
1755 },
1756 "git",
1757 );
1758 let author_email = String::from_utf8_lossy(&out.stdout).trim().to_string();
1759 assert_eq!(
1760 author_email, "rollback-bot@anodize.test",
1761 "revert commit must carry the injected committer identity"
1762 );
1763
1764 let cfg = anodizer_core::test_helpers::output_with_spawn_retry(
1767 || {
1768 let mut cmd = Command::new("git");
1769 cmd.current_dir(dir)
1770 .args(["config", "--local", "--get", "user.email"])
1771 .env("GIT_TERMINAL_PROMPT", "0")
1772 .env("LC_ALL", "C");
1773 cmd
1774 },
1775 "git",
1776 );
1777 assert!(
1778 !cfg.status.success() || cfg.stdout.is_empty(),
1779 "revert must not write user.email into the repo's local config; got: {}",
1780 String::from_utf8_lossy(&cfg.stdout)
1781 );
1782 }
1783
1784 #[test]
1789 fn revert_commit_in_aborts_on_conflict_and_leaves_tree_clean() {
1790 let tmp = tempfile::tempdir().unwrap();
1791 let dir = tmp.path();
1792 let run = |args: &[&str]| {
1793 let out = anodizer_core::test_helpers::output_with_spawn_retry(
1794 || {
1795 let mut cmd = Command::new("git");
1796 cmd.args(args)
1797 .current_dir(dir)
1798 .env("GIT_AUTHOR_NAME", "t")
1799 .env("GIT_AUTHOR_EMAIL", "t@t.com")
1800 .env("GIT_COMMITTER_NAME", "t")
1801 .env("GIT_COMMITTER_EMAIL", "t@t.com");
1802 cmd
1803 },
1804 "git",
1805 );
1806 assert!(
1807 out.status.success(),
1808 "git {args:?} failed: {}",
1809 String::from_utf8_lossy(&out.stderr)
1810 );
1811 };
1812 run(&["init", "-b", "master"]);
1813 run(&["config", "user.email", "t@t.com"]);
1814 run(&["config", "user.name", "t"]);
1815 std::fs::write(dir.join("x"), "v1\n").unwrap();
1817 run(&["add", "."]);
1818 run(&["commit", "-m", "initial"]);
1819 std::fs::write(dir.join("x"), "v2\n").unwrap();
1821 run(&["add", "."]);
1822 run(&["commit", "-m", "chore(release): v2"]);
1823 let bump_sha = get_head_commit_in(dir).unwrap();
1824 std::fs::write(dir.join("x"), "v3\n").unwrap();
1828 run(&["add", "."]);
1829 run(&["commit", "-m", "feat: overlap"]);
1830
1831 let identity = CommitterIdentity::default();
1832 let err = revert_commit_in(dir, &bump_sha, None, &identity)
1833 .expect_err("revert against overlapping HEAD must conflict and bail");
1834 let msg = format!("{err}");
1835 assert!(
1836 msg.contains("aborted"),
1837 "bail message must mention abort recovery: {msg}"
1838 );
1839
1840 assert!(
1844 !dir.join(".git/REVERT_HEAD").exists(),
1845 ".git/REVERT_HEAD must be cleaned up after --abort"
1846 );
1847 let status_out = anodizer_core::test_helpers::output_with_spawn_retry(
1848 || {
1849 let mut cmd = Command::new("git");
1850 cmd.args(["status", "--porcelain"]).current_dir(dir);
1851 cmd
1852 },
1853 "git",
1854 );
1855 assert!(
1856 status_out.stdout.is_empty(),
1857 "working tree must be clean after revert --abort; got:\n{}",
1858 String::from_utf8_lossy(&status_out.stdout)
1859 );
1860 }
1861
1862 #[test]
1867 fn commits_with_subjects_in_returns_all_pairs_in_one_call() {
1868 let tmp = tempfile::tempdir().unwrap();
1869 let dir = tmp.path();
1870 let run = |args: &[&str]| {
1871 let out = anodizer_core::test_helpers::output_with_spawn_retry(
1872 || {
1873 let mut cmd = Command::new("git");
1874 cmd.args(args)
1875 .current_dir(dir)
1876 .env("GIT_AUTHOR_NAME", "t")
1877 .env("GIT_AUTHOR_EMAIL", "t@t.com")
1878 .env("GIT_COMMITTER_NAME", "t")
1879 .env("GIT_COMMITTER_EMAIL", "t@t.com");
1880 cmd
1881 },
1882 "git",
1883 );
1884 assert!(out.status.success(), "git {args:?} failed");
1885 };
1886 run(&["init", "-b", "master"]);
1887 run(&["config", "user.email", "t@t.com"]);
1888 run(&["config", "user.name", "t"]);
1889 std::fs::write(dir.join("a"), "0").unwrap();
1890 run(&["add", "."]);
1891 run(&["commit", "-m", "initial"]);
1892 let base = get_head_commit_in(dir).unwrap();
1893 std::fs::write(dir.join("a"), "1").unwrap();
1894 run(&["add", "."]);
1895 run(&["commit", "-m", "feat: A with extra detail"]);
1896 std::fs::write(dir.join("a"), "2").unwrap();
1897 run(&["add", "."]);
1898 run(&["commit", "-m", "fix: B"]);
1899
1900 let pairs = commits_with_subjects_in(dir, &base).unwrap();
1901 assert_eq!(pairs.len(), 2, "two commits sit on top of base");
1902 assert_eq!(pairs[0].1, "fix: B");
1904 assert_eq!(pairs[1].1, "feat: A with extra detail");
1905
1906 let head = get_head_commit_in(dir).unwrap();
1908 assert!(commits_with_subjects_in(dir, &head).unwrap().is_empty());
1909 }
1910
1911 #[test]
1912 fn parse_commit_output_with_files_pairs_each_commit_with_its_files() {
1913 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";
1916 let parsed = parse_commit_output_with_files(raw);
1917 assert_eq!(parsed.len(), 2);
1918 assert_eq!(parsed[0].commit.message, "fix: B");
1919 assert_eq!(parsed[0].files, vec!["crates/cli/main.rs".to_string()]);
1920 assert_eq!(parsed[1].commit.message, "feat: A");
1921 assert_eq!(
1922 parsed[1].files,
1923 vec!["crates/core/lib.rs".to_string(), "Cargo.toml".to_string()]
1924 );
1925 }
1926
1927 #[test]
1928 fn parse_commit_output_with_files_preserves_multiline_body_at_idx_gt_0() {
1929 let body0 = "detail line one\ndetail line two\n\nCo-Authored-By: Bob <bob@b.com>";
1934 let raw = format!(
1935 "h1\x1fs1\x1ffix: B\x1ft\x1ft@t\x1f\x1e\ncrates/cli/main.rs\n\n\
1936 h0\x1fs0\x1ffeat: A\x1ft\x1ft@t\x1f{body0}\x1e\ncrates/core/lib.rs\n"
1937 );
1938 let parsed = parse_commit_output_with_files(&raw);
1939 assert_eq!(parsed.len(), 2);
1940 assert_eq!(parsed[1].commit.message, "feat: A");
1942 assert_eq!(parsed[1].commit.body, body0);
1943 assert!(
1944 parsed[1]
1945 .commit
1946 .body
1947 .contains("Co-Authored-By: Bob <bob@b.com>"),
1948 "multi-line body trailer dropped: {:?}",
1949 parsed[1].commit.body
1950 );
1951 assert_eq!(parsed[1].files, vec!["crates/core/lib.rs".to_string()]);
1952 }
1953
1954 #[test]
1955 fn get_commits_between_paths_with_files_in_reports_touched_files() {
1956 let tmp = tempfile::tempdir().unwrap();
1957 let dir = tmp.path();
1958 let run = |args: &[&str]| {
1959 assert!(
1960 anodizer_core::test_helpers::output_with_spawn_retry(
1961 || {
1962 let mut cmd = Command::new("git");
1963 cmd.args(args)
1964 .current_dir(dir)
1965 .env("GIT_AUTHOR_NAME", "t")
1966 .env("GIT_AUTHOR_EMAIL", "t@t.com")
1967 .env("GIT_COMMITTER_NAME", "t")
1968 .env("GIT_COMMITTER_EMAIL", "t@t.com");
1969 cmd
1970 },
1971 "git",
1972 )
1973 .status
1974 .success()
1975 );
1976 };
1977 run(&["init"]);
1978 run(&["config", "user.email", "t@t.com"]);
1979 run(&["config", "user.name", "t"]);
1980 std::fs::write(dir.join("base"), "0").unwrap();
1981 run(&["add", "."]);
1982 run(&["commit", "-m", "initial"]);
1983 let base = get_head_commit_in(dir).unwrap();
1984 std::fs::create_dir_all(dir.join("crates/core")).unwrap();
1985 std::fs::write(dir.join("crates/core/lib.rs"), "1").unwrap();
1986 run(&["add", "."]);
1987 run(&["commit", "-m", "feat: core"]);
1988
1989 let pairs = get_commits_between_paths_with_files_in(dir, &base, "HEAD", &[]).unwrap();
1990 assert_eq!(pairs.len(), 1);
1991 assert_eq!(pairs[0].commit.message, "feat: core");
1992 assert_eq!(pairs[0].files, vec!["crates/core/lib.rs".to_string()]);
1993 }
1994
1995 #[test]
1996 fn get_commits_between_paths_with_files_in_preserves_multiline_body_for_later_commits() {
1997 let tmp = tempfile::tempdir().unwrap();
2003 let dir = tmp.path();
2004 let run = |args: &[&str]| {
2005 assert!(
2006 anodizer_core::test_helpers::output_with_spawn_retry(
2007 || {
2008 let mut cmd = Command::new("git");
2009 cmd.args(args)
2010 .current_dir(dir)
2011 .env("GIT_AUTHOR_NAME", "t")
2012 .env("GIT_AUTHOR_EMAIL", "t@t.com")
2013 .env("GIT_COMMITTER_NAME", "t")
2014 .env("GIT_COMMITTER_EMAIL", "t@t.com");
2015 cmd
2016 },
2017 "git",
2018 )
2019 .status
2020 .success()
2021 );
2022 };
2023 run(&["init"]);
2024 run(&["config", "user.email", "t@t.com"]);
2025 run(&["config", "user.name", "t"]);
2026 std::fs::write(dir.join("base"), "0").unwrap();
2027 run(&["add", "."]);
2028 run(&["commit", "-m", "initial"]);
2029 let base = get_head_commit_in(dir).unwrap();
2030
2031 std::fs::write(dir.join("a.rs"), "1").unwrap();
2033 run(&["add", "."]);
2034 run(&[
2035 "commit",
2036 "-m",
2037 "feat: with body\n\nfirst body line\nsecond body line\n\nCo-Authored-By: Bob <bob@b.com>",
2038 ]);
2039 std::fs::write(dir.join("b.rs"), "2").unwrap();
2041 run(&["add", "."]);
2042 run(&["commit", "-m", "fix: later"]);
2043
2044 let pairs = get_commits_between_paths_with_files_in(dir, &base, "HEAD", &[]).unwrap();
2045 assert_eq!(pairs.len(), 2);
2046 assert_eq!(pairs[0].commit.message, "fix: later");
2048 let body = &pairs[1].commit.body;
2049 assert!(
2050 body.contains("first body line") && body.contains("second body line"),
2051 "multi-line body truncated for idx>0 commit: {body:?}"
2052 );
2053 assert!(
2054 body.contains("Co-Authored-By: Bob <bob@b.com>"),
2055 "Co-Authored-By trailer dropped for idx>0 commit: {body:?}"
2056 );
2057 }
2058
2059 #[test]
2062 fn parse_commit_output_empty_input_yields_no_commits() {
2063 assert!(parse_commit_output("").is_empty());
2064 }
2065
2066 #[test]
2067 fn parse_commit_output_decodes_all_six_fields() {
2068 let raw =
2070 "abc123def\x1fabc123d\x1ffeat: add thing\x1fAlice\x1falice@x.com\x1fbody text\x1e";
2071 let commits = parse_commit_output(raw);
2072 assert_eq!(commits.len(), 1);
2073 let c = &commits[0];
2074 assert_eq!(c.hash, "abc123def");
2075 assert_eq!(c.short_hash, "abc123d");
2076 assert_eq!(c.message, "feat: add thing");
2077 assert_eq!(c.author_name, "Alice");
2078 assert_eq!(c.author_email, "alice@x.com");
2079 assert_eq!(c.body, "body text");
2080 }
2081
2082 #[test]
2083 fn parse_commit_output_trims_hash_and_body_but_keeps_inner_subject() {
2084 let raw = " abc \x1fabc\x1ffix: keep spaces\x1ft\x1ft@t\x1f\n\nbody\n\x1e";
2087 let commits = parse_commit_output(raw);
2088 assert_eq!(commits.len(), 1);
2089 assert_eq!(commits[0].hash, "abc", "hash is trimmed");
2090 assert_eq!(commits[0].message, "fix: keep spaces", "subject verbatim");
2091 assert_eq!(commits[0].body, "body", "body is trimmed");
2092 }
2093
2094 #[test]
2095 fn parse_commit_output_absent_body_field_defaults_to_empty() {
2096 let raw = "h\x1fh\x1fsubject\x1fname\x1fmail\x1e";
2098 let commits = parse_commit_output(raw);
2099 assert_eq!(commits.len(), 1);
2100 assert_eq!(commits[0].body, "");
2101 assert_eq!(commits[0].message, "subject");
2102 }
2103
2104 #[test]
2105 fn parse_commit_output_skips_records_with_too_few_fields() {
2106 let raw = "only\x1ftwo\x1e\
2109 h\x1fh\x1fgood: subject\x1fn\x1fe\x1fbody\x1e";
2110 let commits = parse_commit_output(raw);
2111 assert_eq!(commits.len(), 1, "malformed record dropped, good one kept");
2112 assert_eq!(commits[0].message, "good: subject");
2113 }
2114
2115 #[test]
2116 fn parse_commit_output_multiline_body_survives_record_separator_split() {
2117 let raw = "h1\x1fh1\x1ffeat: A\x1fA\x1fa@x\x1fline one\nline two\n\nCo-Authored-By: B <b@x>\x1e\
2120 h0\x1fh0\x1ffix: B\x1fB\x1fb@x\x1f\x1e";
2121 let commits = parse_commit_output(raw);
2122 assert_eq!(commits.len(), 2);
2123 assert_eq!(commits[0].message, "feat: A");
2124 assert!(commits[0].body.contains("line one\nline two"));
2125 assert!(commits[0].body.contains("Co-Authored-By: B <b@x>"));
2126 assert_eq!(commits[1].message, "fix: B");
2127 assert_eq!(commits[1].body, "");
2128 }
2129
2130 #[test]
2133 fn short_commit_str_truncates_long_sha_to_seven() {
2134 assert_eq!(short_commit_str("abcdef0123456789"), "abcdef0");
2135 assert_eq!(short_commit_str("abcdef0123456789").len(), SHORT_COMMIT_LEN);
2136 }
2137
2138 #[test]
2139 fn short_commit_str_returns_shorter_or_equal_input_unchanged() {
2140 assert_eq!(short_commit_str("abc"), "abc", "shorter than 7 unchanged");
2141 assert_eq!(
2142 short_commit_str("abcdefg"),
2143 "abcdefg",
2144 "exactly 7 unchanged"
2145 );
2146 assert_eq!(short_commit_str(""), "", "empty stays empty");
2147 }
2148
2149 fn g(dir: &Path, args: &[&str]) {
2153 let out = anodizer_core::test_helpers::output_with_spawn_retry(
2154 || {
2155 let mut cmd = Command::new("git");
2156 cmd.args(args)
2157 .current_dir(dir)
2158 .env("GIT_AUTHOR_NAME", "Ada")
2159 .env("GIT_AUTHOR_EMAIL", "ada@x.com")
2160 .env("GIT_COMMITTER_NAME", "Ada")
2161 .env("GIT_COMMITTER_EMAIL", "ada@x.com")
2162 .env("GIT_AUTHOR_DATE", "1715000000 +0000")
2163 .env("GIT_COMMITTER_DATE", "1715000000 +0000");
2164 cmd
2165 },
2166 "git",
2167 );
2168 assert!(
2169 out.status.success(),
2170 "git {args:?} failed: {}",
2171 String::from_utf8_lossy(&out.stderr)
2172 );
2173 }
2174
2175 fn init_bare_repo(dir: &Path) {
2177 g(dir, &["init", "-b", "master"]);
2178 g(dir, &["config", "user.email", "ada@x.com"]);
2179 g(dir, &["config", "user.name", "Ada"]);
2180 }
2181
2182 fn commit_file(dir: &Path, path: &str, content: &str, subject: &str) {
2184 let full = dir.join(path);
2185 if let Some(parent) = full.parent() {
2186 std::fs::create_dir_all(parent).unwrap();
2187 }
2188 std::fs::write(full, content).unwrap();
2189 g(dir, &["add", "."]);
2190 g(dir, &["commit", "-m", subject]);
2191 }
2192
2193 #[test]
2196 fn get_commits_between_in_returns_only_post_base_commits() {
2197 let tmp = tempfile::tempdir().unwrap();
2198 let dir = tmp.path();
2199 init_bare_repo(dir);
2200 commit_file(dir, "a", "0", "initial");
2201 let base = get_head_commit_in(dir).unwrap();
2202 commit_file(dir, "a", "1", "feat: one");
2203 commit_file(dir, "a", "2", "fix: two");
2204
2205 let commits = get_commits_between_in(dir, &base, "HEAD", None).unwrap();
2206 assert_eq!(commits.len(), 2, "two commits sit above base");
2207 assert_eq!(commits[0].message, "fix: two");
2209 assert_eq!(commits[1].message, "feat: one");
2210 assert_eq!(commits[1].author_name, "Ada");
2211 assert_eq!(commits[1].author_email, "ada@x.com");
2212 }
2213
2214 #[test]
2215 fn get_commits_between_in_path_filter_excludes_untouched_files() {
2216 let tmp = tempfile::tempdir().unwrap();
2217 let dir = tmp.path();
2218 init_bare_repo(dir);
2219 commit_file(dir, "base", "0", "initial");
2220 let base = get_head_commit_in(dir).unwrap();
2221 commit_file(dir, "src/lib.rs", "1", "feat: touch lib");
2222 commit_file(dir, "docs/readme", "2", "docs: touch docs only");
2223
2224 let commits = get_commits_between_in(dir, &base, "HEAD", Some("src")).unwrap();
2226 assert_eq!(commits.len(), 1, "only the src-touching commit survives");
2227 assert_eq!(commits[0].message, "feat: touch lib");
2228 }
2229
2230 #[test]
2231 fn get_commits_between_paths_in_unions_multiple_paths() {
2232 let tmp = tempfile::tempdir().unwrap();
2233 let dir = tmp.path();
2234 init_bare_repo(dir);
2235 commit_file(dir, "base", "0", "initial");
2236 let base = get_head_commit_in(dir).unwrap();
2237 commit_file(dir, "a/x", "1", "feat: a");
2238 commit_file(dir, "b/y", "2", "feat: b");
2239 commit_file(dir, "c/z", "3", "feat: c");
2240
2241 let commits =
2243 get_commits_between_paths_in(dir, &base, "HEAD", &["a".into(), "b".into()]).unwrap();
2244 let subjects: Vec<&str> = commits.iter().map(|c| c.message.as_str()).collect();
2245 assert_eq!(
2246 commits.len(),
2247 2,
2248 "a and b touched, c excluded: {subjects:?}"
2249 );
2250 assert!(subjects.contains(&"feat: a"));
2251 assert!(subjects.contains(&"feat: b"));
2252 assert!(!subjects.contains(&"feat: c"));
2253 }
2254
2255 #[test]
2258 fn get_all_commits_in_returns_every_commit_on_head() {
2259 let tmp = tempfile::tempdir().unwrap();
2260 let dir = tmp.path();
2261 init_bare_repo(dir);
2262 commit_file(dir, "a", "0", "first");
2263 commit_file(dir, "a", "1", "second");
2264 commit_file(dir, "a", "2", "third");
2265
2266 let commits = get_all_commits_in(dir, None).unwrap();
2267 assert_eq!(commits.len(), 3);
2268 assert_eq!(commits[0].message, "third", "newest-first");
2269 assert_eq!(commits[2].message, "first");
2270 }
2271
2272 #[test]
2273 fn get_all_commits_paths_in_filters_to_path() {
2274 let tmp = tempfile::tempdir().unwrap();
2275 let dir = tmp.path();
2276 init_bare_repo(dir);
2277 commit_file(dir, "keep/x", "0", "feat: keep");
2278 commit_file(dir, "drop/y", "1", "feat: drop");
2279
2280 let commits = get_all_commits_paths_in(dir, &["keep".into()]).unwrap();
2281 assert_eq!(commits.len(), 1);
2282 assert_eq!(commits[0].message, "feat: keep");
2283 }
2284
2285 #[test]
2286 fn get_all_commits_paths_with_files_in_pairs_files() {
2287 let tmp = tempfile::tempdir().unwrap();
2288 let dir = tmp.path();
2289 init_bare_repo(dir);
2290 commit_file(dir, "crates/core/lib.rs", "0", "feat: core");
2291
2292 let pairs = get_all_commits_paths_with_files_in(dir, &[]).unwrap();
2293 assert_eq!(pairs.len(), 1);
2294 assert_eq!(pairs[0].commit.message, "feat: core");
2295 assert_eq!(pairs[0].files, vec!["crates/core/lib.rs".to_string()]);
2296 }
2297
2298 #[test]
2301 fn get_commits_reachable_paths_in_stops_at_the_given_rev() {
2302 let tmp = tempfile::tempdir().unwrap();
2303 let dir = tmp.path();
2304 init_bare_repo(dir);
2305 commit_file(dir, "a", "0", "first");
2306 commit_file(dir, "a", "1", "second");
2307 let mid = get_head_commit_in(dir).unwrap();
2308 commit_file(dir, "a", "2", "third-after-mid");
2309
2310 let commits = get_commits_reachable_paths_in(dir, &mid, &[]).unwrap();
2312 let subjects: Vec<&str> = commits.iter().map(|c| c.message.as_str()).collect();
2313 assert_eq!(commits.len(), 2, "only ancestors of mid: {subjects:?}");
2314 assert!(subjects.contains(&"first"));
2315 assert!(subjects.contains(&"second"));
2316 assert!(!subjects.contains(&"third-after-mid"));
2317 }
2318
2319 #[test]
2320 fn get_commits_reachable_paths_with_files_in_pairs_touched_files() {
2321 let tmp = tempfile::tempdir().unwrap();
2322 let dir = tmp.path();
2323 init_bare_repo(dir);
2324 commit_file(dir, "src/main.rs", "0", "feat: main");
2325 let head = get_head_commit_in(dir).unwrap();
2326
2327 let pairs = get_commits_reachable_paths_with_files_in(dir, &head, &[]).unwrap();
2328 assert_eq!(pairs.len(), 1);
2329 assert_eq!(pairs[0].commit.message, "feat: main");
2330 assert_eq!(pairs[0].files, vec!["src/main.rs".to_string()]);
2331 }
2332
2333 #[test]
2336 fn get_last_commit_messages_in_returns_n_subjects_newest_first() {
2337 let tmp = tempfile::tempdir().unwrap();
2338 let dir = tmp.path();
2339 init_bare_repo(dir);
2340 commit_file(dir, "a", "0", "one");
2341 commit_file(dir, "a", "1", "two");
2342 commit_file(dir, "a", "2", "three");
2343
2344 let msgs = get_last_commit_messages_in(dir, 2).unwrap();
2345 assert_eq!(msgs, vec!["three".to_string(), "two".to_string()]);
2346 }
2347
2348 #[test]
2349 fn get_commit_messages_between_in_lists_post_base_subjects() {
2350 let tmp = tempfile::tempdir().unwrap();
2351 let dir = tmp.path();
2352 init_bare_repo(dir);
2353 commit_file(dir, "a", "0", "initial");
2354 let base = get_head_commit_in(dir).unwrap();
2355 commit_file(dir, "a", "1", "feat: x");
2356 commit_file(dir, "a", "2", "fix: y");
2357
2358 let msgs = get_commit_messages_between_in(dir, &base, "HEAD").unwrap();
2359 assert_eq!(msgs, vec!["fix: y".to_string(), "feat: x".to_string()]);
2360 }
2361
2362 #[test]
2363 fn get_last_commit_messages_path_in_filters_to_path() {
2364 let tmp = tempfile::tempdir().unwrap();
2365 let dir = tmp.path();
2366 init_bare_repo(dir);
2367 commit_file(dir, "keep/a", "0", "feat: keep");
2368 commit_file(dir, "other/b", "1", "feat: other");
2369
2370 let msgs = get_last_commit_messages_path_in(dir, 10, "keep").unwrap();
2371 assert_eq!(msgs, vec!["feat: keep".to_string()]);
2372 }
2373
2374 #[test]
2375 fn get_commit_messages_between_path_in_filters_range_and_path() {
2376 let tmp = tempfile::tempdir().unwrap();
2377 let dir = tmp.path();
2378 init_bare_repo(dir);
2379 commit_file(dir, "base", "0", "initial");
2380 let base = get_head_commit_in(dir).unwrap();
2381 commit_file(dir, "src/x", "1", "feat: src");
2382 commit_file(dir, "doc/y", "2", "docs: doc");
2383
2384 let msgs = get_commit_messages_between_path_in(dir, &base, "HEAD", "src").unwrap();
2385 assert_eq!(msgs, vec!["feat: src".to_string()]);
2386 }
2387
2388 #[test]
2391 fn has_changes_since_in_detects_path_touched_after_tag() {
2392 let tmp = tempfile::tempdir().unwrap();
2393 let dir = tmp.path();
2394 init_bare_repo(dir);
2395 commit_file(dir, "watched", "0", "initial");
2396 g(dir, &["tag", "v1.0.0"]);
2397 assert!(!has_changes_since_in(dir, "v1.0.0", "watched").unwrap());
2399 commit_file(dir, "watched", "1", "feat: change watched");
2400 assert!(has_changes_since_in(dir, "v1.0.0", "watched").unwrap());
2402 assert!(!has_changes_since_in(dir, "v1.0.0", "unrelated").unwrap());
2404 }
2405
2406 #[test]
2407 fn paths_changed_since_tag_in_true_when_any_path_changed() {
2408 let tmp = tempfile::tempdir().unwrap();
2409 let dir = tmp.path();
2410 init_bare_repo(dir);
2411 commit_file(dir, "a", "0", "initial");
2412 g(dir, &["tag", "v1.0.0"]);
2413 commit_file(dir, "b", "1", "feat: add b");
2414
2415 assert!(paths_changed_since_tag_in(dir, "v1.0.0", &["a", "b"]).unwrap());
2417 assert!(!paths_changed_since_tag_in(dir, "v1.0.0", &["a"]).unwrap());
2419 }
2420
2421 #[test]
2422 fn paths_changed_since_tag_in_returns_false_when_git_fails() {
2423 let tmp = tempfile::tempdir().unwrap();
2426 let dir = tmp.path();
2427 init_bare_repo(dir);
2428 commit_file(dir, "a", "0", "initial");
2429 assert!(!paths_changed_since_tag_in(dir, "nope-no-such-tag", &["a"]).unwrap());
2430 }
2431
2432 #[test]
2435 fn head_commit_hash_in_matches_rev_parse_head() {
2436 let tmp = tempfile::tempdir().unwrap();
2437 let dir = tmp.path();
2438 init_bare_repo(dir);
2439 commit_file(dir, "a", "0", "initial");
2440 let expected = get_head_commit_in(dir).unwrap();
2441 assert_eq!(head_commit_hash_in(dir).unwrap(), expected);
2442 }
2443
2444 #[test]
2445 fn head_commit_hash_in_errors_on_non_repo() {
2446 let tmp = tempfile::tempdir().unwrap();
2447 assert!(head_commit_hash_in(tmp.path()).is_err());
2449 }
2450
2451 #[test]
2452 fn rev_parse_in_resolves_branch_to_full_sha() {
2453 let tmp = tempfile::tempdir().unwrap();
2454 let dir = tmp.path();
2455 init_bare_repo(dir);
2456 commit_file(dir, "a", "0", "initial");
2457 let head = get_head_commit_in(dir).unwrap();
2458 assert_eq!(rev_parse_in(dir, "master").unwrap(), head);
2459 }
2460
2461 #[test]
2462 fn rev_verify_commit_in_accepts_commit_rejects_unknown() {
2463 let tmp = tempfile::tempdir().unwrap();
2464 let dir = tmp.path();
2465 init_bare_repo(dir);
2466 commit_file(dir, "a", "0", "initial");
2467 let head = get_head_commit_in(dir).unwrap();
2468 assert_eq!(rev_verify_commit_in(dir, "HEAD").unwrap(), head);
2469 assert!(rev_verify_commit_in(dir, "deadbeefdeadbeef").is_err());
2471 }
2472
2473 #[test]
2474 fn commits_between_in_lists_shas_above_base_and_empty_at_head() {
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 base = get_head_commit_in(dir).unwrap();
2480 commit_file(dir, "a", "1", "second");
2481 let head = get_head_commit_in(dir).unwrap();
2482
2483 let shas = commits_between_in(dir, &base).unwrap();
2484 assert_eq!(
2485 shas,
2486 vec![head.clone()],
2487 "exactly the one commit above base"
2488 );
2489 assert!(commits_between_in(dir, &head).unwrap().is_empty());
2491 }
2492
2493 #[test]
2494 fn commit_subject_in_returns_single_commit_subject() {
2495 let tmp = tempfile::tempdir().unwrap();
2496 let dir = tmp.path();
2497 init_bare_repo(dir);
2498 commit_file(dir, "a", "0", "feat: only-subject\n\nignored body");
2499 let head = get_head_commit_in(dir).unwrap();
2500 assert_eq!(commit_subject_in(dir, &head).unwrap(), "feat: only-subject");
2501 }
2502
2503 #[test]
2504 fn head_commit_timestamp_in_returns_pinned_committer_epoch() {
2505 let tmp = tempfile::tempdir().unwrap();
2506 let dir = tmp.path();
2507 init_bare_repo(dir);
2508 commit_file(dir, "a", "0", "initial");
2510 assert_eq!(head_commit_timestamp_in(dir).unwrap(), 1_715_000_000);
2511 }
2512
2513 #[test]
2516 fn log_subjects_for_range_returns_full_bodies_for_path() {
2517 let tmp = tempfile::tempdir().unwrap();
2518 let dir = tmp.path();
2519 init_bare_repo(dir);
2520 commit_file(dir, "watched", "0", "feat: A\n\nbody of A");
2521 commit_file(dir, "watched", "1", "fix: B");
2522
2523 let bodies = log_subjects_for_range(dir, "HEAD", "watched").unwrap();
2524 assert_eq!(bodies.len(), 2);
2525 assert!(bodies[0].starts_with("fix: B"));
2527 assert!(bodies[1].contains("feat: A") && bodies[1].contains("body of A"));
2528 }
2529
2530 #[test]
2531 fn log_subjects_for_range_returns_empty_when_range_invalid() {
2532 let tmp = tempfile::tempdir().unwrap();
2533 let dir = tmp.path();
2534 init_bare_repo(dir);
2535 commit_file(dir, "a", "0", "initial");
2536 let bodies = log_subjects_for_range(dir, "no-such-ref..HEAD", "a").unwrap();
2539 assert!(bodies.is_empty());
2540 }
2541
2542 #[test]
2545 fn add_path_in_then_commit_in_creates_commit() {
2546 let tmp = tempfile::tempdir().unwrap();
2547 let dir = tmp.path();
2548 init_bare_repo(dir);
2549 commit_file(dir, "seed", "0", "initial");
2550 std::fs::write(dir.join("new.txt"), "hello").unwrap();
2551
2552 add_path_in(dir, std::path::Path::new("new.txt")).unwrap();
2553 commit_in(dir, "feat: add new.txt", false).unwrap();
2554
2555 let subject = String::from_utf8(
2556 anodizer_core::test_helpers::output_with_spawn_retry(
2557 || {
2558 let mut cmd = Command::new("git");
2559 cmd.args(["log", "-1", "--pretty=%s"]).current_dir(dir);
2560 cmd
2561 },
2562 "git",
2563 )
2564 .stdout,
2565 )
2566 .unwrap()
2567 .trim()
2568 .to_string();
2569 assert_eq!(subject, "feat: add new.txt");
2570 }
2571
2572 #[test]
2573 fn add_path_in_errors_on_missing_file() {
2574 let tmp = tempfile::tempdir().unwrap();
2575 let dir = tmp.path();
2576 init_bare_repo(dir);
2577 let err = add_path_in(dir, std::path::Path::new("does-not-exist")).unwrap_err();
2578 assert!(
2579 err.to_string().contains("git add"),
2580 "error must name the failing git add: {err}"
2581 );
2582 }
2583
2584 #[test]
2587 fn reset_hard_in_moves_head_and_restores_tree() {
2588 let tmp = tempfile::tempdir().unwrap();
2589 let dir = tmp.path();
2590 init_bare_repo(dir);
2591 commit_file(dir, "a", "first", "first");
2592 let target = get_head_commit_in(dir).unwrap();
2593 commit_file(dir, "a", "second", "second");
2594 assert_ne!(get_head_commit_in(dir).unwrap(), target);
2595
2596 reset_hard_in(dir, &target).unwrap();
2597 assert_eq!(get_head_commit_in(dir).unwrap(), target, "HEAD moved back");
2598 assert_eq!(
2599 std::fs::read_to_string(dir.join("a")).unwrap(),
2600 "first",
2601 "working tree restored to target content"
2602 );
2603 }
2604
2605 #[test]
2608 fn push_branch_in_bails_without_origin_remote() {
2609 let tmp = tempfile::tempdir().unwrap();
2610 let dir = tmp.path();
2611 init_bare_repo(dir);
2612 commit_file(dir, "a", "0", "initial");
2613 let err = push_branch_in(dir, "master").unwrap_err();
2614 assert!(
2615 err.to_string().contains("no 'origin' remote"),
2616 "missing-remote bail must be explicit: {err}"
2617 );
2618 }
2619
2620 #[test]
2623 #[serial_test::serial(git_env)]
2624 fn resolve_rollback_identity_inherits_when_repo_has_identity() {
2625 let tmp = tempfile::tempdir().unwrap();
2626 let dir = tmp.path();
2627 init_bare_repo(dir); struct EnvGuard(Vec<(&'static str, Option<String>)>);
2632 impl Drop for EnvGuard {
2633 fn drop(&mut self) {
2634 for (k, v) in &self.0 {
2635 match v {
2636 Some(val) => unsafe { std::env::set_var(k, val) },
2638 None => unsafe { std::env::remove_var(k) },
2640 }
2641 }
2642 }
2643 }
2644 let keys = [
2645 "GIT_AUTHOR_NAME",
2646 "GIT_AUTHOR_EMAIL",
2647 "GIT_COMMITTER_NAME",
2648 "GIT_COMMITTER_EMAIL",
2649 ];
2650 let _g = EnvGuard(keys.iter().map(|k| (*k, std::env::var(k).ok())).collect());
2651 for k in keys {
2652 unsafe { std::env::remove_var(k) };
2654 }
2655
2656 let id = resolve_rollback_identity(dir);
2658 assert!(
2659 id.name.is_none() && id.email.is_none(),
2660 "configured repo identity must be inherited, not overridden: {id:?}"
2661 );
2662 }
2663
2664 #[test]
2665 #[serial_test::serial(git_env)]
2666 fn resolve_rollback_identity_synthesizes_when_no_identity_anywhere() {
2667 let tmp = tempfile::tempdir().unwrap();
2668 let dir = tmp.path();
2669 g(dir, &["init", "-b", "master"]);
2671
2672 struct EnvGuard(Vec<(&'static str, Option<String>)>);
2673 impl Drop for EnvGuard {
2674 fn drop(&mut self) {
2675 for (k, v) in &self.0 {
2676 match v {
2677 Some(val) => unsafe { std::env::set_var(k, val) },
2679 None => unsafe { std::env::remove_var(k) },
2681 }
2682 }
2683 }
2684 }
2685 let keys = [
2686 "GIT_AUTHOR_NAME",
2687 "GIT_AUTHOR_EMAIL",
2688 "GIT_COMMITTER_NAME",
2689 "GIT_COMMITTER_EMAIL",
2690 ];
2691 let _g = EnvGuard(keys.iter().map(|k| (*k, std::env::var(k).ok())).collect());
2692 for k in keys {
2693 unsafe { std::env::remove_var(k) };
2695 }
2696
2697 let (n, e) = read_git_identity(dir);
2700 if n.is_none() || e.is_none() {
2701 let id = resolve_rollback_identity(dir);
2702 assert_eq!(id.name.as_deref(), Some("anodize-rollback"));
2703 assert!(
2704 id.email
2705 .as_deref()
2706 .unwrap_or("")
2707 .starts_with("anodize-rollback@"),
2708 "synthetic identity required when no config present: {id:?}"
2709 );
2710 }
2711 }
2712
2713 #[test]
2714 fn read_git_identity_reads_configured_values() {
2715 let tmp = tempfile::tempdir().unwrap();
2716 let dir = tmp.path();
2717 g(dir, &["init", "-b", "master"]);
2718 g(dir, &["config", "user.name", "Configured Name"]);
2719 g(dir, &["config", "user.email", "configured@x.com"]);
2720
2721 let (name, email) = read_git_identity(dir);
2722 assert_eq!(name.as_deref(), Some("Configured Name"));
2723 assert_eq!(email.as_deref(), Some("configured@x.com"));
2724 }
2725}