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
27pub const CHANGELOG_PROVENANCE_PREFIX: &str = "changelog regenerated for ";
38
39pub fn changelog_regenerated_marker(crate_name: &str, version: &str) -> String {
45 format!("{CHANGELOG_PROVENANCE_PREFIX}{crate_name}@{version}")
46}
47
48pub fn changelog_regenerated_recorded_in(
64 workspace_root: &Path,
65 crate_name: &str,
66 version: &str,
67 changelog_rel_path: &str,
68) -> Result<bool> {
69 let marker = changelog_regenerated_marker(crate_name, version);
70 let out = Command::new("git")
71 .arg("-C")
72 .arg(workspace_root)
73 .args([
74 "-c",
75 "log.showSignature=false",
76 "log",
77 "-1",
78 "--pretty=format:%B",
79 "--",
80 changelog_rel_path,
81 ])
82 .env("GIT_TERMINAL_PROMPT", "0")
83 .env("LC_ALL", "C")
84 .output()
85 .context("failed to invoke git log for the changelog provenance marker")?;
86 if !out.status.success() {
87 let stderr = String::from_utf8_lossy(&out.stderr);
88 if stderr.contains("does not have any commits yet") {
90 return Ok(false);
91 }
92 let raw = format!("git log failed: {}", stderr.trim());
93 bail!("{}", crate::redact::redact_process_env(&raw));
94 }
95 let body = String::from_utf8_lossy(&out.stdout);
96 Ok(body.lines().map(str::trim).any(|line| line == marker))
97}
98
99#[derive(Debug, Clone)]
100pub struct Commit {
101 pub hash: String,
102 pub short_hash: String,
103 pub message: String,
104 pub author_name: String,
105 pub author_email: String,
106 pub body: String,
109}
110
111pub fn parse_commit_output(output: &str) -> Vec<Commit> {
122 if output.is_empty() {
123 return vec![];
124 }
125 output
126 .split('\x1e')
127 .filter(|record| !record.trim().is_empty())
128 .filter_map(|record| {
129 let fields: Vec<&str> = record.split('\x1f').collect();
130 if fields.len() >= 5 {
131 Some(Commit {
132 hash: fields[0].trim().to_string(),
133 short_hash: fields[1].to_string(),
134 message: fields[2].to_string(),
135 author_name: fields[3].to_string(),
136 author_email: fields[4].to_string(),
137 body: fields.get(5).unwrap_or(&"").trim().to_string(),
138 })
139 } else {
140 None
141 }
142 })
143 .collect()
144}
145
146fn cwd_or_dot() -> PathBuf {
147 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
148}
149
150pub fn get_commits_between(from: &str, to: &str, path_filter: Option<&str>) -> Result<Vec<Commit>> {
152 get_commits_between_in(&cwd_or_dot(), from, to, path_filter)
153}
154
155pub fn get_commits_between_in(
157 cwd: &Path,
158 from: &str,
159 to: &str,
160 path_filter: Option<&str>,
161) -> Result<Vec<Commit>> {
162 get_commits_between_paths_in(
163 cwd,
164 from,
165 to,
166 &path_filter
167 .into_iter()
168 .map(String::from)
169 .collect::<Vec<_>>(),
170 )
171}
172
173pub fn get_commits_between_paths(from: &str, to: &str, paths: &[String]) -> Result<Vec<Commit>> {
175 get_commits_between_paths_in(&cwd_or_dot(), from, to, paths)
176}
177
178pub fn get_commits_between_paths_in(
180 cwd: &Path,
181 from: &str,
182 to: &str,
183 paths: &[String],
184) -> Result<Vec<Commit>> {
185 let range = format!("{}..{}", from, to);
186 let mut args = vec![
187 "-c".to_string(),
188 "log.showSignature=false".to_string(),
189 "log".to_string(),
190 "--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e".to_string(),
191 range,
192 ];
193 if !paths.is_empty() {
194 args.push("--".to_string());
195 for p in paths {
196 args.push(p.clone());
197 }
198 }
199 let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
200 let output = git_output_in(cwd, &arg_refs)?;
201 Ok(parse_commit_output(&output))
202}
203
204pub fn get_all_commits(path_filter: Option<&str>) -> Result<Vec<Commit>> {
207 get_all_commits_in(&cwd_or_dot(), path_filter)
208}
209
210pub fn get_all_commits_in(cwd: &Path, path_filter: Option<&str>) -> Result<Vec<Commit>> {
212 get_all_commits_paths_in(
213 cwd,
214 &path_filter
215 .into_iter()
216 .map(String::from)
217 .collect::<Vec<_>>(),
218 )
219}
220
221pub fn get_all_commits_paths(paths: &[String]) -> Result<Vec<Commit>> {
223 get_all_commits_paths_in(&cwd_or_dot(), paths)
224}
225
226pub fn get_all_commits_paths_in(cwd: &Path, paths: &[String]) -> Result<Vec<Commit>> {
228 let mut args = vec![
229 "-c".to_string(),
230 "log.showSignature=false".to_string(),
231 "log".to_string(),
232 "--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e".to_string(),
233 "HEAD".to_string(),
234 ];
235 if !paths.is_empty() {
236 args.push("--".to_string());
237 for p in paths {
238 args.push(p.clone());
239 }
240 }
241 let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
242 let output = git_output_in(cwd, &arg_refs)?;
243 Ok(parse_commit_output(&output))
244}
245
246#[derive(Debug, Clone)]
252pub struct CommitWithFiles {
253 pub commit: Commit,
255 pub files: Vec<String>,
257}
258
259pub fn parse_commit_output_with_files(output: &str) -> Vec<CommitWithFiles> {
276 if output.is_empty() {
277 return vec![];
278 }
279 let segments: Vec<&str> = output.split('\x1e').collect();
280 let mut out: Vec<CommitWithFiles> = Vec::new();
281 for (idx, seg) in segments.iter().enumerate() {
287 let metadata = if idx == 0 {
295 seg.trim_start_matches(['\n', '\r']).to_string()
296 } else {
297 let lines: Vec<&str> = seg.split('\n').collect();
298 match lines.iter().position(|line| line.contains('\x1f')) {
299 Some(start) => lines[start..].join("\n"),
300 None => String::new(),
301 }
302 };
303 if metadata.trim().is_empty() {
304 continue;
305 }
306 let commits = parse_commit_output(&metadata);
307 let Some(commit) = commits.into_iter().next() else {
308 continue;
309 };
310 let files = match segments.get(idx + 1) {
313 Some(next) => next
314 .split('\n')
315 .map(str::trim)
316 .take_while(|line| !line.contains('\x1f'))
317 .filter(|line| !line.is_empty())
318 .map(str::to_string)
319 .collect(),
320 None => Vec::new(),
321 };
322 out.push(CommitWithFiles { commit, files });
323 }
324 out
325}
326
327pub fn get_commits_between_paths_with_files_in(
331 cwd: &Path,
332 from: &str,
333 to: &str,
334 paths: &[String],
335) -> Result<Vec<CommitWithFiles>> {
336 let range = format!("{}..{}", from, to);
337 let mut args = vec![
338 "-c".to_string(),
339 "log.showSignature=false".to_string(),
340 "log".to_string(),
341 "--name-only".to_string(),
342 "--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e".to_string(),
343 range,
344 ];
345 if !paths.is_empty() {
346 args.push("--".to_string());
347 for p in paths {
348 args.push(p.clone());
349 }
350 }
351 let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
352 let output = git_output_in(cwd, &arg_refs)?;
353 Ok(parse_commit_output_with_files(&output))
354}
355
356pub fn get_all_commits_paths_with_files_in(
358 cwd: &Path,
359 paths: &[String],
360) -> Result<Vec<CommitWithFiles>> {
361 let mut args = vec![
362 "-c".to_string(),
363 "log.showSignature=false".to_string(),
364 "log".to_string(),
365 "--name-only".to_string(),
366 "--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e".to_string(),
367 "HEAD".to_string(),
368 ];
369 if !paths.is_empty() {
370 args.push("--".to_string());
371 for p in paths {
372 args.push(p.clone());
373 }
374 }
375 let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
376 let output = git_output_in(cwd, &arg_refs)?;
377 Ok(parse_commit_output_with_files(&output))
378}
379
380pub fn get_commits_reachable_paths_in(
385 cwd: &Path,
386 rev: &str,
387 paths: &[String],
388) -> Result<Vec<Commit>> {
389 let mut args = vec![
390 "-c".to_string(),
391 "log.showSignature=false".to_string(),
392 "log".to_string(),
393 "--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e".to_string(),
394 rev.to_string(),
395 ];
396 if !paths.is_empty() {
397 args.push("--".to_string());
398 for p in paths {
399 args.push(p.clone());
400 }
401 }
402 let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
403 let output = git_output_in(cwd, &arg_refs)?;
404 Ok(parse_commit_output(&output))
405}
406
407pub fn get_commits_reachable_paths_with_files_in(
409 cwd: &Path,
410 rev: &str,
411 paths: &[String],
412) -> Result<Vec<CommitWithFiles>> {
413 let mut args = vec![
414 "-c".to_string(),
415 "log.showSignature=false".to_string(),
416 "log".to_string(),
417 "--name-only".to_string(),
418 "--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e".to_string(),
419 rev.to_string(),
420 ];
421 if !paths.is_empty() {
422 args.push("--".to_string());
423 for p in paths {
424 args.push(p.clone());
425 }
426 }
427 let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
428 let output = git_output_in(cwd, &arg_refs)?;
429 Ok(parse_commit_output_with_files(&output))
430}
431
432pub fn get_last_commit_messages(count: usize) -> Result<Vec<String>> {
434 get_last_commit_messages_in(&cwd_or_dot(), count)
435}
436
437pub fn get_last_commit_messages_in(cwd: &Path, count: usize) -> Result<Vec<String>> {
439 let output = git_output_in(
440 cwd,
441 &[
442 "-c",
443 "log.showSignature=false",
444 "log",
445 &format!("-{count}"),
446 "--pretty=format:%s",
447 ],
448 )?;
449 Ok(output.lines().map(str::to_string).collect())
450}
451
452pub fn get_commit_messages_between(from: &str, to: &str) -> Result<Vec<String>> {
454 get_commit_messages_between_in(&cwd_or_dot(), from, to)
455}
456
457pub fn get_commit_messages_between_in(cwd: &Path, from: &str, to: &str) -> Result<Vec<String>> {
459 let output = git_output_in(
460 cwd,
461 &[
462 "-c",
463 "log.showSignature=false",
464 "log",
465 "--pretty=format:%s",
466 &format!("{from}..{to}"),
467 ],
468 )?;
469 Ok(output.lines().map(str::to_string).collect())
470}
471
472pub fn get_current_branch() -> Result<String> {
474 get_current_branch_in(&cwd_or_dot())
475}
476
477pub fn is_branchlike(name: &str) -> bool {
502 use regex::Regex;
503 use std::sync::OnceLock;
504 static LOCKSTEP: OnceLock<Regex> = OnceLock::new();
505 static PER_CRATE: OnceLock<Regex> = OnceLock::new();
506 let lockstep = LOCKSTEP.get_or_init(|| Regex::new(r"^v\d+\.\d+\.\d+").expect("static regex"));
507 let per_crate =
508 PER_CRATE.get_or_init(|| Regex::new(r"^[^/]+-v\d+\.\d+\.\d+").expect("static regex"));
509 !(lockstep.is_match(name) || per_crate.is_match(name))
510}
511
512pub fn get_current_branch_in(cwd: &Path) -> Result<String> {
526 get_current_branch_in_with_env(cwd, &crate::ProcessEnvSource)
527}
528
529pub fn get_current_branch_in_with_env<E: crate::EnvSource + ?Sized>(
535 cwd: &Path,
536 env: &E,
537) -> Result<String> {
538 if let Ok(name) = git_output_in(cwd, &["symbolic-ref", "--short", "HEAD"]) {
539 return Ok(name);
540 }
541 if let Ok(out) = git_output_in(
542 cwd,
543 &[
544 "for-each-ref",
545 "--points-at",
546 "HEAD",
547 "--format=%(refname:short)",
548 "refs/heads/",
549 ],
550 ) && !out.is_empty()
551 {
552 let branches: Vec<&str> = out.lines().collect();
553 for preferred in ["master", "main"] {
554 if branches.contains(&preferred) {
555 return Ok(preferred.to_string());
556 }
557 }
558 if let Some(first) = branches.first() {
559 return Ok((*first).to_string());
560 }
561 }
562 if let Ok(out) = git_output_in(
563 cwd,
564 &["symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
565 ) && let Some(name) = out.strip_prefix("origin/")
566 {
567 return Ok(name.to_string());
568 }
569 if let Some(name) = env.var("GITHUB_REF_NAME")
570 && !name.is_empty()
571 && is_branchlike(&name)
572 {
573 return Ok(name);
574 }
575 anyhow::bail!(
576 "could not resolve current branch: HEAD is detached and no fallback (points-at-HEAD branches, origin/HEAD, GITHUB_REF_NAME) succeeded"
577 )
578}
579
580pub fn branches_containing_sha_in(cwd: &Path, sha: &str) -> Result<Vec<String>> {
586 let out = git_output_in(
587 cwd,
588 &[
589 "branch",
590 "-r",
591 "--contains",
592 sha,
593 "--format=%(refname:short)",
594 ],
595 )?;
596 Ok(out
597 .lines()
598 .filter_map(|line| line.trim().strip_prefix("origin/").map(str::to_string))
599 .filter(|name| !name.is_empty() && name != "HEAD")
600 .collect())
601}
602
603pub fn has_commits_since_tag(tag: &str) -> Result<bool> {
605 has_commits_since_tag_in(&cwd_or_dot(), tag)
606}
607
608pub fn has_commits_since_tag_in(cwd: &Path, tag: &str) -> Result<bool> {
610 let range = format!("{}..HEAD", tag);
611 let output = git_output_in(
612 cwd,
613 &["-c", "log.showSignature=false", "log", "--oneline", &range],
614 )?;
615 Ok(!output.is_empty())
616}
617
618pub fn count_commits_since_last_tag_in(cwd: &Path, monorepo_prefix: Option<&str>) -> Result<u64> {
638 let match_arg;
647 let mut describe_args: Vec<&str> = vec!["describe", "--tags", "--abbrev=0"];
648 if let Some(prefix) = monorepo_prefix {
649 match_arg = format!("--match={}*", prefix);
650 describe_args.push(&match_arg);
651 }
652 describe_args.push("HEAD");
653 let range = match git_output_in(cwd, &describe_args) {
654 Ok(tag) if !tag.is_empty() => format!("{tag}..HEAD"),
655 _ => "HEAD".to_string(),
656 };
657 let count = match git_output_in(cwd, &["rev-list", "--count", &range]) {
659 Ok(s) => s.trim().parse::<u64>().unwrap_or(0),
660 Err(_) => 0,
661 };
662 Ok(count)
663}
664
665pub fn get_short_commit() -> Result<String> {
667 get_short_commit_in(&cwd_or_dot())
668}
669
670pub fn get_short_commit_in(cwd: &Path) -> Result<String> {
672 git_output_in(cwd, &["rev-parse", "--short", "HEAD"])
673}
674
675pub const SHORT_COMMIT_LEN: usize = 7;
681
682pub fn short_commit_str(commit: &str) -> String {
693 if commit.len() > SHORT_COMMIT_LEN {
694 commit[..SHORT_COMMIT_LEN].to_string()
695 } else {
696 commit.to_string()
697 }
698}
699
700pub fn get_head_commit() -> Result<String> {
707 get_head_commit_in(&cwd_or_dot())
708}
709
710pub fn get_head_commit_in(cwd: &Path) -> Result<String> {
712 git_output_in(cwd, &["rev-parse", "HEAD"])
713}
714
715pub fn has_changes_since(tag: &str, path: &str) -> Result<bool> {
717 has_changes_since_in(&cwd_or_dot(), tag, path)
718}
719
720pub fn has_changes_since_in(cwd: &Path, tag: &str, path: &str) -> Result<bool> {
722 let output = git_output_in(
723 cwd,
724 &["diff", "--name-only", &format!("{}..HEAD", tag), "--", path],
725 )?;
726 Ok(!output.is_empty())
727}
728
729pub fn get_last_commit_messages_path(count: usize, path: &str) -> Result<Vec<String>> {
731 get_last_commit_messages_path_in(&cwd_or_dot(), count, path)
732}
733
734pub fn get_last_commit_messages_path_in(
736 cwd: &Path,
737 count: usize,
738 path: &str,
739) -> Result<Vec<String>> {
740 let output = git_output_in(
741 cwd,
742 &[
743 "-c",
744 "log.showSignature=false",
745 "log",
746 &format!("-{count}"),
747 "--pretty=format:%s",
748 "--",
749 path,
750 ],
751 )?;
752 Ok(output.lines().map(str::to_string).collect())
753}
754
755pub fn get_commit_messages_between_path(from: &str, to: &str, path: &str) -> Result<Vec<String>> {
757 get_commit_messages_between_path_in(&cwd_or_dot(), from, to, path)
758}
759
760pub fn get_commit_messages_between_path_in(
762 cwd: &Path,
763 from: &str,
764 to: &str,
765 path: &str,
766) -> Result<Vec<String>> {
767 let output = git_output_in(
768 cwd,
769 &[
770 "-c",
771 "log.showSignature=false",
772 "log",
773 "--pretty=format:%s",
774 &format!("{from}..{to}"),
775 "--",
776 path,
777 ],
778 )?;
779 Ok(output.lines().map(str::to_string).collect())
780}
781
782pub fn stage_and_commit(files: &[&str], message: &str) -> Result<bool> {
790 stage_and_commit_in(&cwd_or_dot(), files, message)
791}
792
793pub fn stage_and_commit_in(cwd: &Path, files: &[&str], message: &str) -> Result<bool> {
795 let mut args = vec!["add", "--"];
796 args.extend(files.iter().copied());
797 git_output_in(cwd, &args)?;
798 let diff = Command::new("git")
804 .current_dir(cwd)
805 .args(["diff", "--cached", "--quiet", "--"])
806 .args(files)
807 .env("GIT_TERMINAL_PROMPT", "0")
808 .env("LC_ALL", "C")
809 .status()?;
810 if diff.success() {
811 return Ok(false);
812 }
813 git_output_in(cwd, &["commit", "-m", message])?;
814 Ok(true)
815}
816
817pub fn log_subjects_for_range(
830 workspace_root: &std::path::Path,
831 range: &str,
832 rel_path: &str,
833) -> Result<Vec<String>> {
834 let out = Command::new("git")
835 .arg("-C")
836 .arg(workspace_root)
837 .args([
838 "-c",
839 "log.showSignature=false",
840 "log",
841 "--pretty=format:%B%x1e",
842 range,
843 "--",
844 rel_path,
845 ])
846 .env("GIT_TERMINAL_PROMPT", "0")
847 .env("LC_ALL", "C")
848 .output()?;
849 if !out.status.success() {
850 let stderr = String::from_utf8_lossy(&out.stderr);
855 let missing_range = stderr.contains("unknown revision")
856 || stderr.contains("bad revision")
857 || stderr.contains("does not have any commits yet");
858 if missing_range {
859 return Ok(Vec::new());
860 }
861 let raw = format!("git log {} failed: {}", range, stderr.trim());
862 bail!("{}", crate::redact::redact_process_env(&raw));
863 }
864 let text = String::from_utf8_lossy(&out.stdout);
865 Ok(text
866 .split('\x1e')
867 .map(|s| s.trim().to_string())
868 .filter(|s| !s.is_empty())
869 .collect())
870}
871
872pub fn add_path_in(workspace_root: &std::path::Path, rel: &std::path::Path) -> Result<()> {
874 let out = Command::new("git")
875 .arg("-C")
876 .arg(workspace_root)
877 .arg("add")
878 .arg(rel)
879 .env("GIT_TERMINAL_PROMPT", "0")
880 .env("LC_ALL", "C")
881 .output()
882 .context("failed to invoke git add")?;
883 if !out.status.success() {
884 let stderr_raw = String::from_utf8_lossy(&out.stderr);
885 let raw = format!("git add {} failed: {}", rel.display(), stderr_raw.trim());
886 bail!("{}", crate::redact::redact_process_env(&raw));
887 }
888 Ok(())
889}
890
891pub fn commit_in(workspace_root: &std::path::Path, message: &str, sign: bool) -> Result<()> {
894 let mut cmd = Command::new("git");
895 cmd.arg("-C").arg(workspace_root).arg("commit");
896 if sign {
897 cmd.arg("-S");
898 }
899 cmd.arg("-m")
900 .arg(message)
901 .env("GIT_TERMINAL_PROMPT", "0")
902 .env("LC_ALL", "C");
903 let out = cmd.output().context("failed to invoke git commit")?;
904 if !out.status.success() {
905 let stderr_raw = String::from_utf8_lossy(&out.stderr);
906 let raw = format!("git commit failed: {}", stderr_raw.trim());
907 bail!("{}", crate::redact::redact_process_env(&raw));
908 }
909 Ok(())
910}
911
912pub fn paths_changed_since_tag(tag: &str, paths: &[&str]) -> Result<bool> {
917 paths_changed_since_tag_in(&cwd_or_dot(), tag, paths)
918}
919
920pub fn paths_changed_since_tag_in(cwd: &Path, tag: &str, paths: &[&str]) -> Result<bool> {
922 let mut args: Vec<String> = vec![
923 "diff".to_string(),
924 "--name-only".to_string(),
925 format!("{tag}..HEAD"),
926 "--".to_string(),
927 ];
928 for p in paths {
929 args.push((*p).to_string());
930 }
931 let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
932 let output = Command::new("git")
933 .current_dir(cwd)
934 .args(&arg_refs)
935 .env("GIT_TERMINAL_PROMPT", "0")
936 .env("LC_ALL", "C")
937 .output()?;
938 if output.status.success() {
939 Ok(!String::from_utf8_lossy(&output.stdout).trim().is_empty())
940 } else {
941 Ok(false)
942 }
943}
944
945pub fn head_commit_hash_in(repo: &std::path::Path) -> Result<String> {
951 get_head_commit_in(repo)
952}
953
954pub fn rev_parse_in(cwd: &Path, rev: &str) -> Result<String> {
959 git_output_in(cwd, &["rev-parse", rev])
960}
961
962pub fn rev_verify_commit_in(cwd: &Path, rev: &str) -> Result<String> {
968 git_output_in(
969 cwd,
970 &["rev-parse", "--verify", &format!("{}^{{commit}}", rev)],
971 )
972}
973
974pub fn commits_between_in(cwd: &Path, sha: &str) -> Result<Vec<String>> {
979 let range = format!("{}..HEAD", sha);
980 let out = git_output_in(cwd, &["rev-list", &range])?;
981 if out.is_empty() {
982 return Ok(Vec::new());
983 }
984 Ok(out.lines().map(|s| s.trim().to_string()).collect())
985}
986
987pub fn commit_subject_in(cwd: &Path, sha: &str) -> Result<String> {
991 git_output_in(
992 cwd,
993 &[
994 "-c",
995 "log.showSignature=false",
996 "log",
997 "-1",
998 "--format=%s",
999 sha,
1000 ],
1001 )
1002}
1003
1004pub fn commits_with_subjects_in(cwd: &Path, sha: &str) -> Result<Vec<(String, String)>> {
1011 let range = format!("{}..HEAD", sha);
1012 let out = git_output_in(
1013 cwd,
1014 &[
1015 "-c",
1016 "log.showSignature=false",
1017 "log",
1018 "--format=%H%x1f%s",
1019 &range,
1020 ],
1021 )?;
1022 if out.is_empty() {
1023 return Ok(Vec::new());
1024 }
1025 Ok(out
1026 .lines()
1027 .filter_map(|line| {
1028 let mut parts = line.splitn(2, '\x1f');
1029 let sha = parts.next()?.trim().to_string();
1030 let subj = parts.next().unwrap_or("").to_string();
1031 if sha.is_empty() {
1032 None
1033 } else {
1034 Some((sha, subj))
1035 }
1036 })
1037 .collect())
1038}
1039
1040#[derive(Debug, Clone, Default)]
1053pub struct CommitterIdentity {
1054 pub name: Option<String>,
1055 pub email: Option<String>,
1056}
1057
1058impl CommitterIdentity {
1059 pub fn default_for_rollback() -> Self {
1065 let host = std::env::var("HOSTNAME")
1066 .ok()
1067 .or_else(|| std::env::var("COMPUTERNAME").ok())
1068 .and_then(|h| h.split('.').next().map(str::to_string))
1069 .filter(|h| !h.is_empty())
1070 .unwrap_or_else(|| "localhost".to_string());
1071 Self {
1072 name: Some("anodize-rollback".to_string()),
1073 email: Some(format!("anodize-rollback@{host}")),
1074 }
1075 }
1076
1077 fn apply_to(&self, cmd: &mut Command) {
1078 if let Some(n) = &self.name {
1079 cmd.env("GIT_AUTHOR_NAME", n).env("GIT_COMMITTER_NAME", n);
1080 }
1081 if let Some(e) = &self.email {
1082 cmd.env("GIT_AUTHOR_EMAIL", e).env("GIT_COMMITTER_EMAIL", e);
1083 }
1084 }
1085}
1086
1087fn read_git_identity(cwd: &Path) -> (Option<String>, Option<String>) {
1093 let one = |key: &str| -> Option<String> {
1094 let out = Command::new("git")
1095 .current_dir(cwd)
1096 .args(["config", "--get", key])
1097 .env("LC_ALL", "C")
1098 .env("GIT_TERMINAL_PROMPT", "0")
1099 .output()
1100 .ok()?;
1101 if !out.status.success() {
1102 return None;
1103 }
1104 let value = String::from_utf8_lossy(&out.stdout).trim().to_string();
1105 if value.is_empty() { None } else { Some(value) }
1106 };
1107 (one("user.name"), one("user.email"))
1108}
1109
1110pub fn resolve_rollback_identity(cwd: &Path) -> CommitterIdentity {
1117 let env_author_set =
1118 std::env::var("GIT_AUTHOR_EMAIL").is_ok() && std::env::var("GIT_AUTHOR_NAME").is_ok();
1119 let env_committer_set =
1120 std::env::var("GIT_COMMITTER_EMAIL").is_ok() && std::env::var("GIT_COMMITTER_NAME").is_ok();
1121 if env_author_set && env_committer_set {
1122 return CommitterIdentity::default();
1123 }
1124 let (name, email) = read_git_identity(cwd);
1125 if name.is_some() && email.is_some() {
1126 return CommitterIdentity::default();
1127 }
1128 CommitterIdentity::default_for_rollback()
1129}
1130
1131pub fn revert_commit_in(
1157 cwd: &Path,
1158 sha: &str,
1159 message: Option<&str>,
1160 identity: &CommitterIdentity,
1161) -> Result<()> {
1162 let status = Command::new("git")
1163 .args(["status", "--porcelain", "--untracked-files=no"])
1164 .current_dir(cwd)
1165 .env("LC_ALL", "C")
1166 .env("GIT_TERMINAL_PROMPT", "0")
1167 .output()
1168 .with_context(|| format!("revert_commit_in: git status in {}", cwd.display()))?;
1169 if !status.status.success() {
1170 let stderr_raw = String::from_utf8_lossy(&status.stderr);
1171 let raw = format!("git status failed: {}", stderr_raw.trim());
1172 bail!("{}", crate::redact::redact_process_env(&raw));
1173 }
1174 if !status.stdout.is_empty() {
1175 bail!(
1176 "refusing to revert in a dirty working tree at {}\nstatus:\n{}",
1177 cwd.display(),
1178 String::from_utf8_lossy(&status.stdout),
1179 );
1180 }
1181
1182 let mut revert_cmd = Command::new("git");
1183 revert_cmd
1184 .current_dir(cwd)
1185 .args(["revert", "--no-edit", sha])
1186 .env("LC_ALL", "C")
1187 .env("GIT_TERMINAL_PROMPT", "0");
1188 identity.apply_to(&mut revert_cmd);
1189 let out = revert_cmd
1190 .output()
1191 .with_context(|| format!("revert_commit_in: git revert in {}", cwd.display()))?;
1192 if !out.status.success() {
1193 let stderr_raw = String::from_utf8_lossy(&out.stderr);
1194 let _ = Command::new("git")
1197 .current_dir(cwd)
1198 .args(["revert", "--abort"])
1199 .env("LC_ALL", "C")
1200 .env("GIT_TERMINAL_PROMPT", "0")
1201 .output();
1202 let raw = format!(
1203 "git revert {sha} hit conflicts and was aborted (working tree restored). \
1204 The bump commit overlaps with later changes — resolve manually, \
1205 or re-run with --mode=reset to force.\nstderr: {}",
1206 stderr_raw.trim()
1207 );
1208 bail!("{}", crate::redact::redact_process_env(&raw));
1209 }
1210 if let Some(msg) = message {
1211 let mut amend_cmd = Command::new("git");
1212 amend_cmd
1213 .current_dir(cwd)
1214 .args(["commit", "--amend", "-m", msg])
1215 .env("LC_ALL", "C")
1216 .env("GIT_TERMINAL_PROMPT", "0");
1217 identity.apply_to(&mut amend_cmd);
1218 let out = amend_cmd.output().with_context(|| {
1219 format!("revert_commit_in: git commit --amend in {}", cwd.display())
1220 })?;
1221 if !out.status.success() {
1222 let stderr_raw = String::from_utf8_lossy(&out.stderr);
1223 let raw = format!("git commit --amend failed: {}", stderr_raw.trim());
1224 bail!("{}", crate::redact::redact_process_env(&raw));
1225 }
1226 }
1227 Ok(())
1228}
1229
1230pub fn reset_hard_in(cwd: &Path, sha: &str) -> Result<()> {
1233 git_output_in(cwd, &["reset", "--hard", sha])?;
1234 Ok(())
1235}
1236
1237pub fn push_branch_in(cwd: &Path, branch: &str) -> Result<()> {
1242 if !super::has_remote_in(cwd, "origin") {
1243 bail!("no 'origin' remote configured, cannot push branch '{branch}'");
1244 }
1245 let refspec = format!("HEAD:refs/heads/{}", branch);
1246 let out = Command::new("git")
1247 .current_dir(cwd)
1248 .args(["push", "origin", &refspec])
1249 .env("GIT_TERMINAL_PROMPT", "0")
1250 .env("LC_ALL", "C")
1251 .output()
1252 .with_context(|| format!("push_branch_in: git push origin {refspec}"))?;
1253 if !out.status.success() {
1254 let stderr_raw = String::from_utf8_lossy(&out.stderr);
1255 let raw = format!("git push origin {} failed: {}", refspec, stderr_raw.trim());
1256 bail!("{}", crate::redact::redact_process_env(&raw));
1257 }
1258 Ok(())
1259}
1260
1261pub fn head_commit_timestamp_in(repo: &std::path::Path) -> Result<i64> {
1265 let out = Command::new("git")
1266 .arg("-C")
1267 .arg(repo)
1268 .args(["log", "-1", "--format=%ct", "HEAD"])
1269 .env("GIT_TERMINAL_PROMPT", "0")
1270 .env("LC_ALL", "C")
1271 .output()
1272 .context("failed to invoke git log -1 --format=%ct HEAD")?;
1273 if !out.status.success() {
1274 let stderr_raw = String::from_utf8_lossy(&out.stderr);
1275 let raw = format!("git log -1 --format=%ct HEAD failed: {}", stderr_raw.trim());
1276 bail!("{}", crate::redact::redact_process_env(&raw));
1277 }
1278 let text = String::from_utf8_lossy(&out.stdout).trim().to_string();
1279 text.parse::<i64>()
1280 .with_context(|| format!("git log --format=%ct returned non-i64 timestamp: {}", text))
1281}
1282
1283#[cfg(test)]
1284mod tests {
1285 use super::*;
1286 use std::process::Command;
1287
1288 fn init_repo_with_commits(dir: &Path, files: &[&str]) {
1289 let run = |args: &[&str]| {
1290 let out = anodizer_core::test_helpers::output_with_spawn_retry(
1291 || {
1292 let mut cmd = Command::new("git");
1293 cmd.args(args)
1294 .current_dir(dir)
1295 .env("GIT_AUTHOR_NAME", "t")
1296 .env("GIT_AUTHOR_EMAIL", "t@t.com")
1297 .env("GIT_COMMITTER_NAME", "t")
1298 .env("GIT_COMMITTER_EMAIL", "t@t.com");
1299 cmd
1300 },
1301 "git",
1302 );
1303 assert!(out.status.success(), "git {args:?} failed");
1304 };
1305 run(&["init"]);
1306 run(&["config", "user.email", "t@t.com"]);
1307 run(&["config", "user.name", "t"]);
1308 for (i, f) in files.iter().enumerate() {
1309 std::fs::write(dir.join(f), format!("c{i}")).unwrap();
1310 run(&["add", "."]);
1311 run(&["commit", "-m", &format!("commit-{i}: {f}")]);
1312 }
1313 }
1314
1315 #[test]
1316 fn changelog_provenance_marker_binds_to_its_crate_and_version() {
1317 let tmp = tempfile::tempdir().unwrap();
1318 init_repo_with_commits(tmp.path(), &["a"]);
1319 std::fs::write(tmp.path().join("CHANGELOG.md"), "notes").unwrap();
1320 let msg = format!(
1321 "{}\n\n{}",
1322 release_bump_subject("core→0.12.0", ""),
1323 changelog_regenerated_marker("core", "0.12.0")
1324 );
1325 stage_and_commit_in(tmp.path(), &["CHANGELOG.md"], &msg).unwrap();
1326 let probe = |name: &str, ver: &str| {
1327 changelog_regenerated_recorded_in(tmp.path(), name, ver, "CHANGELOG.md").unwrap()
1328 };
1329 assert!(probe("core", "0.12.0"));
1330 assert!(!probe("cli", "0.12.0"));
1332 assert!(!probe("core", "0.12.1"));
1335 assert!(!probe("core", "0.12.01"));
1336 }
1337
1338 #[test]
1339 fn changelog_provenance_scopes_to_the_probed_changelog_path() {
1340 let tmp = tempfile::tempdir().unwrap();
1341 init_repo_with_commits(tmp.path(), &["a"]);
1342 std::fs::create_dir_all(tmp.path().join("crates/core")).unwrap();
1343 std::fs::write(tmp.path().join("crates/core/CHANGELOG.md"), "notes").unwrap();
1344 let msg = format!(
1345 "{}\n\n{}",
1346 release_bump_subject("core→0.2.0", ""),
1347 changelog_regenerated_marker("core", "0.2.0")
1348 );
1349 stage_and_commit_in(tmp.path(), &["crates/core/CHANGELOG.md"], &msg).unwrap();
1350 assert!(
1351 changelog_regenerated_recorded_in(
1352 tmp.path(),
1353 "core",
1354 "0.2.0",
1355 "crates/core/CHANGELOG.md"
1356 )
1357 .unwrap()
1358 );
1359 assert!(
1362 !changelog_regenerated_recorded_in(tmp.path(), "core", "0.2.0", "CHANGELOG.md")
1363 .unwrap()
1364 );
1365 }
1366
1367 #[test]
1368 fn changelog_provenance_superseded_by_a_later_hand_edit() {
1369 let tmp = tempfile::tempdir().unwrap();
1370 init_repo_with_commits(tmp.path(), &["a"]);
1371 std::fs::write(tmp.path().join("CHANGELOG.md"), "notes").unwrap();
1372 let msg = format!(
1373 "{}\n\n{}",
1374 release_bump_subject("core→0.2.0", ""),
1375 changelog_regenerated_marker("core", "0.2.0")
1376 );
1377 stage_and_commit_in(tmp.path(), &["CHANGELOG.md"], &msg).unwrap();
1378 assert!(
1379 changelog_regenerated_recorded_in(tmp.path(), "core", "0.2.0", "CHANGELOG.md").unwrap()
1380 );
1381
1382 std::fs::write(tmp.path().join("other.txt"), "x").unwrap();
1385 stage_and_commit_in(tmp.path(), &["other.txt"], "chore: unrelated").unwrap();
1386 assert!(
1387 changelog_regenerated_recorded_in(tmp.path(), "core", "0.2.0", "CHANGELOG.md").unwrap()
1388 );
1389
1390 std::fs::write(tmp.path().join("CHANGELOG.md"), "notes\nedited").unwrap();
1393 stage_and_commit_in(tmp.path(), &["CHANGELOG.md"], "docs: tweak changelog").unwrap();
1394 assert!(
1395 !changelog_regenerated_recorded_in(tmp.path(), "core", "0.2.0", "CHANGELOG.md")
1396 .unwrap()
1397 );
1398 }
1399
1400 #[test]
1401 fn changelog_provenance_absent_when_no_marker_committed() {
1402 let tmp = tempfile::tempdir().unwrap();
1403 init_repo_with_commits(tmp.path(), &["a"]);
1404 assert!(
1405 !changelog_regenerated_recorded_in(tmp.path(), "core", "0.2.0", "CHANGELOG.md")
1406 .unwrap()
1407 );
1408 }
1409
1410 #[test]
1411 fn get_head_commit_in_returns_tempdirs_head_sha() {
1412 let tmp = tempfile::tempdir().unwrap();
1413 init_repo_with_commits(tmp.path(), &["a"]);
1414 let expected = String::from_utf8(
1415 anodizer_core::test_helpers::output_with_spawn_retry(
1416 || {
1417 let mut cmd = Command::new("git");
1418 cmd.args(["rev-parse", "HEAD"]).current_dir(tmp.path());
1419 cmd
1420 },
1421 "git",
1422 )
1423 .stdout,
1424 )
1425 .unwrap()
1426 .trim()
1427 .to_string();
1428 let sha = get_head_commit_in(tmp.path()).unwrap();
1429 assert_eq!(sha, expected);
1430 }
1431
1432 #[test]
1433 fn get_short_commit_in_returns_tempdirs_short_sha() {
1434 let tmp = tempfile::tempdir().unwrap();
1435 init_repo_with_commits(tmp.path(), &["a"]);
1436 let expected = String::from_utf8(
1437 anodizer_core::test_helpers::output_with_spawn_retry(
1438 || {
1439 let mut cmd = Command::new("git");
1440 cmd.args(["rev-parse", "--short", "HEAD"])
1441 .current_dir(tmp.path());
1442 cmd
1443 },
1444 "git",
1445 )
1446 .stdout,
1447 )
1448 .unwrap()
1449 .trim()
1450 .to_string();
1451 let short = get_short_commit_in(tmp.path()).unwrap();
1452 assert_eq!(short, expected);
1453 }
1454
1455 #[test]
1456 fn has_commits_since_tag_in_returns_false_when_tag_is_head() {
1457 let tmp = tempfile::tempdir().unwrap();
1458 let dir = tmp.path();
1459 init_repo_with_commits(dir, &["a"]);
1460 let run = |args: &[&str]| {
1461 anodizer_core::test_helpers::output_with_spawn_retry(
1462 || {
1463 let mut cmd = Command::new("git");
1464 cmd.args(args)
1465 .current_dir(dir)
1466 .env("GIT_AUTHOR_NAME", "t")
1467 .env("GIT_AUTHOR_EMAIL", "t@t.com")
1468 .env("GIT_COMMITTER_NAME", "t")
1469 .env("GIT_COMMITTER_EMAIL", "t@t.com");
1470 cmd
1471 },
1472 "git",
1473 );
1474 };
1475 run(&["tag", "v1.0.0"]);
1476 assert!(!has_commits_since_tag_in(dir, "v1.0.0").unwrap());
1477 }
1478
1479 fn git_in(dir: &Path, args: &[&str]) {
1480 let out = anodizer_core::test_helpers::output_with_spawn_retry(
1481 || {
1482 let mut cmd = Command::new("git");
1483 cmd.args(args)
1484 .current_dir(dir)
1485 .env("GIT_AUTHOR_NAME", "t")
1486 .env("GIT_AUTHOR_EMAIL", "t@t.com")
1487 .env("GIT_COMMITTER_NAME", "t")
1488 .env("GIT_COMMITTER_EMAIL", "t@t.com");
1489 cmd
1490 },
1491 "git",
1492 );
1493 assert!(out.status.success(), "git {args:?} failed");
1494 }
1495
1496 #[test]
1497 fn count_commits_since_last_tag_counts_commits_after_tag() {
1498 let tmp = tempfile::tempdir().unwrap();
1499 let dir = tmp.path();
1500 init_repo_with_commits(dir, &["a", "b"]);
1502 git_in(dir, &["tag", "v1.0.0"]);
1503 for f in ["c", "d", "e"] {
1504 std::fs::write(dir.join(f), "x").unwrap();
1505 git_in(dir, &["add", "."]);
1506 git_in(dir, &["commit", "-m", f]);
1507 }
1508 assert_eq!(count_commits_since_last_tag_in(dir, None).unwrap(), 3);
1509 }
1510
1511 #[test]
1512 fn count_commits_since_last_tag_resets_on_newer_tag() {
1513 let tmp = tempfile::tempdir().unwrap();
1514 let dir = tmp.path();
1515 init_repo_with_commits(dir, &["a"]);
1516 git_in(dir, &["tag", "v1.0.0"]);
1517 for f in ["b", "c"] {
1518 std::fs::write(dir.join(f), "x").unwrap();
1519 git_in(dir, &["add", "."]);
1520 git_in(dir, &["commit", "-m", f]);
1521 }
1522 assert_eq!(count_commits_since_last_tag_in(dir, None).unwrap(), 2);
1523 git_in(dir, &["tag", "v1.1.0"]);
1525 assert_eq!(count_commits_since_last_tag_in(dir, None).unwrap(), 0);
1526 std::fs::write(dir.join("d"), "x").unwrap();
1527 git_in(dir, &["add", "."]);
1528 git_in(dir, &["commit", "-m", "d"]);
1529 assert_eq!(count_commits_since_last_tag_in(dir, None).unwrap(), 1);
1530 }
1531
1532 #[test]
1533 fn count_commits_since_last_tag_counts_all_when_no_tag() {
1534 let tmp = tempfile::tempdir().unwrap();
1535 let dir = tmp.path();
1536 init_repo_with_commits(dir, &["a", "b", "c"]);
1537 assert_eq!(count_commits_since_last_tag_in(dir, None).unwrap(), 3);
1539 }
1540
1541 #[test]
1542 fn count_commits_since_last_tag_respects_monorepo_prefix() {
1543 let tmp = tempfile::tempdir().unwrap();
1547 let dir = tmp.path();
1548 init_repo_with_commits(dir, &["a"]);
1549 git_in(dir, &["tag", "core/v1.0.0"]); for f in ["b", "c"] {
1551 std::fs::write(dir.join(f), "x").unwrap();
1552 git_in(dir, &["add", "."]);
1553 git_in(dir, &["commit", "-m", f]);
1554 }
1555 git_in(dir, &["tag", "api/v2.0.0"]); std::fs::write(dir.join("d"), "x").unwrap();
1557 git_in(dir, &["add", "."]);
1558 git_in(dir, &["commit", "-m", "d"]);
1559
1560 assert_eq!(
1562 count_commits_since_last_tag_in(dir, Some("core/")).unwrap(),
1563 3,
1564 "must count since the matching-prefix tag, ignoring api/v2.0.0",
1565 );
1566 assert_eq!(
1570 count_commits_since_last_tag_in(dir, None).unwrap(),
1571 1,
1572 "unfiltered count picks the nearest (wrong) subproject tag",
1573 );
1574 }
1575
1576 #[test]
1577 fn get_current_branch_in_returns_branch_name() {
1578 let tmp = tempfile::tempdir().unwrap();
1579 let dir = tmp.path();
1580 let run = |args: &[&str]| {
1581 let out = anodizer_core::test_helpers::output_with_spawn_retry(
1582 || {
1583 let mut cmd = Command::new("git");
1584 cmd.args(args)
1585 .current_dir(dir)
1586 .env("GIT_AUTHOR_NAME", "t")
1587 .env("GIT_AUTHOR_EMAIL", "t@t.com")
1588 .env("GIT_COMMITTER_NAME", "t")
1589 .env("GIT_COMMITTER_EMAIL", "t@t.com");
1590 cmd
1591 },
1592 "git",
1593 );
1594 assert!(out.status.success(), "git {args:?} failed");
1595 };
1596 run(&["-c", "init.defaultBranch=t1-test-branch", "init"]);
1597 run(&["config", "user.email", "t@t.com"]);
1598 run(&["config", "user.name", "t"]);
1599 std::fs::write(dir.join("a"), "1").unwrap();
1600 run(&["add", "."]);
1601 run(&["commit", "-m", "c1"]);
1602 let branch = get_current_branch_in(dir).unwrap();
1603 assert_eq!(branch, "t1-test-branch");
1604 }
1605
1606 #[test]
1607 fn get_current_branch_in_resolves_detached_head_via_points_at() {
1608 let tmp = tempfile::tempdir().unwrap();
1609 let dir = tmp.path();
1610 let run = |args: &[&str]| {
1611 let out = anodizer_core::test_helpers::output_with_spawn_retry(
1612 || {
1613 let mut cmd = Command::new("git");
1614 cmd.args(args)
1615 .current_dir(dir)
1616 .env("GIT_AUTHOR_NAME", "t")
1617 .env("GIT_AUTHOR_EMAIL", "t@t.com")
1618 .env("GIT_COMMITTER_NAME", "t")
1619 .env("GIT_COMMITTER_EMAIL", "t@t.com");
1620 cmd
1621 },
1622 "git",
1623 );
1624 assert!(out.status.success(), "git {args:?} failed");
1625 };
1626 run(&["-c", "init.defaultBranch=master", "init"]);
1627 run(&["config", "user.email", "t@t.com"]);
1628 run(&["config", "user.name", "t"]);
1629 std::fs::write(dir.join("a"), "1").unwrap();
1630 run(&["add", "."]);
1631 run(&["commit", "-m", "c1"]);
1632 let sha = get_head_commit_in(dir).unwrap();
1633 run(&["checkout", "--detach", &sha]);
1634 let branch = get_current_branch_in(dir).unwrap();
1635 assert_eq!(
1636 branch, "master",
1637 "detached HEAD pointing at master must resolve to master, not literal HEAD"
1638 );
1639 }
1640
1641 #[test]
1642 fn is_branchlike_rejects_lockstep_tag_shapes() {
1643 assert!(!is_branchlike("v0.4.5"));
1644 assert!(!is_branchlike("v1.2.3"));
1645 assert!(!is_branchlike("v10.20.30"));
1646 assert!(!is_branchlike("v1.2.3-rc.1"));
1647 assert!(!is_branchlike("v1.2.3+build.42"));
1648 }
1649
1650 #[test]
1651 fn is_branchlike_rejects_per_crate_tag_shapes() {
1652 assert!(!is_branchlike("mycrate-v1.2.3"));
1653 assert!(!is_branchlike("cfgd-operator-v0.4.0"));
1654 assert!(!is_branchlike("anodize-core-v1.2.3-rc.1"));
1655 }
1656
1657 #[test]
1658 fn is_branchlike_accepts_real_branch_names() {
1659 assert!(is_branchlike("master"));
1660 assert!(is_branchlike("main"));
1661 assert!(is_branchlike("publisher-required-config"));
1662 assert!(is_branchlike("release/v1.2.3-prep"));
1663 assert!(is_branchlike("dependabot/cargo/serde-1.0.200"));
1664 }
1665
1666 #[test]
1667 fn is_branchlike_accepts_slashed_branch_with_embedded_version() {
1668 assert!(is_branchlike("feature/fix-v2.0.0"));
1673 assert!(is_branchlike("hotfix/release-v1.0.0-blocker"));
1674 assert!(is_branchlike("user/wip-v3.1.4"));
1675 }
1676
1677 #[test]
1678 fn get_current_branch_in_rejects_tag_shaped_github_ref_name() {
1679 let tmp = tempfile::tempdir().unwrap();
1680 let dir = tmp.path();
1681 let run = |args: &[&str]| {
1682 let out = anodizer_core::test_helpers::output_with_spawn_retry(
1683 || {
1684 let mut cmd = Command::new("git");
1685 cmd.args(args)
1686 .current_dir(dir)
1687 .env("GIT_AUTHOR_NAME", "t")
1688 .env("GIT_AUTHOR_EMAIL", "t@t.com")
1689 .env("GIT_COMMITTER_NAME", "t")
1690 .env("GIT_COMMITTER_EMAIL", "t@t.com");
1691 cmd
1692 },
1693 "git",
1694 );
1695 assert!(out.status.success(), "git {args:?} failed");
1696 };
1697 run(&["-c", "init.defaultBranch=master", "init"]);
1701 run(&["config", "user.email", "t@t.com"]);
1702 run(&["config", "user.name", "t"]);
1703 std::fs::write(dir.join("a"), "1").unwrap();
1704 run(&["add", "."]);
1705 run(&["commit", "-m", "c1"]);
1706 let sha = get_head_commit_in(dir).unwrap();
1707 std::fs::write(dir.join("a"), "2").unwrap();
1710 run(&["add", "."]);
1711 run(&["commit", "-m", "c2"]);
1712 run(&["checkout", "--detach", &sha]);
1713
1714 let env = crate::MapEnvSource::new().with("GITHUB_REF_NAME", "v0.4.5");
1719 let err = get_current_branch_in_with_env(dir, &env).unwrap_err();
1720 assert!(
1721 err.to_string().contains("could not resolve current branch"),
1722 "tag-shaped GITHUB_REF_NAME must trigger bail: {err}"
1723 );
1724
1725 let env = crate::MapEnvSource::new().with("GITHUB_REF_NAME", "mycrate-v1.2.3");
1727 let err = get_current_branch_in_with_env(dir, &env).unwrap_err();
1728 assert!(
1729 err.to_string().contains("could not resolve current branch"),
1730 "per-crate tag GITHUB_REF_NAME must trigger bail: {err}"
1731 );
1732
1733 let env = crate::MapEnvSource::new().with("GITHUB_REF_NAME", "master");
1735 let branch = get_current_branch_in_with_env(dir, &env).unwrap();
1736 assert_eq!(branch, "master");
1737 }
1738
1739 #[test]
1740 fn branches_containing_sha_in_returns_empty_without_remote() {
1741 let tmp = tempfile::tempdir().unwrap();
1742 let dir = tmp.path();
1743 let run = |args: &[&str]| {
1744 let out = anodizer_core::test_helpers::output_with_spawn_retry(
1745 || {
1746 let mut cmd = Command::new("git");
1747 cmd.args(args)
1748 .current_dir(dir)
1749 .env("GIT_AUTHOR_NAME", "t")
1750 .env("GIT_AUTHOR_EMAIL", "t@t.com")
1751 .env("GIT_COMMITTER_NAME", "t")
1752 .env("GIT_COMMITTER_EMAIL", "t@t.com");
1753 cmd
1754 },
1755 "git",
1756 );
1757 assert!(out.status.success(), "git {args:?} failed");
1758 };
1759 run(&["-c", "init.defaultBranch=master", "init"]);
1760 run(&["config", "user.email", "t@t.com"]);
1761 run(&["config", "user.name", "t"]);
1762 std::fs::write(dir.join("a"), "1").unwrap();
1763 run(&["add", "."]);
1764 run(&["commit", "-m", "c1"]);
1765 let sha = get_head_commit_in(dir).unwrap();
1766 let branches = branches_containing_sha_in(dir, &sha).unwrap();
1770 assert!(branches.is_empty(), "no remote → no remote branches");
1771 }
1772
1773 #[test]
1774 fn branches_containing_sha_in_finds_remote_branch_after_push() {
1775 let tmp = tempfile::tempdir().unwrap();
1776 let bare = tempfile::tempdir().unwrap();
1777 let dir = tmp.path();
1778 let run_in = |cwd: &Path, args: &[&str]| {
1779 let out = anodizer_core::test_helpers::output_with_spawn_retry(
1780 || {
1781 let mut cmd = Command::new("git");
1782 cmd.args(args)
1783 .current_dir(cwd)
1784 .env("GIT_AUTHOR_NAME", "t")
1785 .env("GIT_AUTHOR_EMAIL", "t@t.com")
1786 .env("GIT_COMMITTER_NAME", "t")
1787 .env("GIT_COMMITTER_EMAIL", "t@t.com");
1788 cmd
1789 },
1790 "git",
1791 );
1792 assert!(out.status.success(), "git {args:?} failed");
1793 };
1794 run_in(
1795 bare.path(),
1796 &["-c", "init.defaultBranch=master", "init", "--bare"],
1797 );
1798 run_in(dir, &["-c", "init.defaultBranch=master", "init"]);
1799 run_in(dir, &["config", "user.email", "t@t.com"]);
1800 run_in(dir, &["config", "user.name", "t"]);
1801 run_in(
1802 dir,
1803 &["remote", "add", "origin", bare.path().to_str().unwrap()],
1804 );
1805 std::fs::write(dir.join("a"), "1").unwrap();
1806 run_in(dir, &["add", "."]);
1807 run_in(dir, &["commit", "-m", "c1"]);
1808 let sha = get_head_commit_in(dir).unwrap();
1809 run_in(dir, &["push", "-u", "origin", "master"]);
1810
1811 let branches = branches_containing_sha_in(dir, &sha).unwrap();
1812 assert_eq!(branches, vec!["master".to_string()]);
1813 }
1814
1815 #[test]
1816 fn stage_and_commit_in_returns_false_when_no_diff() {
1817 let tmp = tempfile::tempdir().unwrap();
1818 let dir = tmp.path();
1819 init_repo_with_commits(dir, &["a"]);
1820 let created = stage_and_commit_in(dir, &["a"], "chore: should be a no-op").unwrap();
1824 assert!(!created, "no diff → no commit should be created");
1825 let log = anodizer_core::test_helpers::output_with_spawn_retry(
1826 || {
1827 let mut cmd = Command::new("git");
1828 cmd.args(["log", "--oneline"]).current_dir(dir);
1829 cmd
1830 },
1831 "git",
1832 );
1833 let log_text = String::from_utf8_lossy(&log.stdout);
1834 assert!(
1835 !log_text.contains("should be a no-op"),
1836 "stage_and_commit_in must not create a commit when no diff: {log_text}"
1837 );
1838 }
1839
1840 #[test]
1841 fn stage_and_commit_in_returns_true_when_file_changed() {
1842 let tmp = tempfile::tempdir().unwrap();
1843 let dir = tmp.path();
1844 init_repo_with_commits(dir, &["a"]);
1845 std::fs::write(dir.join("a"), "changed").unwrap();
1846 let created = stage_and_commit_in(dir, &["a"], "chore: real change").unwrap();
1847 assert!(created, "real change → commit must be created");
1848 let log = anodizer_core::test_helpers::output_with_spawn_retry(
1849 || {
1850 let mut cmd = Command::new("git");
1851 cmd.args(["log", "-1", "--pretty=%s"]).current_dir(dir);
1852 cmd
1853 },
1854 "git",
1855 );
1856 let subject = String::from_utf8_lossy(&log.stdout).trim().to_string();
1857 assert_eq!(subject, "chore: real change");
1858 }
1859
1860 #[test]
1861 fn git_output_in_error_falls_back_to_stdout_when_stderr_empty() {
1862 let tmp = tempfile::tempdir().unwrap();
1863 let dir = tmp.path();
1864 init_repo_with_commits(dir, &["a"]);
1865 let err = git_output_in(dir, &["commit", "-m", "no-op"]).unwrap_err();
1869 let msg = err.to_string();
1870 assert!(
1871 msg.contains("nothing to commit") || msg.contains("clean"),
1872 "error must include stdout detail when stderr is empty: {msg}"
1873 );
1874 }
1875
1876 #[test]
1882 fn default_for_rollback_populates_both_name_and_email() {
1883 let id = CommitterIdentity::default_for_rollback();
1884 assert_eq!(id.name.as_deref(), Some("anodize-rollback"));
1885 let email = id.email.expect("email must be Some");
1886 assert!(
1887 email.starts_with("anodize-rollback@"),
1888 "email must use the anodize-rollback@<host> shape; got {email}"
1889 );
1890 assert!(!email.ends_with('@'), "host portion must not be empty");
1891 }
1892
1893 #[test]
1900 fn revert_commit_in_uses_injected_identity_envs() {
1901 let tmp = tempfile::tempdir().unwrap();
1902 let dir = tmp.path();
1903 let run_env = |args: &[&str], extra: &[(&str, &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", "bootstrap")
1910 .env("GIT_AUTHOR_EMAIL", "bootstrap@b.com")
1911 .env("GIT_COMMITTER_NAME", "bootstrap")
1912 .env("GIT_COMMITTER_EMAIL", "bootstrap@b.com");
1913 for (k, v) in extra {
1914 cmd.env(k, v);
1915 }
1916 cmd
1917 },
1918 "git",
1919 );
1920 assert!(
1921 out.status.success(),
1922 "git {args:?} failed: {}",
1923 String::from_utf8_lossy(&out.stderr)
1924 );
1925 };
1926 run_env(&["init", "-b", "master"], &[]);
1927 std::fs::write(dir.join("a"), "0").unwrap();
1928 run_env(&["add", "."], &[]);
1929 run_env(&["commit", "-m", "initial"], &[]);
1930 std::fs::write(dir.join("a"), "1").unwrap();
1931 run_env(&["add", "."], &[]);
1932 run_env(&["commit", "-m", "chore(release): v1.0.0"], &[]);
1933 let bump_sha = get_head_commit_in(dir).unwrap();
1934
1935 let identity = CommitterIdentity {
1939 name: Some("rollback-bot".to_string()),
1940 email: Some("rollback-bot@anodize.test".to_string()),
1941 };
1942 revert_commit_in(dir, &bump_sha, Some("chore(release): rollback"), &identity)
1943 .expect("revert with injected identity must succeed");
1944
1945 let out = anodizer_core::test_helpers::output_with_spawn_retry(
1948 || {
1949 let mut cmd = Command::new("git");
1950 cmd.current_dir(dir)
1951 .args(["log", "-1", "--format=%ae"])
1952 .env("GIT_TERMINAL_PROMPT", "0")
1953 .env("LC_ALL", "C");
1954 cmd
1955 },
1956 "git",
1957 );
1958 let author_email = String::from_utf8_lossy(&out.stdout).trim().to_string();
1959 assert_eq!(
1960 author_email, "rollback-bot@anodize.test",
1961 "revert commit must carry the injected committer identity"
1962 );
1963
1964 let cfg = anodizer_core::test_helpers::output_with_spawn_retry(
1967 || {
1968 let mut cmd = Command::new("git");
1969 cmd.current_dir(dir)
1970 .args(["config", "--local", "--get", "user.email"])
1971 .env("GIT_TERMINAL_PROMPT", "0")
1972 .env("LC_ALL", "C");
1973 cmd
1974 },
1975 "git",
1976 );
1977 assert!(
1978 !cfg.status.success() || cfg.stdout.is_empty(),
1979 "revert must not write user.email into the repo's local config; got: {}",
1980 String::from_utf8_lossy(&cfg.stdout)
1981 );
1982 }
1983
1984 #[test]
1989 fn revert_commit_in_aborts_on_conflict_and_leaves_tree_clean() {
1990 let tmp = tempfile::tempdir().unwrap();
1991 let dir = tmp.path();
1992 let run = |args: &[&str]| {
1993 let out = 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 assert!(
2007 out.status.success(),
2008 "git {args:?} failed: {}",
2009 String::from_utf8_lossy(&out.stderr)
2010 );
2011 };
2012 run(&["init", "-b", "master"]);
2013 run(&["config", "user.email", "t@t.com"]);
2014 run(&["config", "user.name", "t"]);
2015 std::fs::write(dir.join("x"), "v1\n").unwrap();
2017 run(&["add", "."]);
2018 run(&["commit", "-m", "initial"]);
2019 std::fs::write(dir.join("x"), "v2\n").unwrap();
2021 run(&["add", "."]);
2022 run(&["commit", "-m", "chore(release): v2"]);
2023 let bump_sha = get_head_commit_in(dir).unwrap();
2024 std::fs::write(dir.join("x"), "v3\n").unwrap();
2028 run(&["add", "."]);
2029 run(&["commit", "-m", "feat: overlap"]);
2030
2031 let identity = CommitterIdentity::default();
2032 let err = revert_commit_in(dir, &bump_sha, None, &identity)
2033 .expect_err("revert against overlapping HEAD must conflict and bail");
2034 let msg = format!("{err}");
2035 assert!(
2036 msg.contains("aborted"),
2037 "bail message must mention abort recovery: {msg}"
2038 );
2039
2040 assert!(
2044 !dir.join(".git/REVERT_HEAD").exists(),
2045 ".git/REVERT_HEAD must be cleaned up after --abort"
2046 );
2047 let status_out = anodizer_core::test_helpers::output_with_spawn_retry(
2048 || {
2049 let mut cmd = Command::new("git");
2050 cmd.args(["status", "--porcelain"]).current_dir(dir);
2051 cmd
2052 },
2053 "git",
2054 );
2055 assert!(
2056 status_out.stdout.is_empty(),
2057 "working tree must be clean after revert --abort; got:\n{}",
2058 String::from_utf8_lossy(&status_out.stdout)
2059 );
2060 }
2061
2062 #[test]
2067 fn commits_with_subjects_in_returns_all_pairs_in_one_call() {
2068 let tmp = tempfile::tempdir().unwrap();
2069 let dir = tmp.path();
2070 let run = |args: &[&str]| {
2071 let out = anodizer_core::test_helpers::output_with_spawn_retry(
2072 || {
2073 let mut cmd = Command::new("git");
2074 cmd.args(args)
2075 .current_dir(dir)
2076 .env("GIT_AUTHOR_NAME", "t")
2077 .env("GIT_AUTHOR_EMAIL", "t@t.com")
2078 .env("GIT_COMMITTER_NAME", "t")
2079 .env("GIT_COMMITTER_EMAIL", "t@t.com");
2080 cmd
2081 },
2082 "git",
2083 );
2084 assert!(out.status.success(), "git {args:?} failed");
2085 };
2086 run(&["init", "-b", "master"]);
2087 run(&["config", "user.email", "t@t.com"]);
2088 run(&["config", "user.name", "t"]);
2089 std::fs::write(dir.join("a"), "0").unwrap();
2090 run(&["add", "."]);
2091 run(&["commit", "-m", "initial"]);
2092 let base = get_head_commit_in(dir).unwrap();
2093 std::fs::write(dir.join("a"), "1").unwrap();
2094 run(&["add", "."]);
2095 run(&["commit", "-m", "feat: A with extra detail"]);
2096 std::fs::write(dir.join("a"), "2").unwrap();
2097 run(&["add", "."]);
2098 run(&["commit", "-m", "fix: B"]);
2099
2100 let pairs = commits_with_subjects_in(dir, &base).unwrap();
2101 assert_eq!(pairs.len(), 2, "two commits sit on top of base");
2102 assert_eq!(pairs[0].1, "fix: B");
2104 assert_eq!(pairs[1].1, "feat: A with extra detail");
2105
2106 let head = get_head_commit_in(dir).unwrap();
2108 assert!(commits_with_subjects_in(dir, &head).unwrap().is_empty());
2109 }
2110
2111 #[test]
2112 fn parse_commit_output_with_files_pairs_each_commit_with_its_files() {
2113 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";
2116 let parsed = parse_commit_output_with_files(raw);
2117 assert_eq!(parsed.len(), 2);
2118 assert_eq!(parsed[0].commit.message, "fix: B");
2119 assert_eq!(parsed[0].files, vec!["crates/cli/main.rs".to_string()]);
2120 assert_eq!(parsed[1].commit.message, "feat: A");
2121 assert_eq!(
2122 parsed[1].files,
2123 vec!["crates/core/lib.rs".to_string(), "Cargo.toml".to_string()]
2124 );
2125 }
2126
2127 #[test]
2128 fn parse_commit_output_with_files_preserves_multiline_body_at_idx_gt_0() {
2129 let body0 = "detail line one\ndetail line two\n\nCo-Authored-By: Bob <bob@b.com>";
2134 let raw = format!(
2135 "h1\x1fs1\x1ffix: B\x1ft\x1ft@t\x1f\x1e\ncrates/cli/main.rs\n\n\
2136 h0\x1fs0\x1ffeat: A\x1ft\x1ft@t\x1f{body0}\x1e\ncrates/core/lib.rs\n"
2137 );
2138 let parsed = parse_commit_output_with_files(&raw);
2139 assert_eq!(parsed.len(), 2);
2140 assert_eq!(parsed[1].commit.message, "feat: A");
2142 assert_eq!(parsed[1].commit.body, body0);
2143 assert!(
2144 parsed[1]
2145 .commit
2146 .body
2147 .contains("Co-Authored-By: Bob <bob@b.com>"),
2148 "multi-line body trailer dropped: {:?}",
2149 parsed[1].commit.body
2150 );
2151 assert_eq!(parsed[1].files, vec!["crates/core/lib.rs".to_string()]);
2152 }
2153
2154 #[test]
2155 fn get_commits_between_paths_with_files_in_reports_touched_files() {
2156 let tmp = tempfile::tempdir().unwrap();
2157 let dir = tmp.path();
2158 let run = |args: &[&str]| {
2159 assert!(
2160 anodizer_core::test_helpers::output_with_spawn_retry(
2161 || {
2162 let mut cmd = Command::new("git");
2163 cmd.args(args)
2164 .current_dir(dir)
2165 .env("GIT_AUTHOR_NAME", "t")
2166 .env("GIT_AUTHOR_EMAIL", "t@t.com")
2167 .env("GIT_COMMITTER_NAME", "t")
2168 .env("GIT_COMMITTER_EMAIL", "t@t.com");
2169 cmd
2170 },
2171 "git",
2172 )
2173 .status
2174 .success()
2175 );
2176 };
2177 run(&["init"]);
2178 run(&["config", "user.email", "t@t.com"]);
2179 run(&["config", "user.name", "t"]);
2180 std::fs::write(dir.join("base"), "0").unwrap();
2181 run(&["add", "."]);
2182 run(&["commit", "-m", "initial"]);
2183 let base = get_head_commit_in(dir).unwrap();
2184 std::fs::create_dir_all(dir.join("crates/core")).unwrap();
2185 std::fs::write(dir.join("crates/core/lib.rs"), "1").unwrap();
2186 run(&["add", "."]);
2187 run(&["commit", "-m", "feat: core"]);
2188
2189 let pairs = get_commits_between_paths_with_files_in(dir, &base, "HEAD", &[]).unwrap();
2190 assert_eq!(pairs.len(), 1);
2191 assert_eq!(pairs[0].commit.message, "feat: core");
2192 assert_eq!(pairs[0].files, vec!["crates/core/lib.rs".to_string()]);
2193 }
2194
2195 #[test]
2196 fn get_commits_between_paths_with_files_in_preserves_multiline_body_for_later_commits() {
2197 let tmp = tempfile::tempdir().unwrap();
2203 let dir = tmp.path();
2204 let run = |args: &[&str]| {
2205 assert!(
2206 anodizer_core::test_helpers::output_with_spawn_retry(
2207 || {
2208 let mut cmd = Command::new("git");
2209 cmd.args(args)
2210 .current_dir(dir)
2211 .env("GIT_AUTHOR_NAME", "t")
2212 .env("GIT_AUTHOR_EMAIL", "t@t.com")
2213 .env("GIT_COMMITTER_NAME", "t")
2214 .env("GIT_COMMITTER_EMAIL", "t@t.com");
2215 cmd
2216 },
2217 "git",
2218 )
2219 .status
2220 .success()
2221 );
2222 };
2223 run(&["init"]);
2224 run(&["config", "user.email", "t@t.com"]);
2225 run(&["config", "user.name", "t"]);
2226 std::fs::write(dir.join("base"), "0").unwrap();
2227 run(&["add", "."]);
2228 run(&["commit", "-m", "initial"]);
2229 let base = get_head_commit_in(dir).unwrap();
2230
2231 std::fs::write(dir.join("a.rs"), "1").unwrap();
2233 run(&["add", "."]);
2234 run(&[
2235 "commit",
2236 "-m",
2237 "feat: with body\n\nfirst body line\nsecond body line\n\nCo-Authored-By: Bob <bob@b.com>",
2238 ]);
2239 std::fs::write(dir.join("b.rs"), "2").unwrap();
2241 run(&["add", "."]);
2242 run(&["commit", "-m", "fix: later"]);
2243
2244 let pairs = get_commits_between_paths_with_files_in(dir, &base, "HEAD", &[]).unwrap();
2245 assert_eq!(pairs.len(), 2);
2246 assert_eq!(pairs[0].commit.message, "fix: later");
2248 let body = &pairs[1].commit.body;
2249 assert!(
2250 body.contains("first body line") && body.contains("second body line"),
2251 "multi-line body truncated for idx>0 commit: {body:?}"
2252 );
2253 assert!(
2254 body.contains("Co-Authored-By: Bob <bob@b.com>"),
2255 "Co-Authored-By trailer dropped for idx>0 commit: {body:?}"
2256 );
2257 }
2258
2259 #[test]
2262 fn parse_commit_output_empty_input_yields_no_commits() {
2263 assert!(parse_commit_output("").is_empty());
2264 }
2265
2266 #[test]
2267 fn parse_commit_output_decodes_all_six_fields() {
2268 let raw =
2270 "abc123def\x1fabc123d\x1ffeat: add thing\x1fAlice\x1falice@x.com\x1fbody text\x1e";
2271 let commits = parse_commit_output(raw);
2272 assert_eq!(commits.len(), 1);
2273 let c = &commits[0];
2274 assert_eq!(c.hash, "abc123def");
2275 assert_eq!(c.short_hash, "abc123d");
2276 assert_eq!(c.message, "feat: add thing");
2277 assert_eq!(c.author_name, "Alice");
2278 assert_eq!(c.author_email, "alice@x.com");
2279 assert_eq!(c.body, "body text");
2280 }
2281
2282 #[test]
2283 fn parse_commit_output_trims_hash_and_body_but_keeps_inner_subject() {
2284 let raw = " abc \x1fabc\x1ffix: keep spaces\x1ft\x1ft@t\x1f\n\nbody\n\x1e";
2287 let commits = parse_commit_output(raw);
2288 assert_eq!(commits.len(), 1);
2289 assert_eq!(commits[0].hash, "abc", "hash is trimmed");
2290 assert_eq!(commits[0].message, "fix: keep spaces", "subject verbatim");
2291 assert_eq!(commits[0].body, "body", "body is trimmed");
2292 }
2293
2294 #[test]
2295 fn parse_commit_output_absent_body_field_defaults_to_empty() {
2296 let raw = "h\x1fh\x1fsubject\x1fname\x1fmail\x1e";
2298 let commits = parse_commit_output(raw);
2299 assert_eq!(commits.len(), 1);
2300 assert_eq!(commits[0].body, "");
2301 assert_eq!(commits[0].message, "subject");
2302 }
2303
2304 #[test]
2305 fn parse_commit_output_skips_records_with_too_few_fields() {
2306 let raw = "only\x1ftwo\x1e\
2309 h\x1fh\x1fgood: subject\x1fn\x1fe\x1fbody\x1e";
2310 let commits = parse_commit_output(raw);
2311 assert_eq!(commits.len(), 1, "malformed record dropped, good one kept");
2312 assert_eq!(commits[0].message, "good: subject");
2313 }
2314
2315 #[test]
2316 fn parse_commit_output_multiline_body_survives_record_separator_split() {
2317 let raw = "h1\x1fh1\x1ffeat: A\x1fA\x1fa@x\x1fline one\nline two\n\nCo-Authored-By: B <b@x>\x1e\
2320 h0\x1fh0\x1ffix: B\x1fB\x1fb@x\x1f\x1e";
2321 let commits = parse_commit_output(raw);
2322 assert_eq!(commits.len(), 2);
2323 assert_eq!(commits[0].message, "feat: A");
2324 assert!(commits[0].body.contains("line one\nline two"));
2325 assert!(commits[0].body.contains("Co-Authored-By: B <b@x>"));
2326 assert_eq!(commits[1].message, "fix: B");
2327 assert_eq!(commits[1].body, "");
2328 }
2329
2330 #[test]
2333 fn short_commit_str_truncates_long_sha_to_seven() {
2334 assert_eq!(short_commit_str("abcdef0123456789"), "abcdef0");
2335 assert_eq!(short_commit_str("abcdef0123456789").len(), SHORT_COMMIT_LEN);
2336 }
2337
2338 #[test]
2339 fn short_commit_str_returns_shorter_or_equal_input_unchanged() {
2340 assert_eq!(short_commit_str("abc"), "abc", "shorter than 7 unchanged");
2341 assert_eq!(
2342 short_commit_str("abcdefg"),
2343 "abcdefg",
2344 "exactly 7 unchanged"
2345 );
2346 assert_eq!(short_commit_str(""), "", "empty stays empty");
2347 }
2348
2349 fn g(dir: &Path, args: &[&str]) {
2353 let out = anodizer_core::test_helpers::output_with_spawn_retry(
2354 || {
2355 let mut cmd = Command::new("git");
2356 cmd.args(args)
2357 .current_dir(dir)
2358 .env("GIT_AUTHOR_NAME", "Ada")
2359 .env("GIT_AUTHOR_EMAIL", "ada@x.com")
2360 .env("GIT_COMMITTER_NAME", "Ada")
2361 .env("GIT_COMMITTER_EMAIL", "ada@x.com")
2362 .env("GIT_AUTHOR_DATE", "1715000000 +0000")
2363 .env("GIT_COMMITTER_DATE", "1715000000 +0000");
2364 cmd
2365 },
2366 "git",
2367 );
2368 assert!(
2369 out.status.success(),
2370 "git {args:?} failed: {}",
2371 String::from_utf8_lossy(&out.stderr)
2372 );
2373 }
2374
2375 fn init_bare_repo(dir: &Path) {
2377 g(dir, &["init", "-b", "master"]);
2378 g(dir, &["config", "user.email", "ada@x.com"]);
2379 g(dir, &["config", "user.name", "Ada"]);
2380 }
2381
2382 fn commit_file(dir: &Path, path: &str, content: &str, subject: &str) {
2384 let full = dir.join(path);
2385 if let Some(parent) = full.parent() {
2386 std::fs::create_dir_all(parent).unwrap();
2387 }
2388 std::fs::write(full, content).unwrap();
2389 g(dir, &["add", "."]);
2390 g(dir, &["commit", "-m", subject]);
2391 }
2392
2393 #[test]
2396 fn get_commits_between_in_returns_only_post_base_commits() {
2397 let tmp = tempfile::tempdir().unwrap();
2398 let dir = tmp.path();
2399 init_bare_repo(dir);
2400 commit_file(dir, "a", "0", "initial");
2401 let base = get_head_commit_in(dir).unwrap();
2402 commit_file(dir, "a", "1", "feat: one");
2403 commit_file(dir, "a", "2", "fix: two");
2404
2405 let commits = get_commits_between_in(dir, &base, "HEAD", None).unwrap();
2406 assert_eq!(commits.len(), 2, "two commits sit above base");
2407 assert_eq!(commits[0].message, "fix: two");
2409 assert_eq!(commits[1].message, "feat: one");
2410 assert_eq!(commits[1].author_name, "Ada");
2411 assert_eq!(commits[1].author_email, "ada@x.com");
2412 }
2413
2414 #[test]
2415 fn get_commits_between_in_path_filter_excludes_untouched_files() {
2416 let tmp = tempfile::tempdir().unwrap();
2417 let dir = tmp.path();
2418 init_bare_repo(dir);
2419 commit_file(dir, "base", "0", "initial");
2420 let base = get_head_commit_in(dir).unwrap();
2421 commit_file(dir, "src/lib.rs", "1", "feat: touch lib");
2422 commit_file(dir, "docs/readme", "2", "docs: touch docs only");
2423
2424 let commits = get_commits_between_in(dir, &base, "HEAD", Some("src")).unwrap();
2426 assert_eq!(commits.len(), 1, "only the src-touching commit survives");
2427 assert_eq!(commits[0].message, "feat: touch lib");
2428 }
2429
2430 #[test]
2431 fn get_commits_between_paths_in_unions_multiple_paths() {
2432 let tmp = tempfile::tempdir().unwrap();
2433 let dir = tmp.path();
2434 init_bare_repo(dir);
2435 commit_file(dir, "base", "0", "initial");
2436 let base = get_head_commit_in(dir).unwrap();
2437 commit_file(dir, "a/x", "1", "feat: a");
2438 commit_file(dir, "b/y", "2", "feat: b");
2439 commit_file(dir, "c/z", "3", "feat: c");
2440
2441 let commits =
2443 get_commits_between_paths_in(dir, &base, "HEAD", &["a".into(), "b".into()]).unwrap();
2444 let subjects: Vec<&str> = commits.iter().map(|c| c.message.as_str()).collect();
2445 assert_eq!(
2446 commits.len(),
2447 2,
2448 "a and b touched, c excluded: {subjects:?}"
2449 );
2450 assert!(subjects.contains(&"feat: a"));
2451 assert!(subjects.contains(&"feat: b"));
2452 assert!(!subjects.contains(&"feat: c"));
2453 }
2454
2455 #[test]
2458 fn get_all_commits_in_returns_every_commit_on_head() {
2459 let tmp = tempfile::tempdir().unwrap();
2460 let dir = tmp.path();
2461 init_bare_repo(dir);
2462 commit_file(dir, "a", "0", "first");
2463 commit_file(dir, "a", "1", "second");
2464 commit_file(dir, "a", "2", "third");
2465
2466 let commits = get_all_commits_in(dir, None).unwrap();
2467 assert_eq!(commits.len(), 3);
2468 assert_eq!(commits[0].message, "third", "newest-first");
2469 assert_eq!(commits[2].message, "first");
2470 }
2471
2472 #[test]
2473 fn get_all_commits_paths_in_filters_to_path() {
2474 let tmp = tempfile::tempdir().unwrap();
2475 let dir = tmp.path();
2476 init_bare_repo(dir);
2477 commit_file(dir, "keep/x", "0", "feat: keep");
2478 commit_file(dir, "drop/y", "1", "feat: drop");
2479
2480 let commits = get_all_commits_paths_in(dir, &["keep".into()]).unwrap();
2481 assert_eq!(commits.len(), 1);
2482 assert_eq!(commits[0].message, "feat: keep");
2483 }
2484
2485 #[test]
2486 fn get_all_commits_paths_with_files_in_pairs_files() {
2487 let tmp = tempfile::tempdir().unwrap();
2488 let dir = tmp.path();
2489 init_bare_repo(dir);
2490 commit_file(dir, "crates/core/lib.rs", "0", "feat: core");
2491
2492 let pairs = get_all_commits_paths_with_files_in(dir, &[]).unwrap();
2493 assert_eq!(pairs.len(), 1);
2494 assert_eq!(pairs[0].commit.message, "feat: core");
2495 assert_eq!(pairs[0].files, vec!["crates/core/lib.rs".to_string()]);
2496 }
2497
2498 #[test]
2501 fn get_commits_reachable_paths_in_stops_at_the_given_rev() {
2502 let tmp = tempfile::tempdir().unwrap();
2503 let dir = tmp.path();
2504 init_bare_repo(dir);
2505 commit_file(dir, "a", "0", "first");
2506 commit_file(dir, "a", "1", "second");
2507 let mid = get_head_commit_in(dir).unwrap();
2508 commit_file(dir, "a", "2", "third-after-mid");
2509
2510 let commits = get_commits_reachable_paths_in(dir, &mid, &[]).unwrap();
2512 let subjects: Vec<&str> = commits.iter().map(|c| c.message.as_str()).collect();
2513 assert_eq!(commits.len(), 2, "only ancestors of mid: {subjects:?}");
2514 assert!(subjects.contains(&"first"));
2515 assert!(subjects.contains(&"second"));
2516 assert!(!subjects.contains(&"third-after-mid"));
2517 }
2518
2519 #[test]
2520 fn get_commits_reachable_paths_with_files_in_pairs_touched_files() {
2521 let tmp = tempfile::tempdir().unwrap();
2522 let dir = tmp.path();
2523 init_bare_repo(dir);
2524 commit_file(dir, "src/main.rs", "0", "feat: main");
2525 let head = get_head_commit_in(dir).unwrap();
2526
2527 let pairs = get_commits_reachable_paths_with_files_in(dir, &head, &[]).unwrap();
2528 assert_eq!(pairs.len(), 1);
2529 assert_eq!(pairs[0].commit.message, "feat: main");
2530 assert_eq!(pairs[0].files, vec!["src/main.rs".to_string()]);
2531 }
2532
2533 #[test]
2536 fn get_last_commit_messages_in_returns_n_subjects_newest_first() {
2537 let tmp = tempfile::tempdir().unwrap();
2538 let dir = tmp.path();
2539 init_bare_repo(dir);
2540 commit_file(dir, "a", "0", "one");
2541 commit_file(dir, "a", "1", "two");
2542 commit_file(dir, "a", "2", "three");
2543
2544 let msgs = get_last_commit_messages_in(dir, 2).unwrap();
2545 assert_eq!(msgs, vec!["three".to_string(), "two".to_string()]);
2546 }
2547
2548 #[test]
2549 fn get_commit_messages_between_in_lists_post_base_subjects() {
2550 let tmp = tempfile::tempdir().unwrap();
2551 let dir = tmp.path();
2552 init_bare_repo(dir);
2553 commit_file(dir, "a", "0", "initial");
2554 let base = get_head_commit_in(dir).unwrap();
2555 commit_file(dir, "a", "1", "feat: x");
2556 commit_file(dir, "a", "2", "fix: y");
2557
2558 let msgs = get_commit_messages_between_in(dir, &base, "HEAD").unwrap();
2559 assert_eq!(msgs, vec!["fix: y".to_string(), "feat: x".to_string()]);
2560 }
2561
2562 #[test]
2563 fn get_last_commit_messages_path_in_filters_to_path() {
2564 let tmp = tempfile::tempdir().unwrap();
2565 let dir = tmp.path();
2566 init_bare_repo(dir);
2567 commit_file(dir, "keep/a", "0", "feat: keep");
2568 commit_file(dir, "other/b", "1", "feat: other");
2569
2570 let msgs = get_last_commit_messages_path_in(dir, 10, "keep").unwrap();
2571 assert_eq!(msgs, vec!["feat: keep".to_string()]);
2572 }
2573
2574 #[test]
2575 fn get_commit_messages_between_path_in_filters_range_and_path() {
2576 let tmp = tempfile::tempdir().unwrap();
2577 let dir = tmp.path();
2578 init_bare_repo(dir);
2579 commit_file(dir, "base", "0", "initial");
2580 let base = get_head_commit_in(dir).unwrap();
2581 commit_file(dir, "src/x", "1", "feat: src");
2582 commit_file(dir, "doc/y", "2", "docs: doc");
2583
2584 let msgs = get_commit_messages_between_path_in(dir, &base, "HEAD", "src").unwrap();
2585 assert_eq!(msgs, vec!["feat: src".to_string()]);
2586 }
2587
2588 #[test]
2591 fn has_changes_since_in_detects_path_touched_after_tag() {
2592 let tmp = tempfile::tempdir().unwrap();
2593 let dir = tmp.path();
2594 init_bare_repo(dir);
2595 commit_file(dir, "watched", "0", "initial");
2596 g(dir, &["tag", "v1.0.0"]);
2597 assert!(!has_changes_since_in(dir, "v1.0.0", "watched").unwrap());
2599 commit_file(dir, "watched", "1", "feat: change watched");
2600 assert!(has_changes_since_in(dir, "v1.0.0", "watched").unwrap());
2602 assert!(!has_changes_since_in(dir, "v1.0.0", "unrelated").unwrap());
2604 }
2605
2606 #[test]
2607 fn paths_changed_since_tag_in_true_when_any_path_changed() {
2608 let tmp = tempfile::tempdir().unwrap();
2609 let dir = tmp.path();
2610 init_bare_repo(dir);
2611 commit_file(dir, "a", "0", "initial");
2612 g(dir, &["tag", "v1.0.0"]);
2613 commit_file(dir, "b", "1", "feat: add b");
2614
2615 assert!(paths_changed_since_tag_in(dir, "v1.0.0", &["a", "b"]).unwrap());
2617 assert!(!paths_changed_since_tag_in(dir, "v1.0.0", &["a"]).unwrap());
2619 }
2620
2621 #[test]
2622 fn paths_changed_since_tag_in_returns_false_when_git_fails() {
2623 let tmp = tempfile::tempdir().unwrap();
2626 let dir = tmp.path();
2627 init_bare_repo(dir);
2628 commit_file(dir, "a", "0", "initial");
2629 assert!(!paths_changed_since_tag_in(dir, "nope-no-such-tag", &["a"]).unwrap());
2630 }
2631
2632 #[test]
2635 fn head_commit_hash_in_matches_rev_parse_head() {
2636 let tmp = tempfile::tempdir().unwrap();
2637 let dir = tmp.path();
2638 init_bare_repo(dir);
2639 commit_file(dir, "a", "0", "initial");
2640 let expected = get_head_commit_in(dir).unwrap();
2641 assert_eq!(head_commit_hash_in(dir).unwrap(), expected);
2642 }
2643
2644 #[test]
2645 fn head_commit_hash_in_errors_on_non_repo() {
2646 let tmp = tempfile::tempdir().unwrap();
2647 assert!(head_commit_hash_in(tmp.path()).is_err());
2649 }
2650
2651 #[test]
2652 fn rev_parse_in_resolves_branch_to_full_sha() {
2653 let tmp = tempfile::tempdir().unwrap();
2654 let dir = tmp.path();
2655 init_bare_repo(dir);
2656 commit_file(dir, "a", "0", "initial");
2657 let head = get_head_commit_in(dir).unwrap();
2658 assert_eq!(rev_parse_in(dir, "master").unwrap(), head);
2659 }
2660
2661 #[test]
2662 fn rev_verify_commit_in_accepts_commit_rejects_unknown() {
2663 let tmp = tempfile::tempdir().unwrap();
2664 let dir = tmp.path();
2665 init_bare_repo(dir);
2666 commit_file(dir, "a", "0", "initial");
2667 let head = get_head_commit_in(dir).unwrap();
2668 assert_eq!(rev_verify_commit_in(dir, "HEAD").unwrap(), head);
2669 assert!(rev_verify_commit_in(dir, "deadbeefdeadbeef").is_err());
2671 }
2672
2673 #[test]
2674 fn commits_between_in_lists_shas_above_base_and_empty_at_head() {
2675 let tmp = tempfile::tempdir().unwrap();
2676 let dir = tmp.path();
2677 init_bare_repo(dir);
2678 commit_file(dir, "a", "0", "initial");
2679 let base = get_head_commit_in(dir).unwrap();
2680 commit_file(dir, "a", "1", "second");
2681 let head = get_head_commit_in(dir).unwrap();
2682
2683 let shas = commits_between_in(dir, &base).unwrap();
2684 assert_eq!(
2685 shas,
2686 vec![head.clone()],
2687 "exactly the one commit above base"
2688 );
2689 assert!(commits_between_in(dir, &head).unwrap().is_empty());
2691 }
2692
2693 #[test]
2694 fn commit_subject_in_returns_single_commit_subject() {
2695 let tmp = tempfile::tempdir().unwrap();
2696 let dir = tmp.path();
2697 init_bare_repo(dir);
2698 commit_file(dir, "a", "0", "feat: only-subject\n\nignored body");
2699 let head = get_head_commit_in(dir).unwrap();
2700 assert_eq!(commit_subject_in(dir, &head).unwrap(), "feat: only-subject");
2701 }
2702
2703 #[test]
2704 fn head_commit_timestamp_in_returns_pinned_committer_epoch() {
2705 let tmp = tempfile::tempdir().unwrap();
2706 let dir = tmp.path();
2707 init_bare_repo(dir);
2708 commit_file(dir, "a", "0", "initial");
2710 assert_eq!(head_commit_timestamp_in(dir).unwrap(), 1_715_000_000);
2711 }
2712
2713 #[test]
2716 fn log_subjects_for_range_returns_full_bodies_for_path() {
2717 let tmp = tempfile::tempdir().unwrap();
2718 let dir = tmp.path();
2719 init_bare_repo(dir);
2720 commit_file(dir, "watched", "0", "feat: A\n\nbody of A");
2721 commit_file(dir, "watched", "1", "fix: B");
2722
2723 let bodies = log_subjects_for_range(dir, "HEAD", "watched").unwrap();
2724 assert_eq!(bodies.len(), 2);
2725 assert!(bodies[0].starts_with("fix: B"));
2727 assert!(bodies[1].contains("feat: A") && bodies[1].contains("body of A"));
2728 }
2729
2730 #[test]
2731 fn log_subjects_for_range_returns_empty_when_range_invalid() {
2732 let tmp = tempfile::tempdir().unwrap();
2733 let dir = tmp.path();
2734 init_bare_repo(dir);
2735 commit_file(dir, "a", "0", "initial");
2736 let bodies = log_subjects_for_range(dir, "no-such-ref..HEAD", "a").unwrap();
2739 assert!(bodies.is_empty());
2740 }
2741
2742 #[test]
2746 fn log_subjects_for_range_propagates_pathspec_fatal() {
2747 let tmp = tempfile::tempdir().unwrap();
2748 let dir = tmp.path();
2749 init_bare_repo(dir);
2750 commit_file(dir, "a", "0", "initial");
2751 let err = log_subjects_for_range(dir, "HEAD", "")
2752 .expect_err("empty pathspec must be an error, not empty success")
2753 .to_string();
2754 assert!(err.contains("git log HEAD failed"), "{err}");
2755 }
2756
2757 #[test]
2760 fn add_path_in_then_commit_in_creates_commit() {
2761 let tmp = tempfile::tempdir().unwrap();
2762 let dir = tmp.path();
2763 init_bare_repo(dir);
2764 commit_file(dir, "seed", "0", "initial");
2765 std::fs::write(dir.join("new.txt"), "hello").unwrap();
2766
2767 add_path_in(dir, std::path::Path::new("new.txt")).unwrap();
2768 commit_in(dir, "feat: add new.txt", false).unwrap();
2769
2770 let subject = String::from_utf8(
2771 anodizer_core::test_helpers::output_with_spawn_retry(
2772 || {
2773 let mut cmd = Command::new("git");
2774 cmd.args(["log", "-1", "--pretty=%s"]).current_dir(dir);
2775 cmd
2776 },
2777 "git",
2778 )
2779 .stdout,
2780 )
2781 .unwrap()
2782 .trim()
2783 .to_string();
2784 assert_eq!(subject, "feat: add new.txt");
2785 }
2786
2787 #[test]
2788 fn add_path_in_errors_on_missing_file() {
2789 let tmp = tempfile::tempdir().unwrap();
2790 let dir = tmp.path();
2791 init_bare_repo(dir);
2792 let err = add_path_in(dir, std::path::Path::new("does-not-exist")).unwrap_err();
2793 assert!(
2794 err.to_string().contains("git add"),
2795 "error must name the failing git add: {err}"
2796 );
2797 }
2798
2799 #[test]
2802 fn reset_hard_in_moves_head_and_restores_tree() {
2803 let tmp = tempfile::tempdir().unwrap();
2804 let dir = tmp.path();
2805 init_bare_repo(dir);
2806 commit_file(dir, "a", "first", "first");
2807 let target = get_head_commit_in(dir).unwrap();
2808 commit_file(dir, "a", "second", "second");
2809 assert_ne!(get_head_commit_in(dir).unwrap(), target);
2810
2811 reset_hard_in(dir, &target).unwrap();
2812 assert_eq!(get_head_commit_in(dir).unwrap(), target, "HEAD moved back");
2813 assert_eq!(
2814 std::fs::read_to_string(dir.join("a")).unwrap(),
2815 "first",
2816 "working tree restored to target content"
2817 );
2818 }
2819
2820 #[test]
2823 fn push_branch_in_bails_without_origin_remote() {
2824 let tmp = tempfile::tempdir().unwrap();
2825 let dir = tmp.path();
2826 init_bare_repo(dir);
2827 commit_file(dir, "a", "0", "initial");
2828 let err = push_branch_in(dir, "master").unwrap_err();
2829 assert!(
2830 err.to_string().contains("no 'origin' remote"),
2831 "missing-remote bail must be explicit: {err}"
2832 );
2833 }
2834
2835 #[test]
2838 #[serial_test::serial(git_env)]
2839 fn resolve_rollback_identity_inherits_when_repo_has_identity() {
2840 let tmp = tempfile::tempdir().unwrap();
2841 let dir = tmp.path();
2842 init_bare_repo(dir); struct EnvGuard(Vec<(&'static str, Option<String>)>);
2847 impl Drop for EnvGuard {
2848 fn drop(&mut self) {
2849 for (k, v) in &self.0 {
2850 match v {
2851 Some(val) => unsafe { std::env::set_var(k, val) },
2853 None => unsafe { std::env::remove_var(k) },
2855 }
2856 }
2857 }
2858 }
2859 let keys = [
2860 "GIT_AUTHOR_NAME",
2861 "GIT_AUTHOR_EMAIL",
2862 "GIT_COMMITTER_NAME",
2863 "GIT_COMMITTER_EMAIL",
2864 ];
2865 let _g = EnvGuard(keys.iter().map(|k| (*k, std::env::var(k).ok())).collect());
2866 for k in keys {
2867 unsafe { std::env::remove_var(k) };
2869 }
2870
2871 let id = resolve_rollback_identity(dir);
2873 assert!(
2874 id.name.is_none() && id.email.is_none(),
2875 "configured repo identity must be inherited, not overridden: {id:?}"
2876 );
2877 }
2878
2879 #[test]
2880 #[serial_test::serial(git_env)]
2881 fn resolve_rollback_identity_synthesizes_when_no_identity_anywhere() {
2882 let tmp = tempfile::tempdir().unwrap();
2883 let dir = tmp.path();
2884 g(dir, &["init", "-b", "master"]);
2886
2887 struct EnvGuard(Vec<(&'static str, Option<String>)>);
2888 impl Drop for EnvGuard {
2889 fn drop(&mut self) {
2890 for (k, v) in &self.0 {
2891 match v {
2892 Some(val) => unsafe { std::env::set_var(k, val) },
2894 None => unsafe { std::env::remove_var(k) },
2896 }
2897 }
2898 }
2899 }
2900 let keys = [
2901 "GIT_AUTHOR_NAME",
2902 "GIT_AUTHOR_EMAIL",
2903 "GIT_COMMITTER_NAME",
2904 "GIT_COMMITTER_EMAIL",
2905 ];
2906 let _g = EnvGuard(keys.iter().map(|k| (*k, std::env::var(k).ok())).collect());
2907 for k in keys {
2908 unsafe { std::env::remove_var(k) };
2910 }
2911
2912 let (n, e) = read_git_identity(dir);
2915 if n.is_none() || e.is_none() {
2916 let id = resolve_rollback_identity(dir);
2917 assert_eq!(id.name.as_deref(), Some("anodize-rollback"));
2918 assert!(
2919 id.email
2920 .as_deref()
2921 .unwrap_or("")
2922 .starts_with("anodize-rollback@"),
2923 "synthetic identity required when no config present: {id:?}"
2924 );
2925 }
2926 }
2927
2928 #[test]
2929 fn read_git_identity_reads_configured_values() {
2930 let tmp = tempfile::tempdir().unwrap();
2931 let dir = tmp.path();
2932 g(dir, &["init", "-b", "master"]);
2933 g(dir, &["config", "user.name", "Configured Name"]);
2934 g(dir, &["config", "user.email", "configured@x.com"]);
2935
2936 let (name, email) = read_git_identity(dir);
2937 assert_eq!(name.as_deref(), Some("Configured Name"));
2938 assert_eq!(email.as_deref(), Some("configured@x.com"));
2939 }
2940}