1use anyhow::{Context as _, Result, bail};
2use std::path::{Path, PathBuf};
3use std::process::Command;
4
5use super::git_output_in;
6
7#[derive(Debug, Clone)]
8pub struct Commit {
9 pub hash: String,
10 pub short_hash: String,
11 pub message: String,
12 pub author_name: String,
13 pub author_email: String,
14 pub body: String,
17}
18
19pub fn parse_commit_output(output: &str) -> Vec<Commit> {
30 if output.is_empty() {
31 return vec![];
32 }
33 output
34 .split('\x1e')
35 .filter(|record| !record.trim().is_empty())
36 .filter_map(|record| {
37 let fields: Vec<&str> = record.split('\x1f').collect();
38 if fields.len() >= 5 {
39 Some(Commit {
40 hash: fields[0].trim().to_string(),
41 short_hash: fields[1].to_string(),
42 message: fields[2].to_string(),
43 author_name: fields[3].to_string(),
44 author_email: fields[4].to_string(),
45 body: fields.get(5).unwrap_or(&"").trim().to_string(),
46 })
47 } else {
48 None
49 }
50 })
51 .collect()
52}
53
54fn cwd_or_dot() -> PathBuf {
55 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
56}
57
58pub fn get_commits_between(from: &str, to: &str, path_filter: Option<&str>) -> Result<Vec<Commit>> {
60 get_commits_between_in(&cwd_or_dot(), from, to, path_filter)
61}
62
63pub fn get_commits_between_in(
65 cwd: &Path,
66 from: &str,
67 to: &str,
68 path_filter: Option<&str>,
69) -> Result<Vec<Commit>> {
70 get_commits_between_paths_in(
71 cwd,
72 from,
73 to,
74 &path_filter
75 .into_iter()
76 .map(String::from)
77 .collect::<Vec<_>>(),
78 )
79}
80
81pub fn get_commits_between_paths(from: &str, to: &str, paths: &[String]) -> Result<Vec<Commit>> {
83 get_commits_between_paths_in(&cwd_or_dot(), from, to, paths)
84}
85
86pub fn get_commits_between_paths_in(
88 cwd: &Path,
89 from: &str,
90 to: &str,
91 paths: &[String],
92) -> Result<Vec<Commit>> {
93 let range = format!("{}..{}", from, to);
94 let mut args = vec![
95 "-c".to_string(),
96 "log.showSignature=false".to_string(),
97 "log".to_string(),
98 "--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e".to_string(),
99 range,
100 ];
101 if !paths.is_empty() {
102 args.push("--".to_string());
103 for p in paths {
104 args.push(p.clone());
105 }
106 }
107 let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
108 let output = git_output_in(cwd, &arg_refs)?;
109 Ok(parse_commit_output(&output))
110}
111
112pub fn get_all_commits(path_filter: Option<&str>) -> Result<Vec<Commit>> {
115 get_all_commits_in(&cwd_or_dot(), path_filter)
116}
117
118pub fn get_all_commits_in(cwd: &Path, path_filter: Option<&str>) -> Result<Vec<Commit>> {
120 get_all_commits_paths_in(
121 cwd,
122 &path_filter
123 .into_iter()
124 .map(String::from)
125 .collect::<Vec<_>>(),
126 )
127}
128
129pub fn get_all_commits_paths(paths: &[String]) -> Result<Vec<Commit>> {
131 get_all_commits_paths_in(&cwd_or_dot(), paths)
132}
133
134pub fn get_all_commits_paths_in(cwd: &Path, paths: &[String]) -> Result<Vec<Commit>> {
136 let mut args = vec![
137 "-c".to_string(),
138 "log.showSignature=false".to_string(),
139 "log".to_string(),
140 "--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e".to_string(),
141 "HEAD".to_string(),
142 ];
143 if !paths.is_empty() {
144 args.push("--".to_string());
145 for p in paths {
146 args.push(p.clone());
147 }
148 }
149 let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
150 let output = git_output_in(cwd, &arg_refs)?;
151 Ok(parse_commit_output(&output))
152}
153
154#[derive(Debug, Clone)]
160pub struct CommitWithFiles {
161 pub commit: Commit,
163 pub files: Vec<String>,
165}
166
167pub fn parse_commit_output_with_files(output: &str) -> Vec<CommitWithFiles> {
184 if output.is_empty() {
185 return vec![];
186 }
187 let segments: Vec<&str> = output.split('\x1e').collect();
188 let mut out: Vec<CommitWithFiles> = Vec::new();
189 for (idx, seg) in segments.iter().enumerate() {
195 let metadata = if idx == 0 {
203 seg.trim_start_matches(['\n', '\r']).to_string()
204 } else {
205 let lines: Vec<&str> = seg.split('\n').collect();
206 match lines.iter().position(|line| line.contains('\x1f')) {
207 Some(start) => lines[start..].join("\n"),
208 None => String::new(),
209 }
210 };
211 if metadata.trim().is_empty() {
212 continue;
213 }
214 let commits = parse_commit_output(&metadata);
215 let Some(commit) = commits.into_iter().next() else {
216 continue;
217 };
218 let files = match segments.get(idx + 1) {
221 Some(next) => next
222 .split('\n')
223 .map(str::trim)
224 .take_while(|line| !line.contains('\x1f'))
225 .filter(|line| !line.is_empty())
226 .map(str::to_string)
227 .collect(),
228 None => Vec::new(),
229 };
230 out.push(CommitWithFiles { commit, files });
231 }
232 out
233}
234
235pub fn get_commits_between_paths_with_files_in(
239 cwd: &Path,
240 from: &str,
241 to: &str,
242 paths: &[String],
243) -> Result<Vec<CommitWithFiles>> {
244 let range = format!("{}..{}", from, to);
245 let mut args = vec![
246 "-c".to_string(),
247 "log.showSignature=false".to_string(),
248 "log".to_string(),
249 "--name-only".to_string(),
250 "--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e".to_string(),
251 range,
252 ];
253 if !paths.is_empty() {
254 args.push("--".to_string());
255 for p in paths {
256 args.push(p.clone());
257 }
258 }
259 let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
260 let output = git_output_in(cwd, &arg_refs)?;
261 Ok(parse_commit_output_with_files(&output))
262}
263
264pub fn get_all_commits_paths_with_files_in(
266 cwd: &Path,
267 paths: &[String],
268) -> Result<Vec<CommitWithFiles>> {
269 let mut args = vec![
270 "-c".to_string(),
271 "log.showSignature=false".to_string(),
272 "log".to_string(),
273 "--name-only".to_string(),
274 "--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e".to_string(),
275 "HEAD".to_string(),
276 ];
277 if !paths.is_empty() {
278 args.push("--".to_string());
279 for p in paths {
280 args.push(p.clone());
281 }
282 }
283 let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
284 let output = git_output_in(cwd, &arg_refs)?;
285 Ok(parse_commit_output_with_files(&output))
286}
287
288pub fn get_commits_reachable_paths_in(
293 cwd: &Path,
294 rev: &str,
295 paths: &[String],
296) -> Result<Vec<Commit>> {
297 let mut args = vec![
298 "-c".to_string(),
299 "log.showSignature=false".to_string(),
300 "log".to_string(),
301 "--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e".to_string(),
302 rev.to_string(),
303 ];
304 if !paths.is_empty() {
305 args.push("--".to_string());
306 for p in paths {
307 args.push(p.clone());
308 }
309 }
310 let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
311 let output = git_output_in(cwd, &arg_refs)?;
312 Ok(parse_commit_output(&output))
313}
314
315pub fn get_commits_reachable_paths_with_files_in(
317 cwd: &Path,
318 rev: &str,
319 paths: &[String],
320) -> Result<Vec<CommitWithFiles>> {
321 let mut args = vec![
322 "-c".to_string(),
323 "log.showSignature=false".to_string(),
324 "log".to_string(),
325 "--name-only".to_string(),
326 "--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e".to_string(),
327 rev.to_string(),
328 ];
329 if !paths.is_empty() {
330 args.push("--".to_string());
331 for p in paths {
332 args.push(p.clone());
333 }
334 }
335 let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
336 let output = git_output_in(cwd, &arg_refs)?;
337 Ok(parse_commit_output_with_files(&output))
338}
339
340pub fn get_last_commit_messages(count: usize) -> Result<Vec<String>> {
342 get_last_commit_messages_in(&cwd_or_dot(), count)
343}
344
345pub fn get_last_commit_messages_in(cwd: &Path, count: usize) -> Result<Vec<String>> {
347 let output = git_output_in(
348 cwd,
349 &[
350 "-c",
351 "log.showSignature=false",
352 "log",
353 &format!("-{count}"),
354 "--pretty=format:%s",
355 ],
356 )?;
357 Ok(output.lines().map(str::to_string).collect())
358}
359
360pub fn get_commit_messages_between(from: &str, to: &str) -> Result<Vec<String>> {
362 get_commit_messages_between_in(&cwd_or_dot(), from, to)
363}
364
365pub fn get_commit_messages_between_in(cwd: &Path, from: &str, to: &str) -> Result<Vec<String>> {
367 let output = git_output_in(
368 cwd,
369 &[
370 "-c",
371 "log.showSignature=false",
372 "log",
373 "--pretty=format:%s",
374 &format!("{from}..{to}"),
375 ],
376 )?;
377 Ok(output.lines().map(str::to_string).collect())
378}
379
380pub fn get_current_branch() -> Result<String> {
382 get_current_branch_in(&cwd_or_dot())
383}
384
385pub fn is_branchlike(name: &str) -> bool {
410 use regex::Regex;
411 use std::sync::OnceLock;
412 static LOCKSTEP: OnceLock<Regex> = OnceLock::new();
413 static PER_CRATE: OnceLock<Regex> = OnceLock::new();
414 let lockstep = LOCKSTEP.get_or_init(|| Regex::new(r"^v\d+\.\d+\.\d+").expect("static regex"));
415 let per_crate =
416 PER_CRATE.get_or_init(|| Regex::new(r"^[^/]+-v\d+\.\d+\.\d+").expect("static regex"));
417 !(lockstep.is_match(name) || per_crate.is_match(name))
418}
419
420pub fn get_current_branch_in(cwd: &Path) -> Result<String> {
434 if let Ok(name) = git_output_in(cwd, &["symbolic-ref", "--short", "HEAD"]) {
435 return Ok(name);
436 }
437 if let Ok(out) = git_output_in(
438 cwd,
439 &[
440 "for-each-ref",
441 "--points-at",
442 "HEAD",
443 "--format=%(refname:short)",
444 "refs/heads/",
445 ],
446 ) && !out.is_empty()
447 {
448 let branches: Vec<&str> = out.lines().collect();
449 for preferred in ["master", "main"] {
450 if branches.contains(&preferred) {
451 return Ok(preferred.to_string());
452 }
453 }
454 if let Some(first) = branches.first() {
455 return Ok((*first).to_string());
456 }
457 }
458 if let Ok(out) = git_output_in(
459 cwd,
460 &["symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
461 ) && let Some(name) = out.strip_prefix("origin/")
462 {
463 return Ok(name.to_string());
464 }
465 if let Ok(name) = std::env::var("GITHUB_REF_NAME")
466 && !name.is_empty()
467 && is_branchlike(&name)
468 {
469 return Ok(name);
470 }
471 anyhow::bail!(
472 "could not resolve current branch: HEAD is detached and no fallback (points-at-HEAD branches, origin/HEAD, GITHUB_REF_NAME) succeeded"
473 )
474}
475
476pub fn branches_containing_sha_in(cwd: &Path, sha: &str) -> Result<Vec<String>> {
482 let out = git_output_in(
483 cwd,
484 &[
485 "branch",
486 "-r",
487 "--contains",
488 sha,
489 "--format=%(refname:short)",
490 ],
491 )?;
492 Ok(out
493 .lines()
494 .filter_map(|line| line.trim().strip_prefix("origin/").map(str::to_string))
495 .filter(|name| !name.is_empty() && name != "HEAD")
496 .collect())
497}
498
499pub fn has_commits_since_tag(tag: &str) -> Result<bool> {
501 has_commits_since_tag_in(&cwd_or_dot(), tag)
502}
503
504pub fn has_commits_since_tag_in(cwd: &Path, tag: &str) -> Result<bool> {
506 let range = format!("{}..HEAD", tag);
507 let output = git_output_in(
508 cwd,
509 &["-c", "log.showSignature=false", "log", "--oneline", &range],
510 )?;
511 Ok(!output.is_empty())
512}
513
514pub fn count_commits_since_last_tag_in(cwd: &Path, monorepo_prefix: Option<&str>) -> Result<u64> {
534 let match_arg;
543 let mut describe_args: Vec<&str> = vec!["describe", "--tags", "--abbrev=0"];
544 if let Some(prefix) = monorepo_prefix {
545 match_arg = format!("--match={}*", prefix);
546 describe_args.push(&match_arg);
547 }
548 describe_args.push("HEAD");
549 let range = match git_output_in(cwd, &describe_args) {
550 Ok(tag) if !tag.is_empty() => format!("{tag}..HEAD"),
551 _ => "HEAD".to_string(),
552 };
553 let count = match git_output_in(cwd, &["rev-list", "--count", &range]) {
555 Ok(s) => s.trim().parse::<u64>().unwrap_or(0),
556 Err(_) => 0,
557 };
558 Ok(count)
559}
560
561pub fn get_short_commit() -> Result<String> {
563 get_short_commit_in(&cwd_or_dot())
564}
565
566pub fn get_short_commit_in(cwd: &Path) -> Result<String> {
568 git_output_in(cwd, &["rev-parse", "--short", "HEAD"])
569}
570
571pub const SHORT_COMMIT_LEN: usize = 7;
577
578pub fn short_commit_str(commit: &str) -> String {
589 if commit.len() > SHORT_COMMIT_LEN {
590 commit[..SHORT_COMMIT_LEN].to_string()
591 } else {
592 commit.to_string()
593 }
594}
595
596pub fn get_head_commit() -> Result<String> {
603 get_head_commit_in(&cwd_or_dot())
604}
605
606pub fn get_head_commit_in(cwd: &Path) -> Result<String> {
608 git_output_in(cwd, &["rev-parse", "HEAD"])
609}
610
611pub fn has_changes_since(tag: &str, path: &str) -> Result<bool> {
613 has_changes_since_in(&cwd_or_dot(), tag, path)
614}
615
616pub fn has_changes_since_in(cwd: &Path, tag: &str, path: &str) -> Result<bool> {
618 let output = git_output_in(
619 cwd,
620 &["diff", "--name-only", &format!("{}..HEAD", tag), "--", path],
621 )?;
622 Ok(!output.is_empty())
623}
624
625pub fn get_last_commit_messages_path(count: usize, path: &str) -> Result<Vec<String>> {
627 get_last_commit_messages_path_in(&cwd_or_dot(), count, path)
628}
629
630pub fn get_last_commit_messages_path_in(
632 cwd: &Path,
633 count: usize,
634 path: &str,
635) -> Result<Vec<String>> {
636 let output = git_output_in(
637 cwd,
638 &[
639 "-c",
640 "log.showSignature=false",
641 "log",
642 &format!("-{count}"),
643 "--pretty=format:%s",
644 "--",
645 path,
646 ],
647 )?;
648 Ok(output.lines().map(str::to_string).collect())
649}
650
651pub fn get_commit_messages_between_path(from: &str, to: &str, path: &str) -> Result<Vec<String>> {
653 get_commit_messages_between_path_in(&cwd_or_dot(), from, to, path)
654}
655
656pub fn get_commit_messages_between_path_in(
658 cwd: &Path,
659 from: &str,
660 to: &str,
661 path: &str,
662) -> Result<Vec<String>> {
663 let output = git_output_in(
664 cwd,
665 &[
666 "-c",
667 "log.showSignature=false",
668 "log",
669 "--pretty=format:%s",
670 &format!("{from}..{to}"),
671 "--",
672 path,
673 ],
674 )?;
675 Ok(output.lines().map(str::to_string).collect())
676}
677
678pub fn stage_and_commit(files: &[&str], message: &str) -> Result<bool> {
686 stage_and_commit_in(&cwd_or_dot(), files, message)
687}
688
689pub fn stage_and_commit_in(cwd: &Path, files: &[&str], message: &str) -> Result<bool> {
691 let mut args = vec!["add", "--"];
692 args.extend(files.iter().copied());
693 git_output_in(cwd, &args)?;
694 let diff = Command::new("git")
700 .current_dir(cwd)
701 .args(["diff", "--cached", "--quiet", "--"])
702 .args(files)
703 .env("GIT_TERMINAL_PROMPT", "0")
704 .env("LC_ALL", "C")
705 .status()?;
706 if diff.success() {
707 return Ok(false);
708 }
709 git_output_in(cwd, &["commit", "-m", message])?;
710 Ok(true)
711}
712
713pub fn log_subjects_for_range(
724 workspace_root: &std::path::Path,
725 range: &str,
726 rel_path: &str,
727) -> Result<Vec<String>> {
728 let out = Command::new("git")
729 .arg("-C")
730 .arg(workspace_root)
731 .args([
732 "-c",
733 "log.showSignature=false",
734 "log",
735 "--pretty=format:%B%x1e",
736 range,
737 "--",
738 rel_path,
739 ])
740 .env("GIT_TERMINAL_PROMPT", "0")
741 .env("LC_ALL", "C")
742 .output()?;
743 if !out.status.success() {
744 return Ok(Vec::new());
746 }
747 let text = String::from_utf8_lossy(&out.stdout);
748 Ok(text
749 .split('\x1e')
750 .map(|s| s.trim().to_string())
751 .filter(|s| !s.is_empty())
752 .collect())
753}
754
755pub fn add_path_in(workspace_root: &std::path::Path, rel: &std::path::Path) -> Result<()> {
757 let out = Command::new("git")
758 .arg("-C")
759 .arg(workspace_root)
760 .arg("add")
761 .arg(rel)
762 .env("GIT_TERMINAL_PROMPT", "0")
763 .env("LC_ALL", "C")
764 .output()
765 .context("failed to invoke git add")?;
766 if !out.status.success() {
767 let stderr_raw = String::from_utf8_lossy(&out.stderr);
768 let raw = format!("git add {} failed: {}", rel.display(), stderr_raw.trim());
769 bail!("{}", crate::redact::redact_process_env(&raw));
770 }
771 Ok(())
772}
773
774pub fn commit_in(workspace_root: &std::path::Path, message: &str, sign: bool) -> Result<()> {
777 let mut cmd = Command::new("git");
778 cmd.arg("-C").arg(workspace_root).arg("commit");
779 if sign {
780 cmd.arg("-S");
781 }
782 cmd.arg("-m")
783 .arg(message)
784 .env("GIT_TERMINAL_PROMPT", "0")
785 .env("LC_ALL", "C");
786 let out = cmd.output().context("failed to invoke git commit")?;
787 if !out.status.success() {
788 let stderr_raw = String::from_utf8_lossy(&out.stderr);
789 let raw = format!("git commit failed: {}", stderr_raw.trim());
790 bail!("{}", crate::redact::redact_process_env(&raw));
791 }
792 Ok(())
793}
794
795pub fn paths_changed_since_tag(tag: &str, paths: &[&str]) -> Result<bool> {
800 paths_changed_since_tag_in(&cwd_or_dot(), tag, paths)
801}
802
803pub fn paths_changed_since_tag_in(cwd: &Path, tag: &str, paths: &[&str]) -> Result<bool> {
805 let mut args: Vec<String> = vec![
806 "diff".to_string(),
807 "--name-only".to_string(),
808 format!("{tag}..HEAD"),
809 "--".to_string(),
810 ];
811 for p in paths {
812 args.push((*p).to_string());
813 }
814 let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
815 let output = Command::new("git")
816 .current_dir(cwd)
817 .args(&arg_refs)
818 .env("GIT_TERMINAL_PROMPT", "0")
819 .env("LC_ALL", "C")
820 .output()?;
821 if output.status.success() {
822 Ok(!String::from_utf8_lossy(&output.stdout).trim().is_empty())
823 } else {
824 Ok(false)
825 }
826}
827
828pub fn head_commit_hash_in(repo: &std::path::Path) -> Result<String> {
833 let out = Command::new("git")
834 .arg("-C")
835 .arg(repo)
836 .args(["rev-parse", "HEAD"])
837 .env("GIT_TERMINAL_PROMPT", "0")
838 .env("LC_ALL", "C")
839 .output()
840 .context("failed to invoke git rev-parse HEAD")?;
841 if !out.status.success() {
842 let stderr_raw = String::from_utf8_lossy(&out.stderr);
843 let raw = format!("git rev-parse HEAD failed: {}", stderr_raw.trim());
844 bail!("{}", crate::redact::redact_process_env(&raw));
845 }
846 Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
847}
848
849pub fn rev_parse_in(cwd: &Path, rev: &str) -> Result<String> {
854 git_output_in(cwd, &["rev-parse", rev])
855}
856
857pub fn rev_verify_commit_in(cwd: &Path, rev: &str) -> Result<String> {
863 git_output_in(
864 cwd,
865 &["rev-parse", "--verify", &format!("{}^{{commit}}", rev)],
866 )
867}
868
869pub fn commits_between_in(cwd: &Path, sha: &str) -> Result<Vec<String>> {
874 let range = format!("{}..HEAD", sha);
875 let out = git_output_in(cwd, &["rev-list", &range])?;
876 if out.is_empty() {
877 return Ok(Vec::new());
878 }
879 Ok(out.lines().map(|s| s.trim().to_string()).collect())
880}
881
882pub fn commit_subject_in(cwd: &Path, sha: &str) -> Result<String> {
886 git_output_in(
887 cwd,
888 &[
889 "-c",
890 "log.showSignature=false",
891 "log",
892 "-1",
893 "--format=%s",
894 sha,
895 ],
896 )
897}
898
899pub fn commits_with_subjects_in(cwd: &Path, sha: &str) -> Result<Vec<(String, String)>> {
906 let range = format!("{}..HEAD", sha);
907 let out = git_output_in(
908 cwd,
909 &[
910 "-c",
911 "log.showSignature=false",
912 "log",
913 "--format=%H%x1f%s",
914 &range,
915 ],
916 )?;
917 if out.is_empty() {
918 return Ok(Vec::new());
919 }
920 Ok(out
921 .lines()
922 .filter_map(|line| {
923 let mut parts = line.splitn(2, '\x1f');
924 let sha = parts.next()?.trim().to_string();
925 let subj = parts.next().unwrap_or("").to_string();
926 if sha.is_empty() {
927 None
928 } else {
929 Some((sha, subj))
930 }
931 })
932 .collect())
933}
934
935#[derive(Debug, Clone, Default)]
948pub struct CommitterIdentity {
949 pub name: Option<String>,
950 pub email: Option<String>,
951}
952
953impl CommitterIdentity {
954 pub fn default_for_rollback() -> Self {
960 let host = std::env::var("HOSTNAME")
961 .ok()
962 .or_else(|| std::env::var("COMPUTERNAME").ok())
963 .and_then(|h| h.split('.').next().map(str::to_string))
964 .filter(|h| !h.is_empty())
965 .unwrap_or_else(|| "localhost".to_string());
966 Self {
967 name: Some("anodize-rollback".to_string()),
968 email: Some(format!("anodize-rollback@{host}")),
969 }
970 }
971
972 fn apply_to(&self, cmd: &mut Command) {
973 if let Some(n) = &self.name {
974 cmd.env("GIT_AUTHOR_NAME", n).env("GIT_COMMITTER_NAME", n);
975 }
976 if let Some(e) = &self.email {
977 cmd.env("GIT_AUTHOR_EMAIL", e).env("GIT_COMMITTER_EMAIL", e);
978 }
979 }
980}
981
982fn read_git_identity(cwd: &Path) -> (Option<String>, Option<String>) {
988 let one = |key: &str| -> Option<String> {
989 let out = Command::new("git")
990 .current_dir(cwd)
991 .args(["config", "--get", key])
992 .env("LC_ALL", "C")
993 .env("GIT_TERMINAL_PROMPT", "0")
994 .output()
995 .ok()?;
996 if !out.status.success() {
997 return None;
998 }
999 let value = String::from_utf8_lossy(&out.stdout).trim().to_string();
1000 if value.is_empty() { None } else { Some(value) }
1001 };
1002 (one("user.name"), one("user.email"))
1003}
1004
1005pub fn resolve_rollback_identity(cwd: &Path) -> CommitterIdentity {
1012 let env_author_set =
1013 std::env::var("GIT_AUTHOR_EMAIL").is_ok() && std::env::var("GIT_AUTHOR_NAME").is_ok();
1014 let env_committer_set =
1015 std::env::var("GIT_COMMITTER_EMAIL").is_ok() && std::env::var("GIT_COMMITTER_NAME").is_ok();
1016 if env_author_set && env_committer_set {
1017 return CommitterIdentity::default();
1018 }
1019 let (name, email) = read_git_identity(cwd);
1020 if name.is_some() && email.is_some() {
1021 return CommitterIdentity::default();
1022 }
1023 CommitterIdentity::default_for_rollback()
1024}
1025
1026pub fn revert_commit_in(
1052 cwd: &Path,
1053 sha: &str,
1054 message: Option<&str>,
1055 identity: &CommitterIdentity,
1056) -> Result<()> {
1057 let status = Command::new("git")
1058 .args(["status", "--porcelain", "--untracked-files=no"])
1059 .current_dir(cwd)
1060 .env("LC_ALL", "C")
1061 .env("GIT_TERMINAL_PROMPT", "0")
1062 .output()
1063 .with_context(|| format!("revert_commit_in: git status in {}", cwd.display()))?;
1064 if !status.status.success() {
1065 let stderr_raw = String::from_utf8_lossy(&status.stderr);
1066 let raw = format!("git status failed: {}", stderr_raw.trim());
1067 bail!("{}", crate::redact::redact_process_env(&raw));
1068 }
1069 if !status.stdout.is_empty() {
1070 bail!(
1071 "refusing to revert in a dirty working tree at {}\nstatus:\n{}",
1072 cwd.display(),
1073 String::from_utf8_lossy(&status.stdout),
1074 );
1075 }
1076
1077 let mut revert_cmd = Command::new("git");
1078 revert_cmd
1079 .current_dir(cwd)
1080 .args(["revert", "--no-edit", sha])
1081 .env("LC_ALL", "C")
1082 .env("GIT_TERMINAL_PROMPT", "0");
1083 identity.apply_to(&mut revert_cmd);
1084 let out = revert_cmd
1085 .output()
1086 .with_context(|| format!("revert_commit_in: git revert in {}", cwd.display()))?;
1087 if !out.status.success() {
1088 let stderr_raw = String::from_utf8_lossy(&out.stderr);
1089 let _ = Command::new("git")
1092 .current_dir(cwd)
1093 .args(["revert", "--abort"])
1094 .env("LC_ALL", "C")
1095 .env("GIT_TERMINAL_PROMPT", "0")
1096 .output();
1097 let raw = format!(
1098 "git revert {sha} hit conflicts and was aborted (working tree restored). \
1099 The bump commit overlaps with later changes — resolve manually, \
1100 or re-run with --mode=reset to force.\nstderr: {}",
1101 stderr_raw.trim()
1102 );
1103 bail!("{}", crate::redact::redact_process_env(&raw));
1104 }
1105 if let Some(msg) = message {
1106 let mut amend_cmd = Command::new("git");
1107 amend_cmd
1108 .current_dir(cwd)
1109 .args(["commit", "--amend", "-m", msg])
1110 .env("LC_ALL", "C")
1111 .env("GIT_TERMINAL_PROMPT", "0");
1112 identity.apply_to(&mut amend_cmd);
1113 let out = amend_cmd.output().with_context(|| {
1114 format!("revert_commit_in: git commit --amend in {}", cwd.display())
1115 })?;
1116 if !out.status.success() {
1117 let stderr_raw = String::from_utf8_lossy(&out.stderr);
1118 let raw = format!("git commit --amend failed: {}", stderr_raw.trim());
1119 bail!("{}", crate::redact::redact_process_env(&raw));
1120 }
1121 }
1122 Ok(())
1123}
1124
1125pub fn reset_hard_in(cwd: &Path, sha: &str) -> Result<()> {
1128 git_output_in(cwd, &["reset", "--hard", sha])?;
1129 Ok(())
1130}
1131
1132pub fn push_branch_in(cwd: &Path, branch: &str) -> Result<()> {
1137 if !super::has_remote_in(cwd, "origin") {
1138 bail!("no 'origin' remote configured, cannot push branch '{branch}'");
1139 }
1140 let refspec = format!("HEAD:refs/heads/{}", branch);
1141 let out = Command::new("git")
1142 .current_dir(cwd)
1143 .args(["push", "origin", &refspec])
1144 .env("GIT_TERMINAL_PROMPT", "0")
1145 .env("LC_ALL", "C")
1146 .output()
1147 .with_context(|| format!("push_branch_in: git push origin {refspec}"))?;
1148 if !out.status.success() {
1149 let stderr_raw = String::from_utf8_lossy(&out.stderr);
1150 let raw = format!("git push origin {} failed: {}", refspec, stderr_raw.trim());
1151 bail!("{}", crate::redact::redact_process_env(&raw));
1152 }
1153 Ok(())
1154}
1155
1156pub fn head_commit_timestamp_in(repo: &std::path::Path) -> Result<i64> {
1160 let out = Command::new("git")
1161 .arg("-C")
1162 .arg(repo)
1163 .args(["log", "-1", "--format=%ct", "HEAD"])
1164 .env("GIT_TERMINAL_PROMPT", "0")
1165 .env("LC_ALL", "C")
1166 .output()
1167 .context("failed to invoke git log -1 --format=%ct HEAD")?;
1168 if !out.status.success() {
1169 let stderr_raw = String::from_utf8_lossy(&out.stderr);
1170 let raw = format!("git log -1 --format=%ct HEAD failed: {}", stderr_raw.trim());
1171 bail!("{}", crate::redact::redact_process_env(&raw));
1172 }
1173 let text = String::from_utf8_lossy(&out.stdout).trim().to_string();
1174 text.parse::<i64>()
1175 .with_context(|| format!("git log --format=%ct returned non-i64 timestamp: {}", text))
1176}
1177
1178#[cfg(test)]
1179mod tests {
1180 use super::*;
1181 use std::process::Command;
1182
1183 fn init_repo_with_commits(dir: &Path, files: &[&str]) {
1184 let run = |args: &[&str]| {
1185 let out = Command::new("git")
1186 .args(args)
1187 .current_dir(dir)
1188 .env("GIT_AUTHOR_NAME", "t")
1189 .env("GIT_AUTHOR_EMAIL", "t@t.com")
1190 .env("GIT_COMMITTER_NAME", "t")
1191 .env("GIT_COMMITTER_EMAIL", "t@t.com")
1192 .output()
1193 .unwrap();
1194 assert!(out.status.success(), "git {args:?} failed");
1195 };
1196 run(&["init"]);
1197 run(&["config", "user.email", "t@t.com"]);
1198 run(&["config", "user.name", "t"]);
1199 for (i, f) in files.iter().enumerate() {
1200 std::fs::write(dir.join(f), format!("c{i}")).unwrap();
1201 run(&["add", "."]);
1202 run(&["commit", "-m", &format!("commit-{i}: {f}")]);
1203 }
1204 }
1205
1206 #[test]
1207 fn get_head_commit_in_returns_tempdirs_head_sha() {
1208 let tmp = tempfile::tempdir().unwrap();
1209 init_repo_with_commits(tmp.path(), &["a"]);
1210 let expected = String::from_utf8(
1211 Command::new("git")
1212 .args(["rev-parse", "HEAD"])
1213 .current_dir(tmp.path())
1214 .output()
1215 .unwrap()
1216 .stdout,
1217 )
1218 .unwrap()
1219 .trim()
1220 .to_string();
1221 let sha = get_head_commit_in(tmp.path()).unwrap();
1222 assert_eq!(sha, expected);
1223 }
1224
1225 #[test]
1226 fn get_short_commit_in_returns_tempdirs_short_sha() {
1227 let tmp = tempfile::tempdir().unwrap();
1228 init_repo_with_commits(tmp.path(), &["a"]);
1229 let expected = String::from_utf8(
1230 Command::new("git")
1231 .args(["rev-parse", "--short", "HEAD"])
1232 .current_dir(tmp.path())
1233 .output()
1234 .unwrap()
1235 .stdout,
1236 )
1237 .unwrap()
1238 .trim()
1239 .to_string();
1240 let short = get_short_commit_in(tmp.path()).unwrap();
1241 assert_eq!(short, expected);
1242 }
1243
1244 #[test]
1245 fn has_commits_since_tag_in_returns_false_when_tag_is_head() {
1246 let tmp = tempfile::tempdir().unwrap();
1247 let dir = tmp.path();
1248 init_repo_with_commits(dir, &["a"]);
1249 let run = |args: &[&str]| {
1250 Command::new("git")
1251 .args(args)
1252 .current_dir(dir)
1253 .env("GIT_AUTHOR_NAME", "t")
1254 .env("GIT_AUTHOR_EMAIL", "t@t.com")
1255 .env("GIT_COMMITTER_NAME", "t")
1256 .env("GIT_COMMITTER_EMAIL", "t@t.com")
1257 .output()
1258 .unwrap();
1259 };
1260 run(&["tag", "v1.0.0"]);
1261 assert!(!has_commits_since_tag_in(dir, "v1.0.0").unwrap());
1262 }
1263
1264 fn git_in(dir: &Path, args: &[&str]) {
1265 let out = Command::new("git")
1266 .args(args)
1267 .current_dir(dir)
1268 .env("GIT_AUTHOR_NAME", "t")
1269 .env("GIT_AUTHOR_EMAIL", "t@t.com")
1270 .env("GIT_COMMITTER_NAME", "t")
1271 .env("GIT_COMMITTER_EMAIL", "t@t.com")
1272 .output()
1273 .unwrap();
1274 assert!(out.status.success(), "git {args:?} failed");
1275 }
1276
1277 #[test]
1278 fn count_commits_since_last_tag_counts_commits_after_tag() {
1279 let tmp = tempfile::tempdir().unwrap();
1280 let dir = tmp.path();
1281 init_repo_with_commits(dir, &["a", "b"]);
1283 git_in(dir, &["tag", "v1.0.0"]);
1284 for f in ["c", "d", "e"] {
1285 std::fs::write(dir.join(f), "x").unwrap();
1286 git_in(dir, &["add", "."]);
1287 git_in(dir, &["commit", "-m", f]);
1288 }
1289 assert_eq!(count_commits_since_last_tag_in(dir, None).unwrap(), 3);
1290 }
1291
1292 #[test]
1293 fn count_commits_since_last_tag_resets_on_newer_tag() {
1294 let tmp = tempfile::tempdir().unwrap();
1295 let dir = tmp.path();
1296 init_repo_with_commits(dir, &["a"]);
1297 git_in(dir, &["tag", "v1.0.0"]);
1298 for f in ["b", "c"] {
1299 std::fs::write(dir.join(f), "x").unwrap();
1300 git_in(dir, &["add", "."]);
1301 git_in(dir, &["commit", "-m", f]);
1302 }
1303 assert_eq!(count_commits_since_last_tag_in(dir, None).unwrap(), 2);
1304 git_in(dir, &["tag", "v1.1.0"]);
1306 assert_eq!(count_commits_since_last_tag_in(dir, None).unwrap(), 0);
1307 std::fs::write(dir.join("d"), "x").unwrap();
1308 git_in(dir, &["add", "."]);
1309 git_in(dir, &["commit", "-m", "d"]);
1310 assert_eq!(count_commits_since_last_tag_in(dir, None).unwrap(), 1);
1311 }
1312
1313 #[test]
1314 fn count_commits_since_last_tag_counts_all_when_no_tag() {
1315 let tmp = tempfile::tempdir().unwrap();
1316 let dir = tmp.path();
1317 init_repo_with_commits(dir, &["a", "b", "c"]);
1318 assert_eq!(count_commits_since_last_tag_in(dir, None).unwrap(), 3);
1320 }
1321
1322 #[test]
1323 fn count_commits_since_last_tag_respects_monorepo_prefix() {
1324 let tmp = tempfile::tempdir().unwrap();
1328 let dir = tmp.path();
1329 init_repo_with_commits(dir, &["a"]);
1330 git_in(dir, &["tag", "core/v1.0.0"]); for f in ["b", "c"] {
1332 std::fs::write(dir.join(f), "x").unwrap();
1333 git_in(dir, &["add", "."]);
1334 git_in(dir, &["commit", "-m", f]);
1335 }
1336 git_in(dir, &["tag", "api/v2.0.0"]); std::fs::write(dir.join("d"), "x").unwrap();
1338 git_in(dir, &["add", "."]);
1339 git_in(dir, &["commit", "-m", "d"]);
1340
1341 assert_eq!(
1343 count_commits_since_last_tag_in(dir, Some("core/")).unwrap(),
1344 3,
1345 "must count since the matching-prefix tag, ignoring api/v2.0.0",
1346 );
1347 assert_eq!(
1351 count_commits_since_last_tag_in(dir, None).unwrap(),
1352 1,
1353 "unfiltered count picks the nearest (wrong) subproject tag",
1354 );
1355 }
1356
1357 #[test]
1358 fn get_current_branch_in_returns_branch_name() {
1359 let tmp = tempfile::tempdir().unwrap();
1360 let dir = tmp.path();
1361 let run = |args: &[&str]| {
1362 let out = Command::new("git")
1363 .args(args)
1364 .current_dir(dir)
1365 .env("GIT_AUTHOR_NAME", "t")
1366 .env("GIT_AUTHOR_EMAIL", "t@t.com")
1367 .env("GIT_COMMITTER_NAME", "t")
1368 .env("GIT_COMMITTER_EMAIL", "t@t.com")
1369 .output()
1370 .unwrap();
1371 assert!(out.status.success(), "git {args:?} failed");
1372 };
1373 run(&["-c", "init.defaultBranch=t1-test-branch", "init"]);
1374 run(&["config", "user.email", "t@t.com"]);
1375 run(&["config", "user.name", "t"]);
1376 std::fs::write(dir.join("a"), "1").unwrap();
1377 run(&["add", "."]);
1378 run(&["commit", "-m", "c1"]);
1379 let branch = get_current_branch_in(dir).unwrap();
1380 assert_eq!(branch, "t1-test-branch");
1381 }
1382
1383 #[test]
1384 fn get_current_branch_in_resolves_detached_head_via_points_at() {
1385 let tmp = tempfile::tempdir().unwrap();
1386 let dir = tmp.path();
1387 let run = |args: &[&str]| {
1388 let out = Command::new("git")
1389 .args(args)
1390 .current_dir(dir)
1391 .env("GIT_AUTHOR_NAME", "t")
1392 .env("GIT_AUTHOR_EMAIL", "t@t.com")
1393 .env("GIT_COMMITTER_NAME", "t")
1394 .env("GIT_COMMITTER_EMAIL", "t@t.com")
1395 .output()
1396 .unwrap();
1397 assert!(out.status.success(), "git {args:?} failed");
1398 };
1399 run(&["-c", "init.defaultBranch=master", "init"]);
1400 run(&["config", "user.email", "t@t.com"]);
1401 run(&["config", "user.name", "t"]);
1402 std::fs::write(dir.join("a"), "1").unwrap();
1403 run(&["add", "."]);
1404 run(&["commit", "-m", "c1"]);
1405 let sha = get_head_commit_in(dir).unwrap();
1406 run(&["checkout", "--detach", &sha]);
1407 let branch = get_current_branch_in(dir).unwrap();
1408 assert_eq!(
1409 branch, "master",
1410 "detached HEAD pointing at master must resolve to master, not literal HEAD"
1411 );
1412 }
1413
1414 #[test]
1415 fn is_branchlike_rejects_lockstep_tag_shapes() {
1416 assert!(!is_branchlike("v0.4.5"));
1417 assert!(!is_branchlike("v1.2.3"));
1418 assert!(!is_branchlike("v10.20.30"));
1419 assert!(!is_branchlike("v1.2.3-rc.1"));
1420 assert!(!is_branchlike("v1.2.3+build.42"));
1421 }
1422
1423 #[test]
1424 fn is_branchlike_rejects_per_crate_tag_shapes() {
1425 assert!(!is_branchlike("mycrate-v1.2.3"));
1426 assert!(!is_branchlike("cfgd-operator-v0.4.0"));
1427 assert!(!is_branchlike("anodize-core-v1.2.3-rc.1"));
1428 }
1429
1430 #[test]
1431 fn is_branchlike_accepts_real_branch_names() {
1432 assert!(is_branchlike("master"));
1433 assert!(is_branchlike("main"));
1434 assert!(is_branchlike("publisher-required-config"));
1435 assert!(is_branchlike("release/v1.2.3-prep"));
1436 assert!(is_branchlike("dependabot/cargo/serde-1.0.200"));
1437 }
1438
1439 #[test]
1440 fn is_branchlike_accepts_slashed_branch_with_embedded_version() {
1441 assert!(is_branchlike("feature/fix-v2.0.0"));
1446 assert!(is_branchlike("hotfix/release-v1.0.0-blocker"));
1447 assert!(is_branchlike("user/wip-v3.1.4"));
1448 }
1449
1450 #[test]
1451 #[serial_test::serial]
1452 fn get_current_branch_in_rejects_tag_shaped_github_ref_name() {
1453 let tmp = tempfile::tempdir().unwrap();
1454 let dir = tmp.path();
1455 let run = |args: &[&str]| {
1456 let out = Command::new("git")
1457 .args(args)
1458 .current_dir(dir)
1459 .env("GIT_AUTHOR_NAME", "t")
1460 .env("GIT_AUTHOR_EMAIL", "t@t.com")
1461 .env("GIT_COMMITTER_NAME", "t")
1462 .env("GIT_COMMITTER_EMAIL", "t@t.com")
1463 .output()
1464 .unwrap();
1465 assert!(out.status.success(), "git {args:?} failed");
1466 };
1467 run(&["-c", "init.defaultBranch=master", "init"]);
1471 run(&["config", "user.email", "t@t.com"]);
1472 run(&["config", "user.name", "t"]);
1473 std::fs::write(dir.join("a"), "1").unwrap();
1474 run(&["add", "."]);
1475 run(&["commit", "-m", "c1"]);
1476 let sha = get_head_commit_in(dir).unwrap();
1477 std::fs::write(dir.join("a"), "2").unwrap();
1480 run(&["add", "."]);
1481 run(&["commit", "-m", "c2"]);
1482 run(&["checkout", "--detach", &sha]);
1483
1484 struct EnvGuard(&'static str, Option<String>);
1487 impl Drop for EnvGuard {
1488 fn drop(&mut self) {
1489 match &self.1 {
1490 Some(v) => unsafe { std::env::set_var(self.0, v) },
1491 None => unsafe { std::env::remove_var(self.0) },
1492 }
1493 }
1494 }
1495 let _g = EnvGuard("GITHUB_REF_NAME", std::env::var("GITHUB_REF_NAME").ok());
1496
1497 unsafe { std::env::set_var("GITHUB_REF_NAME", "v0.4.5") };
1499 let err = get_current_branch_in(dir).unwrap_err();
1500 assert!(
1501 err.to_string().contains("could not resolve current branch"),
1502 "tag-shaped GITHUB_REF_NAME must trigger bail: {err}"
1503 );
1504
1505 unsafe { std::env::set_var("GITHUB_REF_NAME", "mycrate-v1.2.3") };
1507 let err = get_current_branch_in(dir).unwrap_err();
1508 assert!(
1509 err.to_string().contains("could not resolve current branch"),
1510 "per-crate tag GITHUB_REF_NAME must trigger bail: {err}"
1511 );
1512
1513 unsafe { std::env::set_var("GITHUB_REF_NAME", "master") };
1515 let branch = get_current_branch_in(dir).unwrap();
1516 assert_eq!(branch, "master");
1517 }
1518
1519 #[test]
1520 fn branches_containing_sha_in_returns_empty_without_remote() {
1521 let tmp = tempfile::tempdir().unwrap();
1522 let dir = tmp.path();
1523 let run = |args: &[&str]| {
1524 let out = Command::new("git")
1525 .args(args)
1526 .current_dir(dir)
1527 .env("GIT_AUTHOR_NAME", "t")
1528 .env("GIT_AUTHOR_EMAIL", "t@t.com")
1529 .env("GIT_COMMITTER_NAME", "t")
1530 .env("GIT_COMMITTER_EMAIL", "t@t.com")
1531 .output()
1532 .unwrap();
1533 assert!(out.status.success(), "git {args:?} failed");
1534 };
1535 run(&["-c", "init.defaultBranch=master", "init"]);
1536 run(&["config", "user.email", "t@t.com"]);
1537 run(&["config", "user.name", "t"]);
1538 std::fs::write(dir.join("a"), "1").unwrap();
1539 run(&["add", "."]);
1540 run(&["commit", "-m", "c1"]);
1541 let sha = get_head_commit_in(dir).unwrap();
1542 let branches = branches_containing_sha_in(dir, &sha).unwrap();
1546 assert!(branches.is_empty(), "no remote → no remote branches");
1547 }
1548
1549 #[test]
1550 fn branches_containing_sha_in_finds_remote_branch_after_push() {
1551 let tmp = tempfile::tempdir().unwrap();
1552 let bare = tempfile::tempdir().unwrap();
1553 let dir = tmp.path();
1554 let run_in = |cwd: &Path, args: &[&str]| {
1555 let out = Command::new("git")
1556 .args(args)
1557 .current_dir(cwd)
1558 .env("GIT_AUTHOR_NAME", "t")
1559 .env("GIT_AUTHOR_EMAIL", "t@t.com")
1560 .env("GIT_COMMITTER_NAME", "t")
1561 .env("GIT_COMMITTER_EMAIL", "t@t.com")
1562 .output()
1563 .unwrap();
1564 assert!(out.status.success(), "git {args:?} failed");
1565 };
1566 run_in(
1567 bare.path(),
1568 &["-c", "init.defaultBranch=master", "init", "--bare"],
1569 );
1570 run_in(dir, &["-c", "init.defaultBranch=master", "init"]);
1571 run_in(dir, &["config", "user.email", "t@t.com"]);
1572 run_in(dir, &["config", "user.name", "t"]);
1573 run_in(
1574 dir,
1575 &["remote", "add", "origin", bare.path().to_str().unwrap()],
1576 );
1577 std::fs::write(dir.join("a"), "1").unwrap();
1578 run_in(dir, &["add", "."]);
1579 run_in(dir, &["commit", "-m", "c1"]);
1580 let sha = get_head_commit_in(dir).unwrap();
1581 run_in(dir, &["push", "-u", "origin", "master"]);
1582
1583 let branches = branches_containing_sha_in(dir, &sha).unwrap();
1584 assert_eq!(branches, vec!["master".to_string()]);
1585 }
1586
1587 #[test]
1588 fn stage_and_commit_in_returns_false_when_no_diff() {
1589 let tmp = tempfile::tempdir().unwrap();
1590 let dir = tmp.path();
1591 init_repo_with_commits(dir, &["a"]);
1592 let created = stage_and_commit_in(dir, &["a"], "chore: should be a no-op").unwrap();
1596 assert!(!created, "no diff → no commit should be created");
1597 let log = Command::new("git")
1598 .args(["log", "--oneline"])
1599 .current_dir(dir)
1600 .output()
1601 .unwrap();
1602 let log_text = String::from_utf8_lossy(&log.stdout);
1603 assert!(
1604 !log_text.contains("should be a no-op"),
1605 "stage_and_commit_in must not create a commit when no diff: {log_text}"
1606 );
1607 }
1608
1609 #[test]
1610 fn stage_and_commit_in_returns_true_when_file_changed() {
1611 let tmp = tempfile::tempdir().unwrap();
1612 let dir = tmp.path();
1613 init_repo_with_commits(dir, &["a"]);
1614 std::fs::write(dir.join("a"), "changed").unwrap();
1615 let created = stage_and_commit_in(dir, &["a"], "chore: real change").unwrap();
1616 assert!(created, "real change → commit must be created");
1617 let log = Command::new("git")
1618 .args(["log", "-1", "--pretty=%s"])
1619 .current_dir(dir)
1620 .output()
1621 .unwrap();
1622 let subject = String::from_utf8_lossy(&log.stdout).trim().to_string();
1623 assert_eq!(subject, "chore: real change");
1624 }
1625
1626 #[test]
1627 fn git_output_in_error_falls_back_to_stdout_when_stderr_empty() {
1628 let tmp = tempfile::tempdir().unwrap();
1629 let dir = tmp.path();
1630 init_repo_with_commits(dir, &["a"]);
1631 let err = git_output_in(dir, &["commit", "-m", "no-op"]).unwrap_err();
1635 let msg = err.to_string();
1636 assert!(
1637 msg.contains("nothing to commit") || msg.contains("clean"),
1638 "error must include stdout detail when stderr is empty: {msg}"
1639 );
1640 }
1641
1642 #[test]
1648 fn default_for_rollback_populates_both_name_and_email() {
1649 let id = CommitterIdentity::default_for_rollback();
1650 assert_eq!(id.name.as_deref(), Some("anodize-rollback"));
1651 let email = id.email.expect("email must be Some");
1652 assert!(
1653 email.starts_with("anodize-rollback@"),
1654 "email must use the anodize-rollback@<host> shape; got {email}"
1655 );
1656 assert!(!email.ends_with('@'), "host portion must not be empty");
1657 }
1658
1659 #[test]
1666 fn revert_commit_in_uses_injected_identity_envs() {
1667 let tmp = tempfile::tempdir().unwrap();
1668 let dir = tmp.path();
1669 let run_env = |args: &[&str], extra: &[(&str, &str)]| {
1670 let mut cmd = Command::new("git");
1671 cmd.args(args)
1672 .current_dir(dir)
1673 .env("GIT_AUTHOR_NAME", "bootstrap")
1674 .env("GIT_AUTHOR_EMAIL", "bootstrap@b.com")
1675 .env("GIT_COMMITTER_NAME", "bootstrap")
1676 .env("GIT_COMMITTER_EMAIL", "bootstrap@b.com");
1677 for (k, v) in extra {
1678 cmd.env(k, v);
1679 }
1680 let out = cmd.output().unwrap();
1681 assert!(
1682 out.status.success(),
1683 "git {args:?} failed: {}",
1684 String::from_utf8_lossy(&out.stderr)
1685 );
1686 };
1687 run_env(&["init", "-b", "master"], &[]);
1688 std::fs::write(dir.join("a"), "0").unwrap();
1689 run_env(&["add", "."], &[]);
1690 run_env(&["commit", "-m", "initial"], &[]);
1691 std::fs::write(dir.join("a"), "1").unwrap();
1692 run_env(&["add", "."], &[]);
1693 run_env(&["commit", "-m", "chore(release): v1.0.0"], &[]);
1694 let bump_sha = get_head_commit_in(dir).unwrap();
1695
1696 let identity = CommitterIdentity {
1700 name: Some("rollback-bot".to_string()),
1701 email: Some("rollback-bot@anodize.test".to_string()),
1702 };
1703 revert_commit_in(dir, &bump_sha, Some("chore(release): rollback"), &identity)
1704 .expect("revert with injected identity must succeed");
1705
1706 let out = Command::new("git")
1709 .current_dir(dir)
1710 .args(["log", "-1", "--format=%ae"])
1711 .env("GIT_TERMINAL_PROMPT", "0")
1712 .env("LC_ALL", "C")
1713 .output()
1714 .unwrap();
1715 let author_email = String::from_utf8_lossy(&out.stdout).trim().to_string();
1716 assert_eq!(
1717 author_email, "rollback-bot@anodize.test",
1718 "revert commit must carry the injected committer identity"
1719 );
1720
1721 let cfg = Command::new("git")
1724 .current_dir(dir)
1725 .args(["config", "--local", "--get", "user.email"])
1726 .env("GIT_TERMINAL_PROMPT", "0")
1727 .env("LC_ALL", "C")
1728 .output()
1729 .unwrap();
1730 assert!(
1731 !cfg.status.success() || cfg.stdout.is_empty(),
1732 "revert must not write user.email into the repo's local config; got: {}",
1733 String::from_utf8_lossy(&cfg.stdout)
1734 );
1735 }
1736
1737 #[test]
1742 fn revert_commit_in_aborts_on_conflict_and_leaves_tree_clean() {
1743 let tmp = tempfile::tempdir().unwrap();
1744 let dir = tmp.path();
1745 let run = |args: &[&str]| {
1746 let out = Command::new("git")
1747 .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 .output()
1754 .unwrap();
1755 assert!(
1756 out.status.success(),
1757 "git {args:?} failed: {}",
1758 String::from_utf8_lossy(&out.stderr)
1759 );
1760 };
1761 run(&["init", "-b", "master"]);
1762 run(&["config", "user.email", "t@t.com"]);
1763 run(&["config", "user.name", "t"]);
1764 std::fs::write(dir.join("x"), "v1\n").unwrap();
1766 run(&["add", "."]);
1767 run(&["commit", "-m", "initial"]);
1768 std::fs::write(dir.join("x"), "v2\n").unwrap();
1770 run(&["add", "."]);
1771 run(&["commit", "-m", "chore(release): v2"]);
1772 let bump_sha = get_head_commit_in(dir).unwrap();
1773 std::fs::write(dir.join("x"), "v3\n").unwrap();
1777 run(&["add", "."]);
1778 run(&["commit", "-m", "feat: overlap"]);
1779
1780 let identity = CommitterIdentity::default();
1781 let err = revert_commit_in(dir, &bump_sha, None, &identity)
1782 .expect_err("revert against overlapping HEAD must conflict and bail");
1783 let msg = format!("{err}");
1784 assert!(
1785 msg.contains("aborted"),
1786 "bail message must mention abort recovery: {msg}"
1787 );
1788
1789 assert!(
1793 !dir.join(".git/REVERT_HEAD").exists(),
1794 ".git/REVERT_HEAD must be cleaned up after --abort"
1795 );
1796 let status_out = Command::new("git")
1797 .args(["status", "--porcelain"])
1798 .current_dir(dir)
1799 .output()
1800 .unwrap();
1801 assert!(
1802 status_out.stdout.is_empty(),
1803 "working tree must be clean after revert --abort; got:\n{}",
1804 String::from_utf8_lossy(&status_out.stdout)
1805 );
1806 }
1807
1808 #[test]
1813 fn commits_with_subjects_in_returns_all_pairs_in_one_call() {
1814 let tmp = tempfile::tempdir().unwrap();
1815 let dir = tmp.path();
1816 let run = |args: &[&str]| {
1817 let out = Command::new("git")
1818 .args(args)
1819 .current_dir(dir)
1820 .env("GIT_AUTHOR_NAME", "t")
1821 .env("GIT_AUTHOR_EMAIL", "t@t.com")
1822 .env("GIT_COMMITTER_NAME", "t")
1823 .env("GIT_COMMITTER_EMAIL", "t@t.com")
1824 .output()
1825 .unwrap();
1826 assert!(out.status.success(), "git {args:?} failed");
1827 };
1828 run(&["init", "-b", "master"]);
1829 run(&["config", "user.email", "t@t.com"]);
1830 run(&["config", "user.name", "t"]);
1831 std::fs::write(dir.join("a"), "0").unwrap();
1832 run(&["add", "."]);
1833 run(&["commit", "-m", "initial"]);
1834 let base = get_head_commit_in(dir).unwrap();
1835 std::fs::write(dir.join("a"), "1").unwrap();
1836 run(&["add", "."]);
1837 run(&["commit", "-m", "feat: A with extra detail"]);
1838 std::fs::write(dir.join("a"), "2").unwrap();
1839 run(&["add", "."]);
1840 run(&["commit", "-m", "fix: B"]);
1841
1842 let pairs = commits_with_subjects_in(dir, &base).unwrap();
1843 assert_eq!(pairs.len(), 2, "two commits sit on top of base");
1844 assert_eq!(pairs[0].1, "fix: B");
1846 assert_eq!(pairs[1].1, "feat: A with extra detail");
1847
1848 let head = get_head_commit_in(dir).unwrap();
1850 assert!(commits_with_subjects_in(dir, &head).unwrap().is_empty());
1851 }
1852
1853 #[test]
1854 fn parse_commit_output_with_files_pairs_each_commit_with_its_files() {
1855 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";
1858 let parsed = parse_commit_output_with_files(raw);
1859 assert_eq!(parsed.len(), 2);
1860 assert_eq!(parsed[0].commit.message, "fix: B");
1861 assert_eq!(parsed[0].files, vec!["crates/cli/main.rs".to_string()]);
1862 assert_eq!(parsed[1].commit.message, "feat: A");
1863 assert_eq!(
1864 parsed[1].files,
1865 vec!["crates/core/lib.rs".to_string(), "Cargo.toml".to_string()]
1866 );
1867 }
1868
1869 #[test]
1870 fn parse_commit_output_with_files_preserves_multiline_body_at_idx_gt_0() {
1871 let body0 = "detail line one\ndetail line two\n\nCo-Authored-By: Bob <bob@b.com>";
1876 let raw = format!(
1877 "h1\x1fs1\x1ffix: B\x1ft\x1ft@t\x1f\x1e\ncrates/cli/main.rs\n\n\
1878 h0\x1fs0\x1ffeat: A\x1ft\x1ft@t\x1f{body0}\x1e\ncrates/core/lib.rs\n"
1879 );
1880 let parsed = parse_commit_output_with_files(&raw);
1881 assert_eq!(parsed.len(), 2);
1882 assert_eq!(parsed[1].commit.message, "feat: A");
1884 assert_eq!(parsed[1].commit.body, body0);
1885 assert!(
1886 parsed[1]
1887 .commit
1888 .body
1889 .contains("Co-Authored-By: Bob <bob@b.com>"),
1890 "multi-line body trailer dropped: {:?}",
1891 parsed[1].commit.body
1892 );
1893 assert_eq!(parsed[1].files, vec!["crates/core/lib.rs".to_string()]);
1894 }
1895
1896 #[test]
1897 fn get_commits_between_paths_with_files_in_reports_touched_files() {
1898 let tmp = tempfile::tempdir().unwrap();
1899 let dir = tmp.path();
1900 let run = |args: &[&str]| {
1901 assert!(
1902 Command::new("git")
1903 .args(args)
1904 .current_dir(dir)
1905 .env("GIT_AUTHOR_NAME", "t")
1906 .env("GIT_AUTHOR_EMAIL", "t@t.com")
1907 .env("GIT_COMMITTER_NAME", "t")
1908 .env("GIT_COMMITTER_EMAIL", "t@t.com")
1909 .output()
1910 .unwrap()
1911 .status
1912 .success()
1913 );
1914 };
1915 run(&["init"]);
1916 run(&["config", "user.email", "t@t.com"]);
1917 run(&["config", "user.name", "t"]);
1918 std::fs::write(dir.join("base"), "0").unwrap();
1919 run(&["add", "."]);
1920 run(&["commit", "-m", "initial"]);
1921 let base = get_head_commit_in(dir).unwrap();
1922 std::fs::create_dir_all(dir.join("crates/core")).unwrap();
1923 std::fs::write(dir.join("crates/core/lib.rs"), "1").unwrap();
1924 run(&["add", "."]);
1925 run(&["commit", "-m", "feat: core"]);
1926
1927 let pairs = get_commits_between_paths_with_files_in(dir, &base, "HEAD", &[]).unwrap();
1928 assert_eq!(pairs.len(), 1);
1929 assert_eq!(pairs[0].commit.message, "feat: core");
1930 assert_eq!(pairs[0].files, vec!["crates/core/lib.rs".to_string()]);
1931 }
1932
1933 #[test]
1934 fn get_commits_between_paths_with_files_in_preserves_multiline_body_for_later_commits() {
1935 let tmp = tempfile::tempdir().unwrap();
1941 let dir = tmp.path();
1942 let run = |args: &[&str]| {
1943 assert!(
1944 Command::new("git")
1945 .args(args)
1946 .current_dir(dir)
1947 .env("GIT_AUTHOR_NAME", "t")
1948 .env("GIT_AUTHOR_EMAIL", "t@t.com")
1949 .env("GIT_COMMITTER_NAME", "t")
1950 .env("GIT_COMMITTER_EMAIL", "t@t.com")
1951 .output()
1952 .unwrap()
1953 .status
1954 .success()
1955 );
1956 };
1957 run(&["init"]);
1958 run(&["config", "user.email", "t@t.com"]);
1959 run(&["config", "user.name", "t"]);
1960 std::fs::write(dir.join("base"), "0").unwrap();
1961 run(&["add", "."]);
1962 run(&["commit", "-m", "initial"]);
1963 let base = get_head_commit_in(dir).unwrap();
1964
1965 std::fs::write(dir.join("a.rs"), "1").unwrap();
1967 run(&["add", "."]);
1968 run(&[
1969 "commit",
1970 "-m",
1971 "feat: with body\n\nfirst body line\nsecond body line\n\nCo-Authored-By: Bob <bob@b.com>",
1972 ]);
1973 std::fs::write(dir.join("b.rs"), "2").unwrap();
1975 run(&["add", "."]);
1976 run(&["commit", "-m", "fix: later"]);
1977
1978 let pairs = get_commits_between_paths_with_files_in(dir, &base, "HEAD", &[]).unwrap();
1979 assert_eq!(pairs.len(), 2);
1980 assert_eq!(pairs[0].commit.message, "fix: later");
1982 let body = &pairs[1].commit.body;
1983 assert!(
1984 body.contains("first body line") && body.contains("second body line"),
1985 "multi-line body truncated for idx>0 commit: {body:?}"
1986 );
1987 assert!(
1988 body.contains("Co-Authored-By: Bob <bob@b.com>"),
1989 "Co-Authored-By trailer dropped for idx>0 commit: {body:?}"
1990 );
1991 }
1992
1993 #[test]
1996 fn parse_commit_output_empty_input_yields_no_commits() {
1997 assert!(parse_commit_output("").is_empty());
1998 }
1999
2000 #[test]
2001 fn parse_commit_output_decodes_all_six_fields() {
2002 let raw =
2004 "abc123def\x1fabc123d\x1ffeat: add thing\x1fAlice\x1falice@x.com\x1fbody text\x1e";
2005 let commits = parse_commit_output(raw);
2006 assert_eq!(commits.len(), 1);
2007 let c = &commits[0];
2008 assert_eq!(c.hash, "abc123def");
2009 assert_eq!(c.short_hash, "abc123d");
2010 assert_eq!(c.message, "feat: add thing");
2011 assert_eq!(c.author_name, "Alice");
2012 assert_eq!(c.author_email, "alice@x.com");
2013 assert_eq!(c.body, "body text");
2014 }
2015
2016 #[test]
2017 fn parse_commit_output_trims_hash_and_body_but_keeps_inner_subject() {
2018 let raw = " abc \x1fabc\x1ffix: keep spaces\x1ft\x1ft@t\x1f\n\nbody\n\x1e";
2021 let commits = parse_commit_output(raw);
2022 assert_eq!(commits.len(), 1);
2023 assert_eq!(commits[0].hash, "abc", "hash is trimmed");
2024 assert_eq!(commits[0].message, "fix: keep spaces", "subject verbatim");
2025 assert_eq!(commits[0].body, "body", "body is trimmed");
2026 }
2027
2028 #[test]
2029 fn parse_commit_output_absent_body_field_defaults_to_empty() {
2030 let raw = "h\x1fh\x1fsubject\x1fname\x1fmail\x1e";
2032 let commits = parse_commit_output(raw);
2033 assert_eq!(commits.len(), 1);
2034 assert_eq!(commits[0].body, "");
2035 assert_eq!(commits[0].message, "subject");
2036 }
2037
2038 #[test]
2039 fn parse_commit_output_skips_records_with_too_few_fields() {
2040 let raw = "only\x1ftwo\x1e\
2043 h\x1fh\x1fgood: subject\x1fn\x1fe\x1fbody\x1e";
2044 let commits = parse_commit_output(raw);
2045 assert_eq!(commits.len(), 1, "malformed record dropped, good one kept");
2046 assert_eq!(commits[0].message, "good: subject");
2047 }
2048
2049 #[test]
2050 fn parse_commit_output_multiline_body_survives_record_separator_split() {
2051 let raw = "h1\x1fh1\x1ffeat: A\x1fA\x1fa@x\x1fline one\nline two\n\nCo-Authored-By: B <b@x>\x1e\
2054 h0\x1fh0\x1ffix: B\x1fB\x1fb@x\x1f\x1e";
2055 let commits = parse_commit_output(raw);
2056 assert_eq!(commits.len(), 2);
2057 assert_eq!(commits[0].message, "feat: A");
2058 assert!(commits[0].body.contains("line one\nline two"));
2059 assert!(commits[0].body.contains("Co-Authored-By: B <b@x>"));
2060 assert_eq!(commits[1].message, "fix: B");
2061 assert_eq!(commits[1].body, "");
2062 }
2063
2064 #[test]
2067 fn short_commit_str_truncates_long_sha_to_seven() {
2068 assert_eq!(short_commit_str("abcdef0123456789"), "abcdef0");
2069 assert_eq!(short_commit_str("abcdef0123456789").len(), SHORT_COMMIT_LEN);
2070 }
2071
2072 #[test]
2073 fn short_commit_str_returns_shorter_or_equal_input_unchanged() {
2074 assert_eq!(short_commit_str("abc"), "abc", "shorter than 7 unchanged");
2075 assert_eq!(
2076 short_commit_str("abcdefg"),
2077 "abcdefg",
2078 "exactly 7 unchanged"
2079 );
2080 assert_eq!(short_commit_str(""), "", "empty stays empty");
2081 }
2082
2083 fn g(dir: &Path, args: &[&str]) {
2087 let out = Command::new("git")
2088 .args(args)
2089 .current_dir(dir)
2090 .env("GIT_AUTHOR_NAME", "Ada")
2091 .env("GIT_AUTHOR_EMAIL", "ada@x.com")
2092 .env("GIT_COMMITTER_NAME", "Ada")
2093 .env("GIT_COMMITTER_EMAIL", "ada@x.com")
2094 .env("GIT_AUTHOR_DATE", "1715000000 +0000")
2095 .env("GIT_COMMITTER_DATE", "1715000000 +0000")
2096 .output()
2097 .unwrap();
2098 assert!(
2099 out.status.success(),
2100 "git {args:?} failed: {}",
2101 String::from_utf8_lossy(&out.stderr)
2102 );
2103 }
2104
2105 fn init_bare_repo(dir: &Path) {
2107 g(dir, &["init", "-b", "master"]);
2108 g(dir, &["config", "user.email", "ada@x.com"]);
2109 g(dir, &["config", "user.name", "Ada"]);
2110 }
2111
2112 fn commit_file(dir: &Path, path: &str, content: &str, subject: &str) {
2114 let full = dir.join(path);
2115 if let Some(parent) = full.parent() {
2116 std::fs::create_dir_all(parent).unwrap();
2117 }
2118 std::fs::write(full, content).unwrap();
2119 g(dir, &["add", "."]);
2120 g(dir, &["commit", "-m", subject]);
2121 }
2122
2123 #[test]
2126 fn get_commits_between_in_returns_only_post_base_commits() {
2127 let tmp = tempfile::tempdir().unwrap();
2128 let dir = tmp.path();
2129 init_bare_repo(dir);
2130 commit_file(dir, "a", "0", "initial");
2131 let base = get_head_commit_in(dir).unwrap();
2132 commit_file(dir, "a", "1", "feat: one");
2133 commit_file(dir, "a", "2", "fix: two");
2134
2135 let commits = get_commits_between_in(dir, &base, "HEAD", None).unwrap();
2136 assert_eq!(commits.len(), 2, "two commits sit above base");
2137 assert_eq!(commits[0].message, "fix: two");
2139 assert_eq!(commits[1].message, "feat: one");
2140 assert_eq!(commits[1].author_name, "Ada");
2141 assert_eq!(commits[1].author_email, "ada@x.com");
2142 }
2143
2144 #[test]
2145 fn get_commits_between_in_path_filter_excludes_untouched_files() {
2146 let tmp = tempfile::tempdir().unwrap();
2147 let dir = tmp.path();
2148 init_bare_repo(dir);
2149 commit_file(dir, "base", "0", "initial");
2150 let base = get_head_commit_in(dir).unwrap();
2151 commit_file(dir, "src/lib.rs", "1", "feat: touch lib");
2152 commit_file(dir, "docs/readme", "2", "docs: touch docs only");
2153
2154 let commits = get_commits_between_in(dir, &base, "HEAD", Some("src")).unwrap();
2156 assert_eq!(commits.len(), 1, "only the src-touching commit survives");
2157 assert_eq!(commits[0].message, "feat: touch lib");
2158 }
2159
2160 #[test]
2161 fn get_commits_between_paths_in_unions_multiple_paths() {
2162 let tmp = tempfile::tempdir().unwrap();
2163 let dir = tmp.path();
2164 init_bare_repo(dir);
2165 commit_file(dir, "base", "0", "initial");
2166 let base = get_head_commit_in(dir).unwrap();
2167 commit_file(dir, "a/x", "1", "feat: a");
2168 commit_file(dir, "b/y", "2", "feat: b");
2169 commit_file(dir, "c/z", "3", "feat: c");
2170
2171 let commits =
2173 get_commits_between_paths_in(dir, &base, "HEAD", &["a".into(), "b".into()]).unwrap();
2174 let subjects: Vec<&str> = commits.iter().map(|c| c.message.as_str()).collect();
2175 assert_eq!(
2176 commits.len(),
2177 2,
2178 "a and b touched, c excluded: {subjects:?}"
2179 );
2180 assert!(subjects.contains(&"feat: a"));
2181 assert!(subjects.contains(&"feat: b"));
2182 assert!(!subjects.contains(&"feat: c"));
2183 }
2184
2185 #[test]
2188 fn get_all_commits_in_returns_every_commit_on_head() {
2189 let tmp = tempfile::tempdir().unwrap();
2190 let dir = tmp.path();
2191 init_bare_repo(dir);
2192 commit_file(dir, "a", "0", "first");
2193 commit_file(dir, "a", "1", "second");
2194 commit_file(dir, "a", "2", "third");
2195
2196 let commits = get_all_commits_in(dir, None).unwrap();
2197 assert_eq!(commits.len(), 3);
2198 assert_eq!(commits[0].message, "third", "newest-first");
2199 assert_eq!(commits[2].message, "first");
2200 }
2201
2202 #[test]
2203 fn get_all_commits_paths_in_filters_to_path() {
2204 let tmp = tempfile::tempdir().unwrap();
2205 let dir = tmp.path();
2206 init_bare_repo(dir);
2207 commit_file(dir, "keep/x", "0", "feat: keep");
2208 commit_file(dir, "drop/y", "1", "feat: drop");
2209
2210 let commits = get_all_commits_paths_in(dir, &["keep".into()]).unwrap();
2211 assert_eq!(commits.len(), 1);
2212 assert_eq!(commits[0].message, "feat: keep");
2213 }
2214
2215 #[test]
2216 fn get_all_commits_paths_with_files_in_pairs_files() {
2217 let tmp = tempfile::tempdir().unwrap();
2218 let dir = tmp.path();
2219 init_bare_repo(dir);
2220 commit_file(dir, "crates/core/lib.rs", "0", "feat: core");
2221
2222 let pairs = get_all_commits_paths_with_files_in(dir, &[]).unwrap();
2223 assert_eq!(pairs.len(), 1);
2224 assert_eq!(pairs[0].commit.message, "feat: core");
2225 assert_eq!(pairs[0].files, vec!["crates/core/lib.rs".to_string()]);
2226 }
2227
2228 #[test]
2231 fn get_commits_reachable_paths_in_stops_at_the_given_rev() {
2232 let tmp = tempfile::tempdir().unwrap();
2233 let dir = tmp.path();
2234 init_bare_repo(dir);
2235 commit_file(dir, "a", "0", "first");
2236 commit_file(dir, "a", "1", "second");
2237 let mid = get_head_commit_in(dir).unwrap();
2238 commit_file(dir, "a", "2", "third-after-mid");
2239
2240 let commits = get_commits_reachable_paths_in(dir, &mid, &[]).unwrap();
2242 let subjects: Vec<&str> = commits.iter().map(|c| c.message.as_str()).collect();
2243 assert_eq!(commits.len(), 2, "only ancestors of mid: {subjects:?}");
2244 assert!(subjects.contains(&"first"));
2245 assert!(subjects.contains(&"second"));
2246 assert!(!subjects.contains(&"third-after-mid"));
2247 }
2248
2249 #[test]
2250 fn get_commits_reachable_paths_with_files_in_pairs_touched_files() {
2251 let tmp = tempfile::tempdir().unwrap();
2252 let dir = tmp.path();
2253 init_bare_repo(dir);
2254 commit_file(dir, "src/main.rs", "0", "feat: main");
2255 let head = get_head_commit_in(dir).unwrap();
2256
2257 let pairs = get_commits_reachable_paths_with_files_in(dir, &head, &[]).unwrap();
2258 assert_eq!(pairs.len(), 1);
2259 assert_eq!(pairs[0].commit.message, "feat: main");
2260 assert_eq!(pairs[0].files, vec!["src/main.rs".to_string()]);
2261 }
2262
2263 #[test]
2266 fn get_last_commit_messages_in_returns_n_subjects_newest_first() {
2267 let tmp = tempfile::tempdir().unwrap();
2268 let dir = tmp.path();
2269 init_bare_repo(dir);
2270 commit_file(dir, "a", "0", "one");
2271 commit_file(dir, "a", "1", "two");
2272 commit_file(dir, "a", "2", "three");
2273
2274 let msgs = get_last_commit_messages_in(dir, 2).unwrap();
2275 assert_eq!(msgs, vec!["three".to_string(), "two".to_string()]);
2276 }
2277
2278 #[test]
2279 fn get_commit_messages_between_in_lists_post_base_subjects() {
2280 let tmp = tempfile::tempdir().unwrap();
2281 let dir = tmp.path();
2282 init_bare_repo(dir);
2283 commit_file(dir, "a", "0", "initial");
2284 let base = get_head_commit_in(dir).unwrap();
2285 commit_file(dir, "a", "1", "feat: x");
2286 commit_file(dir, "a", "2", "fix: y");
2287
2288 let msgs = get_commit_messages_between_in(dir, &base, "HEAD").unwrap();
2289 assert_eq!(msgs, vec!["fix: y".to_string(), "feat: x".to_string()]);
2290 }
2291
2292 #[test]
2293 fn get_last_commit_messages_path_in_filters_to_path() {
2294 let tmp = tempfile::tempdir().unwrap();
2295 let dir = tmp.path();
2296 init_bare_repo(dir);
2297 commit_file(dir, "keep/a", "0", "feat: keep");
2298 commit_file(dir, "other/b", "1", "feat: other");
2299
2300 let msgs = get_last_commit_messages_path_in(dir, 10, "keep").unwrap();
2301 assert_eq!(msgs, vec!["feat: keep".to_string()]);
2302 }
2303
2304 #[test]
2305 fn get_commit_messages_between_path_in_filters_range_and_path() {
2306 let tmp = tempfile::tempdir().unwrap();
2307 let dir = tmp.path();
2308 init_bare_repo(dir);
2309 commit_file(dir, "base", "0", "initial");
2310 let base = get_head_commit_in(dir).unwrap();
2311 commit_file(dir, "src/x", "1", "feat: src");
2312 commit_file(dir, "doc/y", "2", "docs: doc");
2313
2314 let msgs = get_commit_messages_between_path_in(dir, &base, "HEAD", "src").unwrap();
2315 assert_eq!(msgs, vec!["feat: src".to_string()]);
2316 }
2317
2318 #[test]
2321 fn has_changes_since_in_detects_path_touched_after_tag() {
2322 let tmp = tempfile::tempdir().unwrap();
2323 let dir = tmp.path();
2324 init_bare_repo(dir);
2325 commit_file(dir, "watched", "0", "initial");
2326 g(dir, &["tag", "v1.0.0"]);
2327 assert!(!has_changes_since_in(dir, "v1.0.0", "watched").unwrap());
2329 commit_file(dir, "watched", "1", "feat: change watched");
2330 assert!(has_changes_since_in(dir, "v1.0.0", "watched").unwrap());
2332 assert!(!has_changes_since_in(dir, "v1.0.0", "unrelated").unwrap());
2334 }
2335
2336 #[test]
2337 fn paths_changed_since_tag_in_true_when_any_path_changed() {
2338 let tmp = tempfile::tempdir().unwrap();
2339 let dir = tmp.path();
2340 init_bare_repo(dir);
2341 commit_file(dir, "a", "0", "initial");
2342 g(dir, &["tag", "v1.0.0"]);
2343 commit_file(dir, "b", "1", "feat: add b");
2344
2345 assert!(paths_changed_since_tag_in(dir, "v1.0.0", &["a", "b"]).unwrap());
2347 assert!(!paths_changed_since_tag_in(dir, "v1.0.0", &["a"]).unwrap());
2349 }
2350
2351 #[test]
2352 fn paths_changed_since_tag_in_returns_false_when_git_fails() {
2353 let tmp = tempfile::tempdir().unwrap();
2356 let dir = tmp.path();
2357 init_bare_repo(dir);
2358 commit_file(dir, "a", "0", "initial");
2359 assert!(!paths_changed_since_tag_in(dir, "nope-no-such-tag", &["a"]).unwrap());
2360 }
2361
2362 #[test]
2365 fn head_commit_hash_in_matches_rev_parse_head() {
2366 let tmp = tempfile::tempdir().unwrap();
2367 let dir = tmp.path();
2368 init_bare_repo(dir);
2369 commit_file(dir, "a", "0", "initial");
2370 let expected = get_head_commit_in(dir).unwrap();
2371 assert_eq!(head_commit_hash_in(dir).unwrap(), expected);
2372 }
2373
2374 #[test]
2375 fn head_commit_hash_in_errors_on_non_repo() {
2376 let tmp = tempfile::tempdir().unwrap();
2377 assert!(head_commit_hash_in(tmp.path()).is_err());
2379 }
2380
2381 #[test]
2382 fn rev_parse_in_resolves_branch_to_full_sha() {
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 head = get_head_commit_in(dir).unwrap();
2388 assert_eq!(rev_parse_in(dir, "master").unwrap(), head);
2389 }
2390
2391 #[test]
2392 fn rev_verify_commit_in_accepts_commit_rejects_unknown() {
2393 let tmp = tempfile::tempdir().unwrap();
2394 let dir = tmp.path();
2395 init_bare_repo(dir);
2396 commit_file(dir, "a", "0", "initial");
2397 let head = get_head_commit_in(dir).unwrap();
2398 assert_eq!(rev_verify_commit_in(dir, "HEAD").unwrap(), head);
2399 assert!(rev_verify_commit_in(dir, "deadbeefdeadbeef").is_err());
2401 }
2402
2403 #[test]
2404 fn commits_between_in_lists_shas_above_base_and_empty_at_head() {
2405 let tmp = tempfile::tempdir().unwrap();
2406 let dir = tmp.path();
2407 init_bare_repo(dir);
2408 commit_file(dir, "a", "0", "initial");
2409 let base = get_head_commit_in(dir).unwrap();
2410 commit_file(dir, "a", "1", "second");
2411 let head = get_head_commit_in(dir).unwrap();
2412
2413 let shas = commits_between_in(dir, &base).unwrap();
2414 assert_eq!(
2415 shas,
2416 vec![head.clone()],
2417 "exactly the one commit above base"
2418 );
2419 assert!(commits_between_in(dir, &head).unwrap().is_empty());
2421 }
2422
2423 #[test]
2424 fn commit_subject_in_returns_single_commit_subject() {
2425 let tmp = tempfile::tempdir().unwrap();
2426 let dir = tmp.path();
2427 init_bare_repo(dir);
2428 commit_file(dir, "a", "0", "feat: only-subject\n\nignored body");
2429 let head = get_head_commit_in(dir).unwrap();
2430 assert_eq!(commit_subject_in(dir, &head).unwrap(), "feat: only-subject");
2431 }
2432
2433 #[test]
2434 fn head_commit_timestamp_in_returns_pinned_committer_epoch() {
2435 let tmp = tempfile::tempdir().unwrap();
2436 let dir = tmp.path();
2437 init_bare_repo(dir);
2438 commit_file(dir, "a", "0", "initial");
2440 assert_eq!(head_commit_timestamp_in(dir).unwrap(), 1_715_000_000);
2441 }
2442
2443 #[test]
2446 fn log_subjects_for_range_returns_full_bodies_for_path() {
2447 let tmp = tempfile::tempdir().unwrap();
2448 let dir = tmp.path();
2449 init_bare_repo(dir);
2450 commit_file(dir, "watched", "0", "feat: A\n\nbody of A");
2451 commit_file(dir, "watched", "1", "fix: B");
2452
2453 let bodies = log_subjects_for_range(dir, "HEAD", "watched").unwrap();
2454 assert_eq!(bodies.len(), 2);
2455 assert!(bodies[0].starts_with("fix: B"));
2457 assert!(bodies[1].contains("feat: A") && bodies[1].contains("body of A"));
2458 }
2459
2460 #[test]
2461 fn log_subjects_for_range_returns_empty_when_range_invalid() {
2462 let tmp = tempfile::tempdir().unwrap();
2463 let dir = tmp.path();
2464 init_bare_repo(dir);
2465 commit_file(dir, "a", "0", "initial");
2466 let bodies = log_subjects_for_range(dir, "no-such-ref..HEAD", "a").unwrap();
2469 assert!(bodies.is_empty());
2470 }
2471
2472 #[test]
2475 fn add_path_in_then_commit_in_creates_commit() {
2476 let tmp = tempfile::tempdir().unwrap();
2477 let dir = tmp.path();
2478 init_bare_repo(dir);
2479 commit_file(dir, "seed", "0", "initial");
2480 std::fs::write(dir.join("new.txt"), "hello").unwrap();
2481
2482 add_path_in(dir, std::path::Path::new("new.txt")).unwrap();
2483 commit_in(dir, "feat: add new.txt", false).unwrap();
2484
2485 let subject = String::from_utf8(
2486 Command::new("git")
2487 .args(["log", "-1", "--pretty=%s"])
2488 .current_dir(dir)
2489 .output()
2490 .unwrap()
2491 .stdout,
2492 )
2493 .unwrap()
2494 .trim()
2495 .to_string();
2496 assert_eq!(subject, "feat: add new.txt");
2497 }
2498
2499 #[test]
2500 fn add_path_in_errors_on_missing_file() {
2501 let tmp = tempfile::tempdir().unwrap();
2502 let dir = tmp.path();
2503 init_bare_repo(dir);
2504 let err = add_path_in(dir, std::path::Path::new("does-not-exist")).unwrap_err();
2505 assert!(
2506 err.to_string().contains("git add"),
2507 "error must name the failing git add: {err}"
2508 );
2509 }
2510
2511 #[test]
2514 fn reset_hard_in_moves_head_and_restores_tree() {
2515 let tmp = tempfile::tempdir().unwrap();
2516 let dir = tmp.path();
2517 init_bare_repo(dir);
2518 commit_file(dir, "a", "first", "first");
2519 let target = get_head_commit_in(dir).unwrap();
2520 commit_file(dir, "a", "second", "second");
2521 assert_ne!(get_head_commit_in(dir).unwrap(), target);
2522
2523 reset_hard_in(dir, &target).unwrap();
2524 assert_eq!(get_head_commit_in(dir).unwrap(), target, "HEAD moved back");
2525 assert_eq!(
2526 std::fs::read_to_string(dir.join("a")).unwrap(),
2527 "first",
2528 "working tree restored to target content"
2529 );
2530 }
2531
2532 #[test]
2535 fn push_branch_in_bails_without_origin_remote() {
2536 let tmp = tempfile::tempdir().unwrap();
2537 let dir = tmp.path();
2538 init_bare_repo(dir);
2539 commit_file(dir, "a", "0", "initial");
2540 let err = push_branch_in(dir, "master").unwrap_err();
2541 assert!(
2542 err.to_string().contains("no 'origin' remote"),
2543 "missing-remote bail must be explicit: {err}"
2544 );
2545 }
2546
2547 #[test]
2550 #[serial_test::serial]
2551 fn resolve_rollback_identity_inherits_when_repo_has_identity() {
2552 let tmp = tempfile::tempdir().unwrap();
2553 let dir = tmp.path();
2554 init_bare_repo(dir); struct EnvGuard(Vec<(&'static str, Option<String>)>);
2559 impl Drop for EnvGuard {
2560 fn drop(&mut self) {
2561 for (k, v) in &self.0 {
2562 match v {
2563 Some(val) => unsafe { std::env::set_var(k, val) },
2564 None => unsafe { std::env::remove_var(k) },
2565 }
2566 }
2567 }
2568 }
2569 let keys = [
2570 "GIT_AUTHOR_NAME",
2571 "GIT_AUTHOR_EMAIL",
2572 "GIT_COMMITTER_NAME",
2573 "GIT_COMMITTER_EMAIL",
2574 ];
2575 let _g = EnvGuard(keys.iter().map(|k| (*k, std::env::var(k).ok())).collect());
2576 for k in keys {
2577 unsafe { std::env::remove_var(k) };
2578 }
2579
2580 let id = resolve_rollback_identity(dir);
2582 assert!(
2583 id.name.is_none() && id.email.is_none(),
2584 "configured repo identity must be inherited, not overridden: {id:?}"
2585 );
2586 }
2587
2588 #[test]
2589 #[serial_test::serial]
2590 fn resolve_rollback_identity_synthesizes_when_no_identity_anywhere() {
2591 let tmp = tempfile::tempdir().unwrap();
2592 let dir = tmp.path();
2593 g(dir, &["init", "-b", "master"]);
2595
2596 struct EnvGuard(Vec<(&'static str, Option<String>)>);
2597 impl Drop for EnvGuard {
2598 fn drop(&mut self) {
2599 for (k, v) in &self.0 {
2600 match v {
2601 Some(val) => unsafe { std::env::set_var(k, val) },
2602 None => unsafe { std::env::remove_var(k) },
2603 }
2604 }
2605 }
2606 }
2607 let keys = [
2608 "GIT_AUTHOR_NAME",
2609 "GIT_AUTHOR_EMAIL",
2610 "GIT_COMMITTER_NAME",
2611 "GIT_COMMITTER_EMAIL",
2612 ];
2613 let _g = EnvGuard(keys.iter().map(|k| (*k, std::env::var(k).ok())).collect());
2614 for k in keys {
2615 unsafe { std::env::remove_var(k) };
2616 }
2617
2618 let (n, e) = read_git_identity(dir);
2621 if n.is_none() || e.is_none() {
2622 let id = resolve_rollback_identity(dir);
2623 assert_eq!(id.name.as_deref(), Some("anodize-rollback"));
2624 assert!(
2625 id.email
2626 .as_deref()
2627 .unwrap_or("")
2628 .starts_with("anodize-rollback@"),
2629 "synthetic identity required when no config present: {id:?}"
2630 );
2631 }
2632 }
2633
2634 #[test]
2635 fn read_git_identity_reads_configured_values() {
2636 let tmp = tempfile::tempdir().unwrap();
2637 let dir = tmp.path();
2638 g(dir, &["init", "-b", "master"]);
2639 g(dir, &["config", "user.name", "Configured Name"]);
2640 g(dir, &["config", "user.email", "configured@x.com"]);
2641
2642 let (name, email) = read_git_identity(dir);
2643 assert_eq!(name.as_deref(), Some("Configured Name"));
2644 assert_eq!(email.as_deref(), Some("configured@x.com"));
2645 }
2646}