1use anyhow::{Context as _, Result, bail};
2use std::path::{Path, PathBuf};
3use std::process::Command;
4
5use super::git_output_in;
6
7pub const RELEASE_COMMIT_PREFIX: &str = "chore(release): ";
13
14pub fn release_bump_subject_prefix() -> String {
17 format!("{RELEASE_COMMIT_PREFIX}bump ")
18}
19
20pub fn release_bump_subject(summary: &str, suffix: &str) -> String {
24 format!("{}{summary}{suffix}", release_bump_subject_prefix())
25}
26
27#[derive(Debug, Clone)]
28pub struct Commit {
29 pub hash: String,
30 pub short_hash: String,
31 pub message: String,
32 pub author_name: String,
33 pub author_email: String,
34 pub body: String,
37}
38
39pub fn parse_commit_output(output: &str) -> Vec<Commit> {
50 if output.is_empty() {
51 return vec![];
52 }
53 output
54 .split('\x1e')
55 .filter(|record| !record.trim().is_empty())
56 .filter_map(|record| {
57 let fields: Vec<&str> = record.split('\x1f').collect();
58 if fields.len() >= 5 {
59 Some(Commit {
60 hash: fields[0].trim().to_string(),
61 short_hash: fields[1].to_string(),
62 message: fields[2].to_string(),
63 author_name: fields[3].to_string(),
64 author_email: fields[4].to_string(),
65 body: fields.get(5).unwrap_or(&"").trim().to_string(),
66 })
67 } else {
68 None
69 }
70 })
71 .collect()
72}
73
74fn cwd_or_dot() -> PathBuf {
75 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
76}
77
78pub fn get_commits_between(from: &str, to: &str, path_filter: Option<&str>) -> Result<Vec<Commit>> {
80 get_commits_between_in(&cwd_or_dot(), from, to, path_filter)
81}
82
83pub fn get_commits_between_in(
85 cwd: &Path,
86 from: &str,
87 to: &str,
88 path_filter: Option<&str>,
89) -> Result<Vec<Commit>> {
90 get_commits_between_paths_in(
91 cwd,
92 from,
93 to,
94 &path_filter
95 .into_iter()
96 .map(String::from)
97 .collect::<Vec<_>>(),
98 )
99}
100
101pub fn get_commits_between_paths(from: &str, to: &str, paths: &[String]) -> Result<Vec<Commit>> {
103 get_commits_between_paths_in(&cwd_or_dot(), from, to, paths)
104}
105
106pub fn get_commits_between_paths_in(
108 cwd: &Path,
109 from: &str,
110 to: &str,
111 paths: &[String],
112) -> Result<Vec<Commit>> {
113 let range = format!("{}..{}", from, to);
114 let mut args = vec![
115 "-c".to_string(),
116 "log.showSignature=false".to_string(),
117 "log".to_string(),
118 "--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e".to_string(),
119 range,
120 ];
121 if !paths.is_empty() {
122 args.push("--".to_string());
123 for p in paths {
124 args.push(p.clone());
125 }
126 }
127 let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
128 let output = git_output_in(cwd, &arg_refs)?;
129 Ok(parse_commit_output(&output))
130}
131
132pub fn get_all_commits(path_filter: Option<&str>) -> Result<Vec<Commit>> {
135 get_all_commits_in(&cwd_or_dot(), path_filter)
136}
137
138pub fn get_all_commits_in(cwd: &Path, path_filter: Option<&str>) -> Result<Vec<Commit>> {
140 get_all_commits_paths_in(
141 cwd,
142 &path_filter
143 .into_iter()
144 .map(String::from)
145 .collect::<Vec<_>>(),
146 )
147}
148
149pub fn get_all_commits_paths(paths: &[String]) -> Result<Vec<Commit>> {
151 get_all_commits_paths_in(&cwd_or_dot(), paths)
152}
153
154pub fn get_all_commits_paths_in(cwd: &Path, paths: &[String]) -> Result<Vec<Commit>> {
156 let mut args = vec![
157 "-c".to_string(),
158 "log.showSignature=false".to_string(),
159 "log".to_string(),
160 "--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e".to_string(),
161 "HEAD".to_string(),
162 ];
163 if !paths.is_empty() {
164 args.push("--".to_string());
165 for p in paths {
166 args.push(p.clone());
167 }
168 }
169 let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
170 let output = git_output_in(cwd, &arg_refs)?;
171 Ok(parse_commit_output(&output))
172}
173
174#[derive(Debug, Clone)]
180pub struct CommitWithFiles {
181 pub commit: Commit,
183 pub files: Vec<String>,
185}
186
187pub fn parse_commit_output_with_files(output: &str) -> Vec<CommitWithFiles> {
204 if output.is_empty() {
205 return vec![];
206 }
207 let segments: Vec<&str> = output.split('\x1e').collect();
208 let mut out: Vec<CommitWithFiles> = Vec::new();
209 for (idx, seg) in segments.iter().enumerate() {
215 let metadata = if idx == 0 {
223 seg.trim_start_matches(['\n', '\r']).to_string()
224 } else {
225 let lines: Vec<&str> = seg.split('\n').collect();
226 match lines.iter().position(|line| line.contains('\x1f')) {
227 Some(start) => lines[start..].join("\n"),
228 None => String::new(),
229 }
230 };
231 if metadata.trim().is_empty() {
232 continue;
233 }
234 let commits = parse_commit_output(&metadata);
235 let Some(commit) = commits.into_iter().next() else {
236 continue;
237 };
238 let files = match segments.get(idx + 1) {
241 Some(next) => next
242 .split('\n')
243 .map(str::trim)
244 .take_while(|line| !line.contains('\x1f'))
245 .filter(|line| !line.is_empty())
246 .map(str::to_string)
247 .collect(),
248 None => Vec::new(),
249 };
250 out.push(CommitWithFiles { commit, files });
251 }
252 out
253}
254
255pub fn get_commits_between_paths_with_files_in(
259 cwd: &Path,
260 from: &str,
261 to: &str,
262 paths: &[String],
263) -> Result<Vec<CommitWithFiles>> {
264 let range = format!("{}..{}", from, to);
265 let mut args = vec![
266 "-c".to_string(),
267 "log.showSignature=false".to_string(),
268 "log".to_string(),
269 "--name-only".to_string(),
270 "--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e".to_string(),
271 range,
272 ];
273 if !paths.is_empty() {
274 args.push("--".to_string());
275 for p in paths {
276 args.push(p.clone());
277 }
278 }
279 let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
280 let output = git_output_in(cwd, &arg_refs)?;
281 Ok(parse_commit_output_with_files(&output))
282}
283
284pub fn get_all_commits_paths_with_files_in(
286 cwd: &Path,
287 paths: &[String],
288) -> Result<Vec<CommitWithFiles>> {
289 let mut args = vec![
290 "-c".to_string(),
291 "log.showSignature=false".to_string(),
292 "log".to_string(),
293 "--name-only".to_string(),
294 "--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e".to_string(),
295 "HEAD".to_string(),
296 ];
297 if !paths.is_empty() {
298 args.push("--".to_string());
299 for p in paths {
300 args.push(p.clone());
301 }
302 }
303 let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
304 let output = git_output_in(cwd, &arg_refs)?;
305 Ok(parse_commit_output_with_files(&output))
306}
307
308pub fn get_commits_reachable_paths_in(
313 cwd: &Path,
314 rev: &str,
315 paths: &[String],
316) -> Result<Vec<Commit>> {
317 let mut args = vec![
318 "-c".to_string(),
319 "log.showSignature=false".to_string(),
320 "log".to_string(),
321 "--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e".to_string(),
322 rev.to_string(),
323 ];
324 if !paths.is_empty() {
325 args.push("--".to_string());
326 for p in paths {
327 args.push(p.clone());
328 }
329 }
330 let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
331 let output = git_output_in(cwd, &arg_refs)?;
332 Ok(parse_commit_output(&output))
333}
334
335pub fn get_commits_reachable_paths_with_files_in(
337 cwd: &Path,
338 rev: &str,
339 paths: &[String],
340) -> Result<Vec<CommitWithFiles>> {
341 let mut args = vec![
342 "-c".to_string(),
343 "log.showSignature=false".to_string(),
344 "log".to_string(),
345 "--name-only".to_string(),
346 "--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e".to_string(),
347 rev.to_string(),
348 ];
349 if !paths.is_empty() {
350 args.push("--".to_string());
351 for p in paths {
352 args.push(p.clone());
353 }
354 }
355 let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
356 let output = git_output_in(cwd, &arg_refs)?;
357 Ok(parse_commit_output_with_files(&output))
358}
359
360pub fn get_last_commit_messages(count: usize) -> Result<Vec<String>> {
362 get_last_commit_messages_in(&cwd_or_dot(), count)
363}
364
365pub fn get_last_commit_messages_in(cwd: &Path, count: usize) -> Result<Vec<String>> {
367 let output = git_output_in(
368 cwd,
369 &[
370 "-c",
371 "log.showSignature=false",
372 "log",
373 &format!("-{count}"),
374 "--pretty=format:%s",
375 ],
376 )?;
377 Ok(output.lines().map(str::to_string).collect())
378}
379
380pub fn get_commit_messages_between(from: &str, to: &str) -> Result<Vec<String>> {
382 get_commit_messages_between_in(&cwd_or_dot(), from, to)
383}
384
385pub fn get_commit_messages_between_in(cwd: &Path, from: &str, to: &str) -> Result<Vec<String>> {
387 let output = git_output_in(
388 cwd,
389 &[
390 "-c",
391 "log.showSignature=false",
392 "log",
393 "--pretty=format:%s",
394 &format!("{from}..{to}"),
395 ],
396 )?;
397 Ok(output.lines().map(str::to_string).collect())
398}
399
400pub fn get_current_branch() -> Result<String> {
402 get_current_branch_in(&cwd_or_dot())
403}
404
405pub fn is_branchlike(name: &str) -> bool {
430 use regex::Regex;
431 use std::sync::OnceLock;
432 static LOCKSTEP: OnceLock<Regex> = OnceLock::new();
433 static PER_CRATE: OnceLock<Regex> = OnceLock::new();
434 let lockstep = LOCKSTEP.get_or_init(|| Regex::new(r"^v\d+\.\d+\.\d+").expect("static regex"));
435 let per_crate =
436 PER_CRATE.get_or_init(|| Regex::new(r"^[^/]+-v\d+\.\d+\.\d+").expect("static regex"));
437 !(lockstep.is_match(name) || per_crate.is_match(name))
438}
439
440pub fn get_current_branch_in(cwd: &Path) -> Result<String> {
454 get_current_branch_in_with_env(cwd, &crate::ProcessEnvSource)
455}
456
457pub fn get_current_branch_in_with_env<E: crate::EnvSource + ?Sized>(
463 cwd: &Path,
464 env: &E,
465) -> Result<String> {
466 if let Ok(name) = git_output_in(cwd, &["symbolic-ref", "--short", "HEAD"]) {
467 return Ok(name);
468 }
469 if let Ok(out) = git_output_in(
470 cwd,
471 &[
472 "for-each-ref",
473 "--points-at",
474 "HEAD",
475 "--format=%(refname:short)",
476 "refs/heads/",
477 ],
478 ) && !out.is_empty()
479 {
480 let branches: Vec<&str> = out.lines().collect();
481 for preferred in ["master", "main"] {
482 if branches.contains(&preferred) {
483 return Ok(preferred.to_string());
484 }
485 }
486 if let Some(first) = branches.first() {
487 return Ok((*first).to_string());
488 }
489 }
490 if let Ok(out) = git_output_in(
491 cwd,
492 &["symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
493 ) && let Some(name) = out.strip_prefix("origin/")
494 {
495 return Ok(name.to_string());
496 }
497 if let Some(name) = env.var("GITHUB_REF_NAME")
498 && !name.is_empty()
499 && is_branchlike(&name)
500 {
501 return Ok(name);
502 }
503 anyhow::bail!(
504 "could not resolve current branch: HEAD is detached and no fallback (points-at-HEAD branches, origin/HEAD, GITHUB_REF_NAME) succeeded"
505 )
506}
507
508pub fn branches_containing_sha_in(cwd: &Path, sha: &str) -> Result<Vec<String>> {
514 let out = git_output_in(
515 cwd,
516 &[
517 "branch",
518 "-r",
519 "--contains",
520 sha,
521 "--format=%(refname:short)",
522 ],
523 )?;
524 Ok(out
525 .lines()
526 .filter_map(|line| line.trim().strip_prefix("origin/").map(str::to_string))
527 .filter(|name| !name.is_empty() && name != "HEAD")
528 .collect())
529}
530
531pub fn has_commits_since_tag(tag: &str) -> Result<bool> {
533 has_commits_since_tag_in(&cwd_or_dot(), tag)
534}
535
536pub fn has_commits_since_tag_in(cwd: &Path, tag: &str) -> Result<bool> {
538 let range = format!("{}..HEAD", tag);
539 let output = git_output_in(
540 cwd,
541 &["-c", "log.showSignature=false", "log", "--oneline", &range],
542 )?;
543 Ok(!output.is_empty())
544}
545
546pub fn count_commits_since_last_tag_in(cwd: &Path, monorepo_prefix: Option<&str>) -> Result<u64> {
566 let match_arg;
575 let mut describe_args: Vec<&str> = vec!["describe", "--tags", "--abbrev=0"];
576 if let Some(prefix) = monorepo_prefix {
577 match_arg = format!("--match={}*", prefix);
578 describe_args.push(&match_arg);
579 }
580 describe_args.push("HEAD");
581 let range = match git_output_in(cwd, &describe_args) {
582 Ok(tag) if !tag.is_empty() => format!("{tag}..HEAD"),
583 _ => "HEAD".to_string(),
584 };
585 let count = match git_output_in(cwd, &["rev-list", "--count", &range]) {
587 Ok(s) => s.trim().parse::<u64>().unwrap_or(0),
588 Err(_) => 0,
589 };
590 Ok(count)
591}
592
593pub fn get_short_commit() -> Result<String> {
595 get_short_commit_in(&cwd_or_dot())
596}
597
598pub fn get_short_commit_in(cwd: &Path) -> Result<String> {
600 git_output_in(cwd, &["rev-parse", "--short", "HEAD"])
601}
602
603pub const SHORT_COMMIT_LEN: usize = 7;
609
610pub fn short_commit_str(commit: &str) -> String {
621 if commit.len() > SHORT_COMMIT_LEN {
622 commit[..SHORT_COMMIT_LEN].to_string()
623 } else {
624 commit.to_string()
625 }
626}
627
628pub fn get_head_commit() -> Result<String> {
635 get_head_commit_in(&cwd_or_dot())
636}
637
638pub fn get_head_commit_in(cwd: &Path) -> Result<String> {
640 git_output_in(cwd, &["rev-parse", "HEAD"])
641}
642
643pub fn has_changes_since(tag: &str, path: &str) -> Result<bool> {
645 has_changes_since_in(&cwd_or_dot(), tag, path)
646}
647
648pub fn has_changes_since_in(cwd: &Path, tag: &str, path: &str) -> Result<bool> {
650 let output = git_output_in(
651 cwd,
652 &["diff", "--name-only", &format!("{}..HEAD", tag), "--", path],
653 )?;
654 Ok(!output.is_empty())
655}
656
657pub fn get_last_commit_messages_path(count: usize, path: &str) -> Result<Vec<String>> {
659 get_last_commit_messages_path_in(&cwd_or_dot(), count, path)
660}
661
662pub fn get_last_commit_messages_path_in(
664 cwd: &Path,
665 count: usize,
666 path: &str,
667) -> Result<Vec<String>> {
668 let output = git_output_in(
669 cwd,
670 &[
671 "-c",
672 "log.showSignature=false",
673 "log",
674 &format!("-{count}"),
675 "--pretty=format:%s",
676 "--",
677 path,
678 ],
679 )?;
680 Ok(output.lines().map(str::to_string).collect())
681}
682
683pub fn get_commit_messages_between_path(from: &str, to: &str, path: &str) -> Result<Vec<String>> {
685 get_commit_messages_between_path_in(&cwd_or_dot(), from, to, path)
686}
687
688pub fn get_commit_messages_between_path_in(
690 cwd: &Path,
691 from: &str,
692 to: &str,
693 path: &str,
694) -> Result<Vec<String>> {
695 let output = git_output_in(
696 cwd,
697 &[
698 "-c",
699 "log.showSignature=false",
700 "log",
701 "--pretty=format:%s",
702 &format!("{from}..{to}"),
703 "--",
704 path,
705 ],
706 )?;
707 Ok(output.lines().map(str::to_string).collect())
708}
709
710pub fn stage_and_commit(files: &[&str], message: &str) -> Result<bool> {
718 stage_and_commit_in(&cwd_or_dot(), files, message)
719}
720
721pub fn stage_and_commit_in(cwd: &Path, files: &[&str], message: &str) -> Result<bool> {
723 let mut args = vec!["add", "--"];
724 args.extend(files.iter().copied());
725 git_output_in(cwd, &args)?;
726 let diff = Command::new("git")
732 .current_dir(cwd)
733 .args(["diff", "--cached", "--quiet", "--"])
734 .args(files)
735 .env("GIT_TERMINAL_PROMPT", "0")
736 .env("LC_ALL", "C")
737 .status()?;
738 if diff.success() {
739 return Ok(false);
740 }
741 git_output_in(cwd, &["commit", "-m", message])?;
742 Ok(true)
743}
744
745pub fn log_subjects_for_range(
758 workspace_root: &std::path::Path,
759 range: &str,
760 rel_path: &str,
761) -> Result<Vec<String>> {
762 let out = Command::new("git")
763 .arg("-C")
764 .arg(workspace_root)
765 .args([
766 "-c",
767 "log.showSignature=false",
768 "log",
769 "--pretty=format:%B%x1e",
770 range,
771 "--",
772 rel_path,
773 ])
774 .env("GIT_TERMINAL_PROMPT", "0")
775 .env("LC_ALL", "C")
776 .output()?;
777 if !out.status.success() {
778 let stderr = String::from_utf8_lossy(&out.stderr);
783 let missing_range = stderr.contains("unknown revision")
784 || stderr.contains("bad revision")
785 || stderr.contains("does not have any commits yet");
786 if missing_range {
787 return Ok(Vec::new());
788 }
789 let raw = format!("git log {} failed: {}", range, stderr.trim());
790 bail!("{}", crate::redact::redact_process_env(&raw));
791 }
792 let text = String::from_utf8_lossy(&out.stdout);
793 Ok(text
794 .split('\x1e')
795 .map(|s| s.trim().to_string())
796 .filter(|s| !s.is_empty())
797 .collect())
798}
799
800pub fn add_path_in(workspace_root: &std::path::Path, rel: &std::path::Path) -> Result<()> {
802 let out = Command::new("git")
803 .arg("-C")
804 .arg(workspace_root)
805 .arg("add")
806 .arg(rel)
807 .env("GIT_TERMINAL_PROMPT", "0")
808 .env("LC_ALL", "C")
809 .output()
810 .context("failed to invoke git add")?;
811 if !out.status.success() {
812 let stderr_raw = String::from_utf8_lossy(&out.stderr);
813 let raw = format!("git add {} failed: {}", rel.display(), stderr_raw.trim());
814 bail!("{}", crate::redact::redact_process_env(&raw));
815 }
816 Ok(())
817}
818
819pub fn commit_in(workspace_root: &std::path::Path, message: &str, sign: bool) -> Result<()> {
822 let mut cmd = Command::new("git");
823 cmd.arg("-C").arg(workspace_root).arg("commit");
824 if sign {
825 cmd.arg("-S");
826 }
827 cmd.arg("-m")
828 .arg(message)
829 .env("GIT_TERMINAL_PROMPT", "0")
830 .env("LC_ALL", "C");
831 let out = cmd.output().context("failed to invoke git commit")?;
832 if !out.status.success() {
833 let stderr_raw = String::from_utf8_lossy(&out.stderr);
834 let raw = format!("git commit failed: {}", stderr_raw.trim());
835 bail!("{}", crate::redact::redact_process_env(&raw));
836 }
837 Ok(())
838}
839
840pub fn paths_changed_since_tag(tag: &str, paths: &[&str]) -> Result<bool> {
845 paths_changed_since_tag_in(&cwd_or_dot(), tag, paths)
846}
847
848pub fn paths_changed_since_tag_in(cwd: &Path, tag: &str, paths: &[&str]) -> Result<bool> {
850 let mut args: Vec<String> = vec![
851 "diff".to_string(),
852 "--name-only".to_string(),
853 format!("{tag}..HEAD"),
854 "--".to_string(),
855 ];
856 for p in paths {
857 args.push((*p).to_string());
858 }
859 let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
860 let output = Command::new("git")
861 .current_dir(cwd)
862 .args(&arg_refs)
863 .env("GIT_TERMINAL_PROMPT", "0")
864 .env("LC_ALL", "C")
865 .output()?;
866 if output.status.success() {
867 Ok(!String::from_utf8_lossy(&output.stdout).trim().is_empty())
868 } else {
869 Ok(false)
870 }
871}
872
873pub fn head_commit_hash_in(repo: &std::path::Path) -> Result<String> {
879 get_head_commit_in(repo)
880}
881
882pub fn rev_parse_in(cwd: &Path, rev: &str) -> Result<String> {
887 git_output_in(cwd, &["rev-parse", rev])
888}
889
890pub fn rev_verify_commit_in(cwd: &Path, rev: &str) -> Result<String> {
896 git_output_in(
897 cwd,
898 &["rev-parse", "--verify", &format!("{}^{{commit}}", rev)],
899 )
900}
901
902pub fn commits_between_in(cwd: &Path, sha: &str) -> Result<Vec<String>> {
907 let range = format!("{}..HEAD", sha);
908 let out = git_output_in(cwd, &["rev-list", &range])?;
909 if out.is_empty() {
910 return Ok(Vec::new());
911 }
912 Ok(out.lines().map(|s| s.trim().to_string()).collect())
913}
914
915pub fn commit_subject_in(cwd: &Path, sha: &str) -> Result<String> {
919 git_output_in(
920 cwd,
921 &[
922 "-c",
923 "log.showSignature=false",
924 "log",
925 "-1",
926 "--format=%s",
927 sha,
928 ],
929 )
930}
931
932pub fn commits_with_subjects_in(cwd: &Path, sha: &str) -> Result<Vec<(String, String)>> {
939 let range = format!("{}..HEAD", sha);
940 let out = git_output_in(
941 cwd,
942 &[
943 "-c",
944 "log.showSignature=false",
945 "log",
946 "--format=%H%x1f%s",
947 &range,
948 ],
949 )?;
950 if out.is_empty() {
951 return Ok(Vec::new());
952 }
953 Ok(out
954 .lines()
955 .filter_map(|line| {
956 let mut parts = line.splitn(2, '\x1f');
957 let sha = parts.next()?.trim().to_string();
958 let subj = parts.next().unwrap_or("").to_string();
959 if sha.is_empty() {
960 None
961 } else {
962 Some((sha, subj))
963 }
964 })
965 .collect())
966}
967
968#[derive(Debug, Clone, Default)]
981pub struct CommitterIdentity {
982 pub name: Option<String>,
983 pub email: Option<String>,
984}
985
986impl CommitterIdentity {
987 pub fn default_for_rollback() -> Self {
993 let host = std::env::var("HOSTNAME")
994 .ok()
995 .or_else(|| std::env::var("COMPUTERNAME").ok())
996 .and_then(|h| h.split('.').next().map(str::to_string))
997 .filter(|h| !h.is_empty())
998 .unwrap_or_else(|| "localhost".to_string());
999 Self {
1000 name: Some("anodize-rollback".to_string()),
1001 email: Some(format!("anodize-rollback@{host}")),
1002 }
1003 }
1004
1005 fn apply_to(&self, cmd: &mut Command) {
1006 if let Some(n) = &self.name {
1007 cmd.env("GIT_AUTHOR_NAME", n).env("GIT_COMMITTER_NAME", n);
1008 }
1009 if let Some(e) = &self.email {
1010 cmd.env("GIT_AUTHOR_EMAIL", e).env("GIT_COMMITTER_EMAIL", e);
1011 }
1012 }
1013}
1014
1015fn read_git_identity(cwd: &Path) -> (Option<String>, Option<String>) {
1021 let one = |key: &str| -> Option<String> {
1022 let out = Command::new("git")
1023 .current_dir(cwd)
1024 .args(["config", "--get", key])
1025 .env("LC_ALL", "C")
1026 .env("GIT_TERMINAL_PROMPT", "0")
1027 .output()
1028 .ok()?;
1029 if !out.status.success() {
1030 return None;
1031 }
1032 let value = String::from_utf8_lossy(&out.stdout).trim().to_string();
1033 if value.is_empty() { None } else { Some(value) }
1034 };
1035 (one("user.name"), one("user.email"))
1036}
1037
1038pub fn resolve_rollback_identity(cwd: &Path) -> CommitterIdentity {
1045 let env_author_set =
1046 std::env::var("GIT_AUTHOR_EMAIL").is_ok() && std::env::var("GIT_AUTHOR_NAME").is_ok();
1047 let env_committer_set =
1048 std::env::var("GIT_COMMITTER_EMAIL").is_ok() && std::env::var("GIT_COMMITTER_NAME").is_ok();
1049 if env_author_set && env_committer_set {
1050 return CommitterIdentity::default();
1051 }
1052 let (name, email) = read_git_identity(cwd);
1053 if name.is_some() && email.is_some() {
1054 return CommitterIdentity::default();
1055 }
1056 CommitterIdentity::default_for_rollback()
1057}
1058
1059pub fn revert_commit_in(
1085 cwd: &Path,
1086 sha: &str,
1087 message: Option<&str>,
1088 identity: &CommitterIdentity,
1089) -> Result<()> {
1090 let status = Command::new("git")
1091 .args(["status", "--porcelain", "--untracked-files=no"])
1092 .current_dir(cwd)
1093 .env("LC_ALL", "C")
1094 .env("GIT_TERMINAL_PROMPT", "0")
1095 .output()
1096 .with_context(|| format!("revert_commit_in: git status in {}", cwd.display()))?;
1097 if !status.status.success() {
1098 let stderr_raw = String::from_utf8_lossy(&status.stderr);
1099 let raw = format!("git status failed: {}", stderr_raw.trim());
1100 bail!("{}", crate::redact::redact_process_env(&raw));
1101 }
1102 if !status.stdout.is_empty() {
1103 bail!(
1104 "refusing to revert in a dirty working tree at {}\nstatus:\n{}",
1105 cwd.display(),
1106 String::from_utf8_lossy(&status.stdout),
1107 );
1108 }
1109
1110 let mut revert_cmd = Command::new("git");
1111 revert_cmd
1112 .current_dir(cwd)
1113 .args(["revert", "--no-edit", sha])
1114 .env("LC_ALL", "C")
1115 .env("GIT_TERMINAL_PROMPT", "0");
1116 identity.apply_to(&mut revert_cmd);
1117 let out = revert_cmd
1118 .output()
1119 .with_context(|| format!("revert_commit_in: git revert in {}", cwd.display()))?;
1120 if !out.status.success() {
1121 let stderr_raw = String::from_utf8_lossy(&out.stderr);
1122 let _ = Command::new("git")
1125 .current_dir(cwd)
1126 .args(["revert", "--abort"])
1127 .env("LC_ALL", "C")
1128 .env("GIT_TERMINAL_PROMPT", "0")
1129 .output();
1130 let raw = format!(
1131 "git revert {sha} hit conflicts and was aborted (working tree restored). \
1132 The bump commit overlaps with later changes — resolve manually, \
1133 or re-run with --mode=reset to force.\nstderr: {}",
1134 stderr_raw.trim()
1135 );
1136 bail!("{}", crate::redact::redact_process_env(&raw));
1137 }
1138 if let Some(msg) = message {
1139 let mut amend_cmd = Command::new("git");
1140 amend_cmd
1141 .current_dir(cwd)
1142 .args(["commit", "--amend", "-m", msg])
1143 .env("LC_ALL", "C")
1144 .env("GIT_TERMINAL_PROMPT", "0");
1145 identity.apply_to(&mut amend_cmd);
1146 let out = amend_cmd.output().with_context(|| {
1147 format!("revert_commit_in: git commit --amend in {}", cwd.display())
1148 })?;
1149 if !out.status.success() {
1150 let stderr_raw = String::from_utf8_lossy(&out.stderr);
1151 let raw = format!("git commit --amend failed: {}", stderr_raw.trim());
1152 bail!("{}", crate::redact::redact_process_env(&raw));
1153 }
1154 }
1155 Ok(())
1156}
1157
1158pub fn reset_hard_in(cwd: &Path, sha: &str) -> Result<()> {
1161 git_output_in(cwd, &["reset", "--hard", sha])?;
1162 Ok(())
1163}
1164
1165pub fn push_branch_in(cwd: &Path, branch: &str) -> Result<()> {
1170 if !super::has_remote_in(cwd, "origin") {
1171 bail!("no 'origin' remote configured, cannot push branch '{branch}'");
1172 }
1173 let refspec = format!("HEAD:refs/heads/{}", branch);
1174 let out = Command::new("git")
1175 .current_dir(cwd)
1176 .args(["push", "origin", &refspec])
1177 .env("GIT_TERMINAL_PROMPT", "0")
1178 .env("LC_ALL", "C")
1179 .output()
1180 .with_context(|| format!("push_branch_in: git push origin {refspec}"))?;
1181 if !out.status.success() {
1182 let stderr_raw = String::from_utf8_lossy(&out.stderr);
1183 let raw = format!("git push origin {} failed: {}", refspec, stderr_raw.trim());
1184 bail!("{}", crate::redact::redact_process_env(&raw));
1185 }
1186 Ok(())
1187}
1188
1189pub fn head_commit_timestamp_in(repo: &std::path::Path) -> Result<i64> {
1193 let out = Command::new("git")
1194 .arg("-C")
1195 .arg(repo)
1196 .args(["log", "-1", "--format=%ct", "HEAD"])
1197 .env("GIT_TERMINAL_PROMPT", "0")
1198 .env("LC_ALL", "C")
1199 .output()
1200 .context("failed to invoke git log -1 --format=%ct HEAD")?;
1201 if !out.status.success() {
1202 let stderr_raw = String::from_utf8_lossy(&out.stderr);
1203 let raw = format!("git log -1 --format=%ct HEAD failed: {}", stderr_raw.trim());
1204 bail!("{}", crate::redact::redact_process_env(&raw));
1205 }
1206 let text = String::from_utf8_lossy(&out.stdout).trim().to_string();
1207 text.parse::<i64>()
1208 .with_context(|| format!("git log --format=%ct returned non-i64 timestamp: {}", text))
1209}
1210
1211#[cfg(test)]
1212mod tests {
1213 use super::*;
1214 use std::process::Command;
1215
1216 fn init_repo_with_commits(dir: &Path, files: &[&str]) {
1217 let run = |args: &[&str]| {
1218 let out = anodizer_core::test_helpers::output_with_spawn_retry(
1219 || {
1220 let mut cmd = Command::new("git");
1221 cmd.args(args)
1222 .current_dir(dir)
1223 .env("GIT_AUTHOR_NAME", "t")
1224 .env("GIT_AUTHOR_EMAIL", "t@t.com")
1225 .env("GIT_COMMITTER_NAME", "t")
1226 .env("GIT_COMMITTER_EMAIL", "t@t.com");
1227 cmd
1228 },
1229 "git",
1230 );
1231 assert!(out.status.success(), "git {args:?} failed");
1232 };
1233 run(&["init"]);
1234 run(&["config", "user.email", "t@t.com"]);
1235 run(&["config", "user.name", "t"]);
1236 for (i, f) in files.iter().enumerate() {
1237 std::fs::write(dir.join(f), format!("c{i}")).unwrap();
1238 run(&["add", "."]);
1239 run(&["commit", "-m", &format!("commit-{i}: {f}")]);
1240 }
1241 }
1242
1243 #[test]
1244 fn get_head_commit_in_returns_tempdirs_head_sha() {
1245 let tmp = tempfile::tempdir().unwrap();
1246 init_repo_with_commits(tmp.path(), &["a"]);
1247 let expected = String::from_utf8(
1248 anodizer_core::test_helpers::output_with_spawn_retry(
1249 || {
1250 let mut cmd = Command::new("git");
1251 cmd.args(["rev-parse", "HEAD"]).current_dir(tmp.path());
1252 cmd
1253 },
1254 "git",
1255 )
1256 .stdout,
1257 )
1258 .unwrap()
1259 .trim()
1260 .to_string();
1261 let sha = get_head_commit_in(tmp.path()).unwrap();
1262 assert_eq!(sha, expected);
1263 }
1264
1265 #[test]
1266 fn get_short_commit_in_returns_tempdirs_short_sha() {
1267 let tmp = tempfile::tempdir().unwrap();
1268 init_repo_with_commits(tmp.path(), &["a"]);
1269 let expected = String::from_utf8(
1270 anodizer_core::test_helpers::output_with_spawn_retry(
1271 || {
1272 let mut cmd = Command::new("git");
1273 cmd.args(["rev-parse", "--short", "HEAD"])
1274 .current_dir(tmp.path());
1275 cmd
1276 },
1277 "git",
1278 )
1279 .stdout,
1280 )
1281 .unwrap()
1282 .trim()
1283 .to_string();
1284 let short = get_short_commit_in(tmp.path()).unwrap();
1285 assert_eq!(short, expected);
1286 }
1287
1288 #[test]
1289 fn has_commits_since_tag_in_returns_false_when_tag_is_head() {
1290 let tmp = tempfile::tempdir().unwrap();
1291 let dir = tmp.path();
1292 init_repo_with_commits(dir, &["a"]);
1293 let run = |args: &[&str]| {
1294 anodizer_core::test_helpers::output_with_spawn_retry(
1295 || {
1296 let mut cmd = Command::new("git");
1297 cmd.args(args)
1298 .current_dir(dir)
1299 .env("GIT_AUTHOR_NAME", "t")
1300 .env("GIT_AUTHOR_EMAIL", "t@t.com")
1301 .env("GIT_COMMITTER_NAME", "t")
1302 .env("GIT_COMMITTER_EMAIL", "t@t.com");
1303 cmd
1304 },
1305 "git",
1306 );
1307 };
1308 run(&["tag", "v1.0.0"]);
1309 assert!(!has_commits_since_tag_in(dir, "v1.0.0").unwrap());
1310 }
1311
1312 fn git_in(dir: &Path, args: &[&str]) {
1313 let out = anodizer_core::test_helpers::output_with_spawn_retry(
1314 || {
1315 let mut cmd = Command::new("git");
1316 cmd.args(args)
1317 .current_dir(dir)
1318 .env("GIT_AUTHOR_NAME", "t")
1319 .env("GIT_AUTHOR_EMAIL", "t@t.com")
1320 .env("GIT_COMMITTER_NAME", "t")
1321 .env("GIT_COMMITTER_EMAIL", "t@t.com");
1322 cmd
1323 },
1324 "git",
1325 );
1326 assert!(out.status.success(), "git {args:?} failed");
1327 }
1328
1329 #[test]
1330 fn count_commits_since_last_tag_counts_commits_after_tag() {
1331 let tmp = tempfile::tempdir().unwrap();
1332 let dir = tmp.path();
1333 init_repo_with_commits(dir, &["a", "b"]);
1335 git_in(dir, &["tag", "v1.0.0"]);
1336 for f in ["c", "d", "e"] {
1337 std::fs::write(dir.join(f), "x").unwrap();
1338 git_in(dir, &["add", "."]);
1339 git_in(dir, &["commit", "-m", f]);
1340 }
1341 assert_eq!(count_commits_since_last_tag_in(dir, None).unwrap(), 3);
1342 }
1343
1344 #[test]
1345 fn count_commits_since_last_tag_resets_on_newer_tag() {
1346 let tmp = tempfile::tempdir().unwrap();
1347 let dir = tmp.path();
1348 init_repo_with_commits(dir, &["a"]);
1349 git_in(dir, &["tag", "v1.0.0"]);
1350 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 assert_eq!(count_commits_since_last_tag_in(dir, None).unwrap(), 2);
1356 git_in(dir, &["tag", "v1.1.0"]);
1358 assert_eq!(count_commits_since_last_tag_in(dir, None).unwrap(), 0);
1359 std::fs::write(dir.join("d"), "x").unwrap();
1360 git_in(dir, &["add", "."]);
1361 git_in(dir, &["commit", "-m", "d"]);
1362 assert_eq!(count_commits_since_last_tag_in(dir, None).unwrap(), 1);
1363 }
1364
1365 #[test]
1366 fn count_commits_since_last_tag_counts_all_when_no_tag() {
1367 let tmp = tempfile::tempdir().unwrap();
1368 let dir = tmp.path();
1369 init_repo_with_commits(dir, &["a", "b", "c"]);
1370 assert_eq!(count_commits_since_last_tag_in(dir, None).unwrap(), 3);
1372 }
1373
1374 #[test]
1375 fn count_commits_since_last_tag_respects_monorepo_prefix() {
1376 let tmp = tempfile::tempdir().unwrap();
1380 let dir = tmp.path();
1381 init_repo_with_commits(dir, &["a"]);
1382 git_in(dir, &["tag", "core/v1.0.0"]); for f in ["b", "c"] {
1384 std::fs::write(dir.join(f), "x").unwrap();
1385 git_in(dir, &["add", "."]);
1386 git_in(dir, &["commit", "-m", f]);
1387 }
1388 git_in(dir, &["tag", "api/v2.0.0"]); std::fs::write(dir.join("d"), "x").unwrap();
1390 git_in(dir, &["add", "."]);
1391 git_in(dir, &["commit", "-m", "d"]);
1392
1393 assert_eq!(
1395 count_commits_since_last_tag_in(dir, Some("core/")).unwrap(),
1396 3,
1397 "must count since the matching-prefix tag, ignoring api/v2.0.0",
1398 );
1399 assert_eq!(
1403 count_commits_since_last_tag_in(dir, None).unwrap(),
1404 1,
1405 "unfiltered count picks the nearest (wrong) subproject tag",
1406 );
1407 }
1408
1409 #[test]
1410 fn get_current_branch_in_returns_branch_name() {
1411 let tmp = tempfile::tempdir().unwrap();
1412 let dir = tmp.path();
1413 let run = |args: &[&str]| {
1414 let out = anodizer_core::test_helpers::output_with_spawn_retry(
1415 || {
1416 let mut cmd = Command::new("git");
1417 cmd.args(args)
1418 .current_dir(dir)
1419 .env("GIT_AUTHOR_NAME", "t")
1420 .env("GIT_AUTHOR_EMAIL", "t@t.com")
1421 .env("GIT_COMMITTER_NAME", "t")
1422 .env("GIT_COMMITTER_EMAIL", "t@t.com");
1423 cmd
1424 },
1425 "git",
1426 );
1427 assert!(out.status.success(), "git {args:?} failed");
1428 };
1429 run(&["-c", "init.defaultBranch=t1-test-branch", "init"]);
1430 run(&["config", "user.email", "t@t.com"]);
1431 run(&["config", "user.name", "t"]);
1432 std::fs::write(dir.join("a"), "1").unwrap();
1433 run(&["add", "."]);
1434 run(&["commit", "-m", "c1"]);
1435 let branch = get_current_branch_in(dir).unwrap();
1436 assert_eq!(branch, "t1-test-branch");
1437 }
1438
1439 #[test]
1440 fn get_current_branch_in_resolves_detached_head_via_points_at() {
1441 let tmp = tempfile::tempdir().unwrap();
1442 let dir = tmp.path();
1443 let run = |args: &[&str]| {
1444 let out = anodizer_core::test_helpers::output_with_spawn_retry(
1445 || {
1446 let mut cmd = Command::new("git");
1447 cmd.args(args)
1448 .current_dir(dir)
1449 .env("GIT_AUTHOR_NAME", "t")
1450 .env("GIT_AUTHOR_EMAIL", "t@t.com")
1451 .env("GIT_COMMITTER_NAME", "t")
1452 .env("GIT_COMMITTER_EMAIL", "t@t.com");
1453 cmd
1454 },
1455 "git",
1456 );
1457 assert!(out.status.success(), "git {args:?} failed");
1458 };
1459 run(&["-c", "init.defaultBranch=master", "init"]);
1460 run(&["config", "user.email", "t@t.com"]);
1461 run(&["config", "user.name", "t"]);
1462 std::fs::write(dir.join("a"), "1").unwrap();
1463 run(&["add", "."]);
1464 run(&["commit", "-m", "c1"]);
1465 let sha = get_head_commit_in(dir).unwrap();
1466 run(&["checkout", "--detach", &sha]);
1467 let branch = get_current_branch_in(dir).unwrap();
1468 assert_eq!(
1469 branch, "master",
1470 "detached HEAD pointing at master must resolve to master, not literal HEAD"
1471 );
1472 }
1473
1474 #[test]
1475 fn is_branchlike_rejects_lockstep_tag_shapes() {
1476 assert!(!is_branchlike("v0.4.5"));
1477 assert!(!is_branchlike("v1.2.3"));
1478 assert!(!is_branchlike("v10.20.30"));
1479 assert!(!is_branchlike("v1.2.3-rc.1"));
1480 assert!(!is_branchlike("v1.2.3+build.42"));
1481 }
1482
1483 #[test]
1484 fn is_branchlike_rejects_per_crate_tag_shapes() {
1485 assert!(!is_branchlike("mycrate-v1.2.3"));
1486 assert!(!is_branchlike("cfgd-operator-v0.4.0"));
1487 assert!(!is_branchlike("anodize-core-v1.2.3-rc.1"));
1488 }
1489
1490 #[test]
1491 fn is_branchlike_accepts_real_branch_names() {
1492 assert!(is_branchlike("master"));
1493 assert!(is_branchlike("main"));
1494 assert!(is_branchlike("publisher-required-config"));
1495 assert!(is_branchlike("release/v1.2.3-prep"));
1496 assert!(is_branchlike("dependabot/cargo/serde-1.0.200"));
1497 }
1498
1499 #[test]
1500 fn is_branchlike_accepts_slashed_branch_with_embedded_version() {
1501 assert!(is_branchlike("feature/fix-v2.0.0"));
1506 assert!(is_branchlike("hotfix/release-v1.0.0-blocker"));
1507 assert!(is_branchlike("user/wip-v3.1.4"));
1508 }
1509
1510 #[test]
1511 fn get_current_branch_in_rejects_tag_shaped_github_ref_name() {
1512 let tmp = tempfile::tempdir().unwrap();
1513 let dir = tmp.path();
1514 let run = |args: &[&str]| {
1515 let out = anodizer_core::test_helpers::output_with_spawn_retry(
1516 || {
1517 let mut cmd = Command::new("git");
1518 cmd.args(args)
1519 .current_dir(dir)
1520 .env("GIT_AUTHOR_NAME", "t")
1521 .env("GIT_AUTHOR_EMAIL", "t@t.com")
1522 .env("GIT_COMMITTER_NAME", "t")
1523 .env("GIT_COMMITTER_EMAIL", "t@t.com");
1524 cmd
1525 },
1526 "git",
1527 );
1528 assert!(out.status.success(), "git {args:?} failed");
1529 };
1530 run(&["-c", "init.defaultBranch=master", "init"]);
1534 run(&["config", "user.email", "t@t.com"]);
1535 run(&["config", "user.name", "t"]);
1536 std::fs::write(dir.join("a"), "1").unwrap();
1537 run(&["add", "."]);
1538 run(&["commit", "-m", "c1"]);
1539 let sha = get_head_commit_in(dir).unwrap();
1540 std::fs::write(dir.join("a"), "2").unwrap();
1543 run(&["add", "."]);
1544 run(&["commit", "-m", "c2"]);
1545 run(&["checkout", "--detach", &sha]);
1546
1547 let env = crate::MapEnvSource::new().with("GITHUB_REF_NAME", "v0.4.5");
1552 let err = get_current_branch_in_with_env(dir, &env).unwrap_err();
1553 assert!(
1554 err.to_string().contains("could not resolve current branch"),
1555 "tag-shaped GITHUB_REF_NAME must trigger bail: {err}"
1556 );
1557
1558 let env = crate::MapEnvSource::new().with("GITHUB_REF_NAME", "mycrate-v1.2.3");
1560 let err = get_current_branch_in_with_env(dir, &env).unwrap_err();
1561 assert!(
1562 err.to_string().contains("could not resolve current branch"),
1563 "per-crate tag GITHUB_REF_NAME must trigger bail: {err}"
1564 );
1565
1566 let env = crate::MapEnvSource::new().with("GITHUB_REF_NAME", "master");
1568 let branch = get_current_branch_in_with_env(dir, &env).unwrap();
1569 assert_eq!(branch, "master");
1570 }
1571
1572 #[test]
1573 fn branches_containing_sha_in_returns_empty_without_remote() {
1574 let tmp = tempfile::tempdir().unwrap();
1575 let dir = tmp.path();
1576 let run = |args: &[&str]| {
1577 let out = anodizer_core::test_helpers::output_with_spawn_retry(
1578 || {
1579 let mut cmd = Command::new("git");
1580 cmd.args(args)
1581 .current_dir(dir)
1582 .env("GIT_AUTHOR_NAME", "t")
1583 .env("GIT_AUTHOR_EMAIL", "t@t.com")
1584 .env("GIT_COMMITTER_NAME", "t")
1585 .env("GIT_COMMITTER_EMAIL", "t@t.com");
1586 cmd
1587 },
1588 "git",
1589 );
1590 assert!(out.status.success(), "git {args:?} failed");
1591 };
1592 run(&["-c", "init.defaultBranch=master", "init"]);
1593 run(&["config", "user.email", "t@t.com"]);
1594 run(&["config", "user.name", "t"]);
1595 std::fs::write(dir.join("a"), "1").unwrap();
1596 run(&["add", "."]);
1597 run(&["commit", "-m", "c1"]);
1598 let sha = get_head_commit_in(dir).unwrap();
1599 let branches = branches_containing_sha_in(dir, &sha).unwrap();
1603 assert!(branches.is_empty(), "no remote → no remote branches");
1604 }
1605
1606 #[test]
1607 fn branches_containing_sha_in_finds_remote_branch_after_push() {
1608 let tmp = tempfile::tempdir().unwrap();
1609 let bare = tempfile::tempdir().unwrap();
1610 let dir = tmp.path();
1611 let run_in = |cwd: &Path, args: &[&str]| {
1612 let out = anodizer_core::test_helpers::output_with_spawn_retry(
1613 || {
1614 let mut cmd = Command::new("git");
1615 cmd.args(args)
1616 .current_dir(cwd)
1617 .env("GIT_AUTHOR_NAME", "t")
1618 .env("GIT_AUTHOR_EMAIL", "t@t.com")
1619 .env("GIT_COMMITTER_NAME", "t")
1620 .env("GIT_COMMITTER_EMAIL", "t@t.com");
1621 cmd
1622 },
1623 "git",
1624 );
1625 assert!(out.status.success(), "git {args:?} failed");
1626 };
1627 run_in(
1628 bare.path(),
1629 &["-c", "init.defaultBranch=master", "init", "--bare"],
1630 );
1631 run_in(dir, &["-c", "init.defaultBranch=master", "init"]);
1632 run_in(dir, &["config", "user.email", "t@t.com"]);
1633 run_in(dir, &["config", "user.name", "t"]);
1634 run_in(
1635 dir,
1636 &["remote", "add", "origin", bare.path().to_str().unwrap()],
1637 );
1638 std::fs::write(dir.join("a"), "1").unwrap();
1639 run_in(dir, &["add", "."]);
1640 run_in(dir, &["commit", "-m", "c1"]);
1641 let sha = get_head_commit_in(dir).unwrap();
1642 run_in(dir, &["push", "-u", "origin", "master"]);
1643
1644 let branches = branches_containing_sha_in(dir, &sha).unwrap();
1645 assert_eq!(branches, vec!["master".to_string()]);
1646 }
1647
1648 #[test]
1649 fn stage_and_commit_in_returns_false_when_no_diff() {
1650 let tmp = tempfile::tempdir().unwrap();
1651 let dir = tmp.path();
1652 init_repo_with_commits(dir, &["a"]);
1653 let created = stage_and_commit_in(dir, &["a"], "chore: should be a no-op").unwrap();
1657 assert!(!created, "no diff → no commit should be created");
1658 let log = anodizer_core::test_helpers::output_with_spawn_retry(
1659 || {
1660 let mut cmd = Command::new("git");
1661 cmd.args(["log", "--oneline"]).current_dir(dir);
1662 cmd
1663 },
1664 "git",
1665 );
1666 let log_text = String::from_utf8_lossy(&log.stdout);
1667 assert!(
1668 !log_text.contains("should be a no-op"),
1669 "stage_and_commit_in must not create a commit when no diff: {log_text}"
1670 );
1671 }
1672
1673 #[test]
1674 fn stage_and_commit_in_returns_true_when_file_changed() {
1675 let tmp = tempfile::tempdir().unwrap();
1676 let dir = tmp.path();
1677 init_repo_with_commits(dir, &["a"]);
1678 std::fs::write(dir.join("a"), "changed").unwrap();
1679 let created = stage_and_commit_in(dir, &["a"], "chore: real change").unwrap();
1680 assert!(created, "real change → commit must be created");
1681 let log = anodizer_core::test_helpers::output_with_spawn_retry(
1682 || {
1683 let mut cmd = Command::new("git");
1684 cmd.args(["log", "-1", "--pretty=%s"]).current_dir(dir);
1685 cmd
1686 },
1687 "git",
1688 );
1689 let subject = String::from_utf8_lossy(&log.stdout).trim().to_string();
1690 assert_eq!(subject, "chore: real change");
1691 }
1692
1693 #[test]
1694 fn git_output_in_error_falls_back_to_stdout_when_stderr_empty() {
1695 let tmp = tempfile::tempdir().unwrap();
1696 let dir = tmp.path();
1697 init_repo_with_commits(dir, &["a"]);
1698 let err = git_output_in(dir, &["commit", "-m", "no-op"]).unwrap_err();
1702 let msg = err.to_string();
1703 assert!(
1704 msg.contains("nothing to commit") || msg.contains("clean"),
1705 "error must include stdout detail when stderr is empty: {msg}"
1706 );
1707 }
1708
1709 #[test]
1715 fn default_for_rollback_populates_both_name_and_email() {
1716 let id = CommitterIdentity::default_for_rollback();
1717 assert_eq!(id.name.as_deref(), Some("anodize-rollback"));
1718 let email = id.email.expect("email must be Some");
1719 assert!(
1720 email.starts_with("anodize-rollback@"),
1721 "email must use the anodize-rollback@<host> shape; got {email}"
1722 );
1723 assert!(!email.ends_with('@'), "host portion must not be empty");
1724 }
1725
1726 #[test]
1733 fn revert_commit_in_uses_injected_identity_envs() {
1734 let tmp = tempfile::tempdir().unwrap();
1735 let dir = tmp.path();
1736 let run_env = |args: &[&str], extra: &[(&str, &str)]| {
1737 let out = anodizer_core::test_helpers::output_with_spawn_retry(
1738 || {
1739 let mut cmd = Command::new("git");
1740 cmd.args(args)
1741 .current_dir(dir)
1742 .env("GIT_AUTHOR_NAME", "bootstrap")
1743 .env("GIT_AUTHOR_EMAIL", "bootstrap@b.com")
1744 .env("GIT_COMMITTER_NAME", "bootstrap")
1745 .env("GIT_COMMITTER_EMAIL", "bootstrap@b.com");
1746 for (k, v) in extra {
1747 cmd.env(k, v);
1748 }
1749 cmd
1750 },
1751 "git",
1752 );
1753 assert!(
1754 out.status.success(),
1755 "git {args:?} failed: {}",
1756 String::from_utf8_lossy(&out.stderr)
1757 );
1758 };
1759 run_env(&["init", "-b", "master"], &[]);
1760 std::fs::write(dir.join("a"), "0").unwrap();
1761 run_env(&["add", "."], &[]);
1762 run_env(&["commit", "-m", "initial"], &[]);
1763 std::fs::write(dir.join("a"), "1").unwrap();
1764 run_env(&["add", "."], &[]);
1765 run_env(&["commit", "-m", "chore(release): v1.0.0"], &[]);
1766 let bump_sha = get_head_commit_in(dir).unwrap();
1767
1768 let identity = CommitterIdentity {
1772 name: Some("rollback-bot".to_string()),
1773 email: Some("rollback-bot@anodize.test".to_string()),
1774 };
1775 revert_commit_in(dir, &bump_sha, Some("chore(release): rollback"), &identity)
1776 .expect("revert with injected identity must succeed");
1777
1778 let out = anodizer_core::test_helpers::output_with_spawn_retry(
1781 || {
1782 let mut cmd = Command::new("git");
1783 cmd.current_dir(dir)
1784 .args(["log", "-1", "--format=%ae"])
1785 .env("GIT_TERMINAL_PROMPT", "0")
1786 .env("LC_ALL", "C");
1787 cmd
1788 },
1789 "git",
1790 );
1791 let author_email = String::from_utf8_lossy(&out.stdout).trim().to_string();
1792 assert_eq!(
1793 author_email, "rollback-bot@anodize.test",
1794 "revert commit must carry the injected committer identity"
1795 );
1796
1797 let cfg = anodizer_core::test_helpers::output_with_spawn_retry(
1800 || {
1801 let mut cmd = Command::new("git");
1802 cmd.current_dir(dir)
1803 .args(["config", "--local", "--get", "user.email"])
1804 .env("GIT_TERMINAL_PROMPT", "0")
1805 .env("LC_ALL", "C");
1806 cmd
1807 },
1808 "git",
1809 );
1810 assert!(
1811 !cfg.status.success() || cfg.stdout.is_empty(),
1812 "revert must not write user.email into the repo's local config; got: {}",
1813 String::from_utf8_lossy(&cfg.stdout)
1814 );
1815 }
1816
1817 #[test]
1822 fn revert_commit_in_aborts_on_conflict_and_leaves_tree_clean() {
1823 let tmp = tempfile::tempdir().unwrap();
1824 let dir = tmp.path();
1825 let run = |args: &[&str]| {
1826 let out = anodizer_core::test_helpers::output_with_spawn_retry(
1827 || {
1828 let mut cmd = Command::new("git");
1829 cmd.args(args)
1830 .current_dir(dir)
1831 .env("GIT_AUTHOR_NAME", "t")
1832 .env("GIT_AUTHOR_EMAIL", "t@t.com")
1833 .env("GIT_COMMITTER_NAME", "t")
1834 .env("GIT_COMMITTER_EMAIL", "t@t.com");
1835 cmd
1836 },
1837 "git",
1838 );
1839 assert!(
1840 out.status.success(),
1841 "git {args:?} failed: {}",
1842 String::from_utf8_lossy(&out.stderr)
1843 );
1844 };
1845 run(&["init", "-b", "master"]);
1846 run(&["config", "user.email", "t@t.com"]);
1847 run(&["config", "user.name", "t"]);
1848 std::fs::write(dir.join("x"), "v1\n").unwrap();
1850 run(&["add", "."]);
1851 run(&["commit", "-m", "initial"]);
1852 std::fs::write(dir.join("x"), "v2\n").unwrap();
1854 run(&["add", "."]);
1855 run(&["commit", "-m", "chore(release): v2"]);
1856 let bump_sha = get_head_commit_in(dir).unwrap();
1857 std::fs::write(dir.join("x"), "v3\n").unwrap();
1861 run(&["add", "."]);
1862 run(&["commit", "-m", "feat: overlap"]);
1863
1864 let identity = CommitterIdentity::default();
1865 let err = revert_commit_in(dir, &bump_sha, None, &identity)
1866 .expect_err("revert against overlapping HEAD must conflict and bail");
1867 let msg = format!("{err}");
1868 assert!(
1869 msg.contains("aborted"),
1870 "bail message must mention abort recovery: {msg}"
1871 );
1872
1873 assert!(
1877 !dir.join(".git/REVERT_HEAD").exists(),
1878 ".git/REVERT_HEAD must be cleaned up after --abort"
1879 );
1880 let status_out = anodizer_core::test_helpers::output_with_spawn_retry(
1881 || {
1882 let mut cmd = Command::new("git");
1883 cmd.args(["status", "--porcelain"]).current_dir(dir);
1884 cmd
1885 },
1886 "git",
1887 );
1888 assert!(
1889 status_out.stdout.is_empty(),
1890 "working tree must be clean after revert --abort; got:\n{}",
1891 String::from_utf8_lossy(&status_out.stdout)
1892 );
1893 }
1894
1895 #[test]
1900 fn commits_with_subjects_in_returns_all_pairs_in_one_call() {
1901 let tmp = tempfile::tempdir().unwrap();
1902 let dir = tmp.path();
1903 let run = |args: &[&str]| {
1904 let out = anodizer_core::test_helpers::output_with_spawn_retry(
1905 || {
1906 let mut cmd = Command::new("git");
1907 cmd.args(args)
1908 .current_dir(dir)
1909 .env("GIT_AUTHOR_NAME", "t")
1910 .env("GIT_AUTHOR_EMAIL", "t@t.com")
1911 .env("GIT_COMMITTER_NAME", "t")
1912 .env("GIT_COMMITTER_EMAIL", "t@t.com");
1913 cmd
1914 },
1915 "git",
1916 );
1917 assert!(out.status.success(), "git {args:?} failed");
1918 };
1919 run(&["init", "-b", "master"]);
1920 run(&["config", "user.email", "t@t.com"]);
1921 run(&["config", "user.name", "t"]);
1922 std::fs::write(dir.join("a"), "0").unwrap();
1923 run(&["add", "."]);
1924 run(&["commit", "-m", "initial"]);
1925 let base = get_head_commit_in(dir).unwrap();
1926 std::fs::write(dir.join("a"), "1").unwrap();
1927 run(&["add", "."]);
1928 run(&["commit", "-m", "feat: A with extra detail"]);
1929 std::fs::write(dir.join("a"), "2").unwrap();
1930 run(&["add", "."]);
1931 run(&["commit", "-m", "fix: B"]);
1932
1933 let pairs = commits_with_subjects_in(dir, &base).unwrap();
1934 assert_eq!(pairs.len(), 2, "two commits sit on top of base");
1935 assert_eq!(pairs[0].1, "fix: B");
1937 assert_eq!(pairs[1].1, "feat: A with extra detail");
1938
1939 let head = get_head_commit_in(dir).unwrap();
1941 assert!(commits_with_subjects_in(dir, &head).unwrap().is_empty());
1942 }
1943
1944 #[test]
1945 fn parse_commit_output_with_files_pairs_each_commit_with_its_files() {
1946 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";
1949 let parsed = parse_commit_output_with_files(raw);
1950 assert_eq!(parsed.len(), 2);
1951 assert_eq!(parsed[0].commit.message, "fix: B");
1952 assert_eq!(parsed[0].files, vec!["crates/cli/main.rs".to_string()]);
1953 assert_eq!(parsed[1].commit.message, "feat: A");
1954 assert_eq!(
1955 parsed[1].files,
1956 vec!["crates/core/lib.rs".to_string(), "Cargo.toml".to_string()]
1957 );
1958 }
1959
1960 #[test]
1961 fn parse_commit_output_with_files_preserves_multiline_body_at_idx_gt_0() {
1962 let body0 = "detail line one\ndetail line two\n\nCo-Authored-By: Bob <bob@b.com>";
1967 let raw = format!(
1968 "h1\x1fs1\x1ffix: B\x1ft\x1ft@t\x1f\x1e\ncrates/cli/main.rs\n\n\
1969 h0\x1fs0\x1ffeat: A\x1ft\x1ft@t\x1f{body0}\x1e\ncrates/core/lib.rs\n"
1970 );
1971 let parsed = parse_commit_output_with_files(&raw);
1972 assert_eq!(parsed.len(), 2);
1973 assert_eq!(parsed[1].commit.message, "feat: A");
1975 assert_eq!(parsed[1].commit.body, body0);
1976 assert!(
1977 parsed[1]
1978 .commit
1979 .body
1980 .contains("Co-Authored-By: Bob <bob@b.com>"),
1981 "multi-line body trailer dropped: {:?}",
1982 parsed[1].commit.body
1983 );
1984 assert_eq!(parsed[1].files, vec!["crates/core/lib.rs".to_string()]);
1985 }
1986
1987 #[test]
1988 fn get_commits_between_paths_with_files_in_reports_touched_files() {
1989 let tmp = tempfile::tempdir().unwrap();
1990 let dir = tmp.path();
1991 let run = |args: &[&str]| {
1992 assert!(
1993 anodizer_core::test_helpers::output_with_spawn_retry(
1994 || {
1995 let mut cmd = Command::new("git");
1996 cmd.args(args)
1997 .current_dir(dir)
1998 .env("GIT_AUTHOR_NAME", "t")
1999 .env("GIT_AUTHOR_EMAIL", "t@t.com")
2000 .env("GIT_COMMITTER_NAME", "t")
2001 .env("GIT_COMMITTER_EMAIL", "t@t.com");
2002 cmd
2003 },
2004 "git",
2005 )
2006 .status
2007 .success()
2008 );
2009 };
2010 run(&["init"]);
2011 run(&["config", "user.email", "t@t.com"]);
2012 run(&["config", "user.name", "t"]);
2013 std::fs::write(dir.join("base"), "0").unwrap();
2014 run(&["add", "."]);
2015 run(&["commit", "-m", "initial"]);
2016 let base = get_head_commit_in(dir).unwrap();
2017 std::fs::create_dir_all(dir.join("crates/core")).unwrap();
2018 std::fs::write(dir.join("crates/core/lib.rs"), "1").unwrap();
2019 run(&["add", "."]);
2020 run(&["commit", "-m", "feat: core"]);
2021
2022 let pairs = get_commits_between_paths_with_files_in(dir, &base, "HEAD", &[]).unwrap();
2023 assert_eq!(pairs.len(), 1);
2024 assert_eq!(pairs[0].commit.message, "feat: core");
2025 assert_eq!(pairs[0].files, vec!["crates/core/lib.rs".to_string()]);
2026 }
2027
2028 #[test]
2029 fn get_commits_between_paths_with_files_in_preserves_multiline_body_for_later_commits() {
2030 let tmp = tempfile::tempdir().unwrap();
2036 let dir = tmp.path();
2037 let run = |args: &[&str]| {
2038 assert!(
2039 anodizer_core::test_helpers::output_with_spawn_retry(
2040 || {
2041 let mut cmd = Command::new("git");
2042 cmd.args(args)
2043 .current_dir(dir)
2044 .env("GIT_AUTHOR_NAME", "t")
2045 .env("GIT_AUTHOR_EMAIL", "t@t.com")
2046 .env("GIT_COMMITTER_NAME", "t")
2047 .env("GIT_COMMITTER_EMAIL", "t@t.com");
2048 cmd
2049 },
2050 "git",
2051 )
2052 .status
2053 .success()
2054 );
2055 };
2056 run(&["init"]);
2057 run(&["config", "user.email", "t@t.com"]);
2058 run(&["config", "user.name", "t"]);
2059 std::fs::write(dir.join("base"), "0").unwrap();
2060 run(&["add", "."]);
2061 run(&["commit", "-m", "initial"]);
2062 let base = get_head_commit_in(dir).unwrap();
2063
2064 std::fs::write(dir.join("a.rs"), "1").unwrap();
2066 run(&["add", "."]);
2067 run(&[
2068 "commit",
2069 "-m",
2070 "feat: with body\n\nfirst body line\nsecond body line\n\nCo-Authored-By: Bob <bob@b.com>",
2071 ]);
2072 std::fs::write(dir.join("b.rs"), "2").unwrap();
2074 run(&["add", "."]);
2075 run(&["commit", "-m", "fix: later"]);
2076
2077 let pairs = get_commits_between_paths_with_files_in(dir, &base, "HEAD", &[]).unwrap();
2078 assert_eq!(pairs.len(), 2);
2079 assert_eq!(pairs[0].commit.message, "fix: later");
2081 let body = &pairs[1].commit.body;
2082 assert!(
2083 body.contains("first body line") && body.contains("second body line"),
2084 "multi-line body truncated for idx>0 commit: {body:?}"
2085 );
2086 assert!(
2087 body.contains("Co-Authored-By: Bob <bob@b.com>"),
2088 "Co-Authored-By trailer dropped for idx>0 commit: {body:?}"
2089 );
2090 }
2091
2092 #[test]
2095 fn parse_commit_output_empty_input_yields_no_commits() {
2096 assert!(parse_commit_output("").is_empty());
2097 }
2098
2099 #[test]
2100 fn parse_commit_output_decodes_all_six_fields() {
2101 let raw =
2103 "abc123def\x1fabc123d\x1ffeat: add thing\x1fAlice\x1falice@x.com\x1fbody text\x1e";
2104 let commits = parse_commit_output(raw);
2105 assert_eq!(commits.len(), 1);
2106 let c = &commits[0];
2107 assert_eq!(c.hash, "abc123def");
2108 assert_eq!(c.short_hash, "abc123d");
2109 assert_eq!(c.message, "feat: add thing");
2110 assert_eq!(c.author_name, "Alice");
2111 assert_eq!(c.author_email, "alice@x.com");
2112 assert_eq!(c.body, "body text");
2113 }
2114
2115 #[test]
2116 fn parse_commit_output_trims_hash_and_body_but_keeps_inner_subject() {
2117 let raw = " abc \x1fabc\x1ffix: keep spaces\x1ft\x1ft@t\x1f\n\nbody\n\x1e";
2120 let commits = parse_commit_output(raw);
2121 assert_eq!(commits.len(), 1);
2122 assert_eq!(commits[0].hash, "abc", "hash is trimmed");
2123 assert_eq!(commits[0].message, "fix: keep spaces", "subject verbatim");
2124 assert_eq!(commits[0].body, "body", "body is trimmed");
2125 }
2126
2127 #[test]
2128 fn parse_commit_output_absent_body_field_defaults_to_empty() {
2129 let raw = "h\x1fh\x1fsubject\x1fname\x1fmail\x1e";
2131 let commits = parse_commit_output(raw);
2132 assert_eq!(commits.len(), 1);
2133 assert_eq!(commits[0].body, "");
2134 assert_eq!(commits[0].message, "subject");
2135 }
2136
2137 #[test]
2138 fn parse_commit_output_skips_records_with_too_few_fields() {
2139 let raw = "only\x1ftwo\x1e\
2142 h\x1fh\x1fgood: subject\x1fn\x1fe\x1fbody\x1e";
2143 let commits = parse_commit_output(raw);
2144 assert_eq!(commits.len(), 1, "malformed record dropped, good one kept");
2145 assert_eq!(commits[0].message, "good: subject");
2146 }
2147
2148 #[test]
2149 fn parse_commit_output_multiline_body_survives_record_separator_split() {
2150 let raw = "h1\x1fh1\x1ffeat: A\x1fA\x1fa@x\x1fline one\nline two\n\nCo-Authored-By: B <b@x>\x1e\
2153 h0\x1fh0\x1ffix: B\x1fB\x1fb@x\x1f\x1e";
2154 let commits = parse_commit_output(raw);
2155 assert_eq!(commits.len(), 2);
2156 assert_eq!(commits[0].message, "feat: A");
2157 assert!(commits[0].body.contains("line one\nline two"));
2158 assert!(commits[0].body.contains("Co-Authored-By: B <b@x>"));
2159 assert_eq!(commits[1].message, "fix: B");
2160 assert_eq!(commits[1].body, "");
2161 }
2162
2163 #[test]
2166 fn short_commit_str_truncates_long_sha_to_seven() {
2167 assert_eq!(short_commit_str("abcdef0123456789"), "abcdef0");
2168 assert_eq!(short_commit_str("abcdef0123456789").len(), SHORT_COMMIT_LEN);
2169 }
2170
2171 #[test]
2172 fn short_commit_str_returns_shorter_or_equal_input_unchanged() {
2173 assert_eq!(short_commit_str("abc"), "abc", "shorter than 7 unchanged");
2174 assert_eq!(
2175 short_commit_str("abcdefg"),
2176 "abcdefg",
2177 "exactly 7 unchanged"
2178 );
2179 assert_eq!(short_commit_str(""), "", "empty stays empty");
2180 }
2181
2182 fn g(dir: &Path, args: &[&str]) {
2186 let out = anodizer_core::test_helpers::output_with_spawn_retry(
2187 || {
2188 let mut cmd = Command::new("git");
2189 cmd.args(args)
2190 .current_dir(dir)
2191 .env("GIT_AUTHOR_NAME", "Ada")
2192 .env("GIT_AUTHOR_EMAIL", "ada@x.com")
2193 .env("GIT_COMMITTER_NAME", "Ada")
2194 .env("GIT_COMMITTER_EMAIL", "ada@x.com")
2195 .env("GIT_AUTHOR_DATE", "1715000000 +0000")
2196 .env("GIT_COMMITTER_DATE", "1715000000 +0000");
2197 cmd
2198 },
2199 "git",
2200 );
2201 assert!(
2202 out.status.success(),
2203 "git {args:?} failed: {}",
2204 String::from_utf8_lossy(&out.stderr)
2205 );
2206 }
2207
2208 fn init_bare_repo(dir: &Path) {
2210 g(dir, &["init", "-b", "master"]);
2211 g(dir, &["config", "user.email", "ada@x.com"]);
2212 g(dir, &["config", "user.name", "Ada"]);
2213 }
2214
2215 fn commit_file(dir: &Path, path: &str, content: &str, subject: &str) {
2217 let full = dir.join(path);
2218 if let Some(parent) = full.parent() {
2219 std::fs::create_dir_all(parent).unwrap();
2220 }
2221 std::fs::write(full, content).unwrap();
2222 g(dir, &["add", "."]);
2223 g(dir, &["commit", "-m", subject]);
2224 }
2225
2226 #[test]
2229 fn get_commits_between_in_returns_only_post_base_commits() {
2230 let tmp = tempfile::tempdir().unwrap();
2231 let dir = tmp.path();
2232 init_bare_repo(dir);
2233 commit_file(dir, "a", "0", "initial");
2234 let base = get_head_commit_in(dir).unwrap();
2235 commit_file(dir, "a", "1", "feat: one");
2236 commit_file(dir, "a", "2", "fix: two");
2237
2238 let commits = get_commits_between_in(dir, &base, "HEAD", None).unwrap();
2239 assert_eq!(commits.len(), 2, "two commits sit above base");
2240 assert_eq!(commits[0].message, "fix: two");
2242 assert_eq!(commits[1].message, "feat: one");
2243 assert_eq!(commits[1].author_name, "Ada");
2244 assert_eq!(commits[1].author_email, "ada@x.com");
2245 }
2246
2247 #[test]
2248 fn get_commits_between_in_path_filter_excludes_untouched_files() {
2249 let tmp = tempfile::tempdir().unwrap();
2250 let dir = tmp.path();
2251 init_bare_repo(dir);
2252 commit_file(dir, "base", "0", "initial");
2253 let base = get_head_commit_in(dir).unwrap();
2254 commit_file(dir, "src/lib.rs", "1", "feat: touch lib");
2255 commit_file(dir, "docs/readme", "2", "docs: touch docs only");
2256
2257 let commits = get_commits_between_in(dir, &base, "HEAD", Some("src")).unwrap();
2259 assert_eq!(commits.len(), 1, "only the src-touching commit survives");
2260 assert_eq!(commits[0].message, "feat: touch lib");
2261 }
2262
2263 #[test]
2264 fn get_commits_between_paths_in_unions_multiple_paths() {
2265 let tmp = tempfile::tempdir().unwrap();
2266 let dir = tmp.path();
2267 init_bare_repo(dir);
2268 commit_file(dir, "base", "0", "initial");
2269 let base = get_head_commit_in(dir).unwrap();
2270 commit_file(dir, "a/x", "1", "feat: a");
2271 commit_file(dir, "b/y", "2", "feat: b");
2272 commit_file(dir, "c/z", "3", "feat: c");
2273
2274 let commits =
2276 get_commits_between_paths_in(dir, &base, "HEAD", &["a".into(), "b".into()]).unwrap();
2277 let subjects: Vec<&str> = commits.iter().map(|c| c.message.as_str()).collect();
2278 assert_eq!(
2279 commits.len(),
2280 2,
2281 "a and b touched, c excluded: {subjects:?}"
2282 );
2283 assert!(subjects.contains(&"feat: a"));
2284 assert!(subjects.contains(&"feat: b"));
2285 assert!(!subjects.contains(&"feat: c"));
2286 }
2287
2288 #[test]
2291 fn get_all_commits_in_returns_every_commit_on_head() {
2292 let tmp = tempfile::tempdir().unwrap();
2293 let dir = tmp.path();
2294 init_bare_repo(dir);
2295 commit_file(dir, "a", "0", "first");
2296 commit_file(dir, "a", "1", "second");
2297 commit_file(dir, "a", "2", "third");
2298
2299 let commits = get_all_commits_in(dir, None).unwrap();
2300 assert_eq!(commits.len(), 3);
2301 assert_eq!(commits[0].message, "third", "newest-first");
2302 assert_eq!(commits[2].message, "first");
2303 }
2304
2305 #[test]
2306 fn get_all_commits_paths_in_filters_to_path() {
2307 let tmp = tempfile::tempdir().unwrap();
2308 let dir = tmp.path();
2309 init_bare_repo(dir);
2310 commit_file(dir, "keep/x", "0", "feat: keep");
2311 commit_file(dir, "drop/y", "1", "feat: drop");
2312
2313 let commits = get_all_commits_paths_in(dir, &["keep".into()]).unwrap();
2314 assert_eq!(commits.len(), 1);
2315 assert_eq!(commits[0].message, "feat: keep");
2316 }
2317
2318 #[test]
2319 fn get_all_commits_paths_with_files_in_pairs_files() {
2320 let tmp = tempfile::tempdir().unwrap();
2321 let dir = tmp.path();
2322 init_bare_repo(dir);
2323 commit_file(dir, "crates/core/lib.rs", "0", "feat: core");
2324
2325 let pairs = get_all_commits_paths_with_files_in(dir, &[]).unwrap();
2326 assert_eq!(pairs.len(), 1);
2327 assert_eq!(pairs[0].commit.message, "feat: core");
2328 assert_eq!(pairs[0].files, vec!["crates/core/lib.rs".to_string()]);
2329 }
2330
2331 #[test]
2334 fn get_commits_reachable_paths_in_stops_at_the_given_rev() {
2335 let tmp = tempfile::tempdir().unwrap();
2336 let dir = tmp.path();
2337 init_bare_repo(dir);
2338 commit_file(dir, "a", "0", "first");
2339 commit_file(dir, "a", "1", "second");
2340 let mid = get_head_commit_in(dir).unwrap();
2341 commit_file(dir, "a", "2", "third-after-mid");
2342
2343 let commits = get_commits_reachable_paths_in(dir, &mid, &[]).unwrap();
2345 let subjects: Vec<&str> = commits.iter().map(|c| c.message.as_str()).collect();
2346 assert_eq!(commits.len(), 2, "only ancestors of mid: {subjects:?}");
2347 assert!(subjects.contains(&"first"));
2348 assert!(subjects.contains(&"second"));
2349 assert!(!subjects.contains(&"third-after-mid"));
2350 }
2351
2352 #[test]
2353 fn get_commits_reachable_paths_with_files_in_pairs_touched_files() {
2354 let tmp = tempfile::tempdir().unwrap();
2355 let dir = tmp.path();
2356 init_bare_repo(dir);
2357 commit_file(dir, "src/main.rs", "0", "feat: main");
2358 let head = get_head_commit_in(dir).unwrap();
2359
2360 let pairs = get_commits_reachable_paths_with_files_in(dir, &head, &[]).unwrap();
2361 assert_eq!(pairs.len(), 1);
2362 assert_eq!(pairs[0].commit.message, "feat: main");
2363 assert_eq!(pairs[0].files, vec!["src/main.rs".to_string()]);
2364 }
2365
2366 #[test]
2369 fn get_last_commit_messages_in_returns_n_subjects_newest_first() {
2370 let tmp = tempfile::tempdir().unwrap();
2371 let dir = tmp.path();
2372 init_bare_repo(dir);
2373 commit_file(dir, "a", "0", "one");
2374 commit_file(dir, "a", "1", "two");
2375 commit_file(dir, "a", "2", "three");
2376
2377 let msgs = get_last_commit_messages_in(dir, 2).unwrap();
2378 assert_eq!(msgs, vec!["three".to_string(), "two".to_string()]);
2379 }
2380
2381 #[test]
2382 fn get_commit_messages_between_in_lists_post_base_subjects() {
2383 let tmp = tempfile::tempdir().unwrap();
2384 let dir = tmp.path();
2385 init_bare_repo(dir);
2386 commit_file(dir, "a", "0", "initial");
2387 let base = get_head_commit_in(dir).unwrap();
2388 commit_file(dir, "a", "1", "feat: x");
2389 commit_file(dir, "a", "2", "fix: y");
2390
2391 let msgs = get_commit_messages_between_in(dir, &base, "HEAD").unwrap();
2392 assert_eq!(msgs, vec!["fix: y".to_string(), "feat: x".to_string()]);
2393 }
2394
2395 #[test]
2396 fn get_last_commit_messages_path_in_filters_to_path() {
2397 let tmp = tempfile::tempdir().unwrap();
2398 let dir = tmp.path();
2399 init_bare_repo(dir);
2400 commit_file(dir, "keep/a", "0", "feat: keep");
2401 commit_file(dir, "other/b", "1", "feat: other");
2402
2403 let msgs = get_last_commit_messages_path_in(dir, 10, "keep").unwrap();
2404 assert_eq!(msgs, vec!["feat: keep".to_string()]);
2405 }
2406
2407 #[test]
2408 fn get_commit_messages_between_path_in_filters_range_and_path() {
2409 let tmp = tempfile::tempdir().unwrap();
2410 let dir = tmp.path();
2411 init_bare_repo(dir);
2412 commit_file(dir, "base", "0", "initial");
2413 let base = get_head_commit_in(dir).unwrap();
2414 commit_file(dir, "src/x", "1", "feat: src");
2415 commit_file(dir, "doc/y", "2", "docs: doc");
2416
2417 let msgs = get_commit_messages_between_path_in(dir, &base, "HEAD", "src").unwrap();
2418 assert_eq!(msgs, vec!["feat: src".to_string()]);
2419 }
2420
2421 #[test]
2424 fn has_changes_since_in_detects_path_touched_after_tag() {
2425 let tmp = tempfile::tempdir().unwrap();
2426 let dir = tmp.path();
2427 init_bare_repo(dir);
2428 commit_file(dir, "watched", "0", "initial");
2429 g(dir, &["tag", "v1.0.0"]);
2430 assert!(!has_changes_since_in(dir, "v1.0.0", "watched").unwrap());
2432 commit_file(dir, "watched", "1", "feat: change watched");
2433 assert!(has_changes_since_in(dir, "v1.0.0", "watched").unwrap());
2435 assert!(!has_changes_since_in(dir, "v1.0.0", "unrelated").unwrap());
2437 }
2438
2439 #[test]
2440 fn paths_changed_since_tag_in_true_when_any_path_changed() {
2441 let tmp = tempfile::tempdir().unwrap();
2442 let dir = tmp.path();
2443 init_bare_repo(dir);
2444 commit_file(dir, "a", "0", "initial");
2445 g(dir, &["tag", "v1.0.0"]);
2446 commit_file(dir, "b", "1", "feat: add b");
2447
2448 assert!(paths_changed_since_tag_in(dir, "v1.0.0", &["a", "b"]).unwrap());
2450 assert!(!paths_changed_since_tag_in(dir, "v1.0.0", &["a"]).unwrap());
2452 }
2453
2454 #[test]
2455 fn paths_changed_since_tag_in_returns_false_when_git_fails() {
2456 let tmp = tempfile::tempdir().unwrap();
2459 let dir = tmp.path();
2460 init_bare_repo(dir);
2461 commit_file(dir, "a", "0", "initial");
2462 assert!(!paths_changed_since_tag_in(dir, "nope-no-such-tag", &["a"]).unwrap());
2463 }
2464
2465 #[test]
2468 fn head_commit_hash_in_matches_rev_parse_head() {
2469 let tmp = tempfile::tempdir().unwrap();
2470 let dir = tmp.path();
2471 init_bare_repo(dir);
2472 commit_file(dir, "a", "0", "initial");
2473 let expected = get_head_commit_in(dir).unwrap();
2474 assert_eq!(head_commit_hash_in(dir).unwrap(), expected);
2475 }
2476
2477 #[test]
2478 fn head_commit_hash_in_errors_on_non_repo() {
2479 let tmp = tempfile::tempdir().unwrap();
2480 assert!(head_commit_hash_in(tmp.path()).is_err());
2482 }
2483
2484 #[test]
2485 fn rev_parse_in_resolves_branch_to_full_sha() {
2486 let tmp = tempfile::tempdir().unwrap();
2487 let dir = tmp.path();
2488 init_bare_repo(dir);
2489 commit_file(dir, "a", "0", "initial");
2490 let head = get_head_commit_in(dir).unwrap();
2491 assert_eq!(rev_parse_in(dir, "master").unwrap(), head);
2492 }
2493
2494 #[test]
2495 fn rev_verify_commit_in_accepts_commit_rejects_unknown() {
2496 let tmp = tempfile::tempdir().unwrap();
2497 let dir = tmp.path();
2498 init_bare_repo(dir);
2499 commit_file(dir, "a", "0", "initial");
2500 let head = get_head_commit_in(dir).unwrap();
2501 assert_eq!(rev_verify_commit_in(dir, "HEAD").unwrap(), head);
2502 assert!(rev_verify_commit_in(dir, "deadbeefdeadbeef").is_err());
2504 }
2505
2506 #[test]
2507 fn commits_between_in_lists_shas_above_base_and_empty_at_head() {
2508 let tmp = tempfile::tempdir().unwrap();
2509 let dir = tmp.path();
2510 init_bare_repo(dir);
2511 commit_file(dir, "a", "0", "initial");
2512 let base = get_head_commit_in(dir).unwrap();
2513 commit_file(dir, "a", "1", "second");
2514 let head = get_head_commit_in(dir).unwrap();
2515
2516 let shas = commits_between_in(dir, &base).unwrap();
2517 assert_eq!(
2518 shas,
2519 vec![head.clone()],
2520 "exactly the one commit above base"
2521 );
2522 assert!(commits_between_in(dir, &head).unwrap().is_empty());
2524 }
2525
2526 #[test]
2527 fn commit_subject_in_returns_single_commit_subject() {
2528 let tmp = tempfile::tempdir().unwrap();
2529 let dir = tmp.path();
2530 init_bare_repo(dir);
2531 commit_file(dir, "a", "0", "feat: only-subject\n\nignored body");
2532 let head = get_head_commit_in(dir).unwrap();
2533 assert_eq!(commit_subject_in(dir, &head).unwrap(), "feat: only-subject");
2534 }
2535
2536 #[test]
2537 fn head_commit_timestamp_in_returns_pinned_committer_epoch() {
2538 let tmp = tempfile::tempdir().unwrap();
2539 let dir = tmp.path();
2540 init_bare_repo(dir);
2541 commit_file(dir, "a", "0", "initial");
2543 assert_eq!(head_commit_timestamp_in(dir).unwrap(), 1_715_000_000);
2544 }
2545
2546 #[test]
2549 fn log_subjects_for_range_returns_full_bodies_for_path() {
2550 let tmp = tempfile::tempdir().unwrap();
2551 let dir = tmp.path();
2552 init_bare_repo(dir);
2553 commit_file(dir, "watched", "0", "feat: A\n\nbody of A");
2554 commit_file(dir, "watched", "1", "fix: B");
2555
2556 let bodies = log_subjects_for_range(dir, "HEAD", "watched").unwrap();
2557 assert_eq!(bodies.len(), 2);
2558 assert!(bodies[0].starts_with("fix: B"));
2560 assert!(bodies[1].contains("feat: A") && bodies[1].contains("body of A"));
2561 }
2562
2563 #[test]
2564 fn log_subjects_for_range_returns_empty_when_range_invalid() {
2565 let tmp = tempfile::tempdir().unwrap();
2566 let dir = tmp.path();
2567 init_bare_repo(dir);
2568 commit_file(dir, "a", "0", "initial");
2569 let bodies = log_subjects_for_range(dir, "no-such-ref..HEAD", "a").unwrap();
2572 assert!(bodies.is_empty());
2573 }
2574
2575 #[test]
2579 fn log_subjects_for_range_propagates_pathspec_fatal() {
2580 let tmp = tempfile::tempdir().unwrap();
2581 let dir = tmp.path();
2582 init_bare_repo(dir);
2583 commit_file(dir, "a", "0", "initial");
2584 let err = log_subjects_for_range(dir, "HEAD", "")
2585 .expect_err("empty pathspec must be an error, not empty success")
2586 .to_string();
2587 assert!(err.contains("git log HEAD failed"), "{err}");
2588 }
2589
2590 #[test]
2593 fn add_path_in_then_commit_in_creates_commit() {
2594 let tmp = tempfile::tempdir().unwrap();
2595 let dir = tmp.path();
2596 init_bare_repo(dir);
2597 commit_file(dir, "seed", "0", "initial");
2598 std::fs::write(dir.join("new.txt"), "hello").unwrap();
2599
2600 add_path_in(dir, std::path::Path::new("new.txt")).unwrap();
2601 commit_in(dir, "feat: add new.txt", false).unwrap();
2602
2603 let subject = String::from_utf8(
2604 anodizer_core::test_helpers::output_with_spawn_retry(
2605 || {
2606 let mut cmd = Command::new("git");
2607 cmd.args(["log", "-1", "--pretty=%s"]).current_dir(dir);
2608 cmd
2609 },
2610 "git",
2611 )
2612 .stdout,
2613 )
2614 .unwrap()
2615 .trim()
2616 .to_string();
2617 assert_eq!(subject, "feat: add new.txt");
2618 }
2619
2620 #[test]
2621 fn add_path_in_errors_on_missing_file() {
2622 let tmp = tempfile::tempdir().unwrap();
2623 let dir = tmp.path();
2624 init_bare_repo(dir);
2625 let err = add_path_in(dir, std::path::Path::new("does-not-exist")).unwrap_err();
2626 assert!(
2627 err.to_string().contains("git add"),
2628 "error must name the failing git add: {err}"
2629 );
2630 }
2631
2632 #[test]
2635 fn reset_hard_in_moves_head_and_restores_tree() {
2636 let tmp = tempfile::tempdir().unwrap();
2637 let dir = tmp.path();
2638 init_bare_repo(dir);
2639 commit_file(dir, "a", "first", "first");
2640 let target = get_head_commit_in(dir).unwrap();
2641 commit_file(dir, "a", "second", "second");
2642 assert_ne!(get_head_commit_in(dir).unwrap(), target);
2643
2644 reset_hard_in(dir, &target).unwrap();
2645 assert_eq!(get_head_commit_in(dir).unwrap(), target, "HEAD moved back");
2646 assert_eq!(
2647 std::fs::read_to_string(dir.join("a")).unwrap(),
2648 "first",
2649 "working tree restored to target content"
2650 );
2651 }
2652
2653 #[test]
2656 fn push_branch_in_bails_without_origin_remote() {
2657 let tmp = tempfile::tempdir().unwrap();
2658 let dir = tmp.path();
2659 init_bare_repo(dir);
2660 commit_file(dir, "a", "0", "initial");
2661 let err = push_branch_in(dir, "master").unwrap_err();
2662 assert!(
2663 err.to_string().contains("no 'origin' remote"),
2664 "missing-remote bail must be explicit: {err}"
2665 );
2666 }
2667
2668 #[test]
2671 #[serial_test::serial(git_env)]
2672 fn resolve_rollback_identity_inherits_when_repo_has_identity() {
2673 let tmp = tempfile::tempdir().unwrap();
2674 let dir = tmp.path();
2675 init_bare_repo(dir); struct EnvGuard(Vec<(&'static str, Option<String>)>);
2680 impl Drop for EnvGuard {
2681 fn drop(&mut self) {
2682 for (k, v) in &self.0 {
2683 match v {
2684 Some(val) => unsafe { std::env::set_var(k, val) },
2686 None => unsafe { std::env::remove_var(k) },
2688 }
2689 }
2690 }
2691 }
2692 let keys = [
2693 "GIT_AUTHOR_NAME",
2694 "GIT_AUTHOR_EMAIL",
2695 "GIT_COMMITTER_NAME",
2696 "GIT_COMMITTER_EMAIL",
2697 ];
2698 let _g = EnvGuard(keys.iter().map(|k| (*k, std::env::var(k).ok())).collect());
2699 for k in keys {
2700 unsafe { std::env::remove_var(k) };
2702 }
2703
2704 let id = resolve_rollback_identity(dir);
2706 assert!(
2707 id.name.is_none() && id.email.is_none(),
2708 "configured repo identity must be inherited, not overridden: {id:?}"
2709 );
2710 }
2711
2712 #[test]
2713 #[serial_test::serial(git_env)]
2714 fn resolve_rollback_identity_synthesizes_when_no_identity_anywhere() {
2715 let tmp = tempfile::tempdir().unwrap();
2716 let dir = tmp.path();
2717 g(dir, &["init", "-b", "master"]);
2719
2720 struct EnvGuard(Vec<(&'static str, Option<String>)>);
2721 impl Drop for EnvGuard {
2722 fn drop(&mut self) {
2723 for (k, v) in &self.0 {
2724 match v {
2725 Some(val) => unsafe { std::env::set_var(k, val) },
2727 None => unsafe { std::env::remove_var(k) },
2729 }
2730 }
2731 }
2732 }
2733 let keys = [
2734 "GIT_AUTHOR_NAME",
2735 "GIT_AUTHOR_EMAIL",
2736 "GIT_COMMITTER_NAME",
2737 "GIT_COMMITTER_EMAIL",
2738 ];
2739 let _g = EnvGuard(keys.iter().map(|k| (*k, std::env::var(k).ok())).collect());
2740 for k in keys {
2741 unsafe { std::env::remove_var(k) };
2743 }
2744
2745 let (n, e) = read_git_identity(dir);
2748 if n.is_none() || e.is_none() {
2749 let id = resolve_rollback_identity(dir);
2750 assert_eq!(id.name.as_deref(), Some("anodize-rollback"));
2751 assert!(
2752 id.email
2753 .as_deref()
2754 .unwrap_or("")
2755 .starts_with("anodize-rollback@"),
2756 "synthetic identity required when no config present: {id:?}"
2757 );
2758 }
2759 }
2760
2761 #[test]
2762 fn read_git_identity_reads_configured_values() {
2763 let tmp = tempfile::tempdir().unwrap();
2764 let dir = tmp.path();
2765 g(dir, &["init", "-b", "master"]);
2766 g(dir, &["config", "user.name", "Configured Name"]);
2767 g(dir, &["config", "user.email", "configured@x.com"]);
2768
2769 let (name, email) = read_git_identity(dir);
2770 assert_eq!(name.as_deref(), Some("Configured Name"));
2771 assert_eq!(email.as_deref(), Some("configured@x.com"));
2772 }
2773}