1use crate::config::GitFlowConfig;
4use crate::phase_id::PhaseId;
5use std::path::{Path, PathBuf};
6use std::process::Command;
7use tracing::{debug, info, warn};
8
9#[derive(Debug, thiserror::Error)]
11pub enum GitError {
12 #[error("failed to execute git: {0}")]
14 Io(#[from] std::io::Error),
15 #[error("git command failed: {0}")]
17 Command(String),
18}
19
20pub const REPO_LOCAL_GIT_VARS: &[&str] = &[
28 "GIT_ALTERNATE_OBJECT_DIRECTORIES",
29 "GIT_CONFIG",
30 "GIT_CONFIG_PARAMETERS",
31 "GIT_CONFIG_COUNT",
32 "GIT_OBJECT_DIRECTORY",
33 "GIT_DIR",
34 "GIT_WORK_TREE",
35 "GIT_IMPLICIT_WORK_TREE",
36 "GIT_GRAFT_FILE",
37 "GIT_INDEX_FILE",
38 "GIT_NO_REPLACE_OBJECTS",
39 "GIT_REPLACE_REF_BASE",
40 "GIT_PREFIX",
41 "GIT_SHALLOW_FILE",
42 "GIT_COMMON_DIR",
43];
44
45pub const ALSO_REDIRECTING_GIT_VARS: &[&str] = &[
56 "GIT_NAMESPACE",
57 "GIT_DISCOVERY_ACROSS_FILESYSTEM",
58 "GIT_CEILING_DIRECTORIES",
59];
60
61pub fn git_command(repo: &Path) -> Command {
73 hermetic_command("git", repo)
74}
75
76pub fn hermetic_command(program: &str, dir: &Path) -> Command {
88 let mut cmd = Command::new(program);
89 cmd.current_dir(dir);
90 for var in REPO_LOCAL_GIT_VARS.iter().chain(ALSO_REDIRECTING_GIT_VARS) {
91 cmd.env_remove(var);
92 }
93 cmd
94}
95
96#[derive(Debug, Clone)]
98pub struct GitFlow {
99 root: PathBuf,
100 config: GitFlowConfig,
101}
102
103#[derive(Debug, Clone)]
105pub struct BranchInfo {
106 pub name: String,
108 pub ahead: usize,
110 pub behind: usize,
112 pub last_commit: String,
114}
115
116impl GitFlow {
117 pub fn new(root: impl AsRef<Path>) -> Self {
120 Self {
121 root: root.as_ref().to_path_buf(),
122 config: GitFlowConfig::default(),
123 }
124 }
125
126 pub fn feature_start(&self, phase: PhaseId) -> Result<String, GitError> {
131 let branch = format!("{}phase-{}", self.config.feature_prefix, phase.padded());
132 info!("creating feature branch: {branch}");
133 self.git(["checkout", &self.config.develop])?;
134 self.git(["checkout", "-b", &branch])?;
135 Ok(branch)
136 }
137
138 pub fn feature_start_force(&self, phase: PhaseId) -> Result<String, GitError> {
140 let branch = format!("{}phase-{}", self.config.feature_prefix, phase.padded());
141 warn!("force-creating feature branch: {branch}");
142 self.git(["checkout", &self.config.develop])?;
143 self.git(["checkout", "-B", &branch])?;
144 Ok(branch)
145 }
146
147 pub fn feature_finish(&self, phase: PhaseId) -> Result<String, GitError> {
149 let branch = self.merge_feature_into_develop(phase)?;
150 self.git(["branch", "-d", &branch])?;
151 Ok(branch)
152 }
153
154 pub fn merge_feature_into_develop(&self, phase: PhaseId) -> Result<String, GitError> {
159 let branch = format!("{}phase-{}", self.config.feature_prefix, phase.padded());
160 info!("merging feature branch: {branch}");
161 self.git(["checkout", &self.config.develop])?;
162 self.git(["merge", "--no-ff", &branch])?;
163 Ok(branch)
164 }
165
166 pub fn is_merged_into_develop(&self, phase: PhaseId) -> bool {
171 let branch = format!("{}phase-{}", self.config.feature_prefix, phase.padded());
172 if !self.branch_exists(&branch) {
173 return false;
174 }
175
176 git_command(&self.root)
177 .args(["merge-base", "--is-ancestor", &branch, &self.config.develop])
178 .output()
179 .map(|output| output.status.success())
180 .unwrap_or(false)
181 }
182
183 pub fn release_start(&self, version: &str) -> Result<String, GitError> {
190 let branch = format!("release/{version}");
191 info!("creating release branch: {branch}");
192 self.git(["checkout", "-B", &branch])?;
193 Ok(branch)
194 }
195
196 pub fn release_finish(&self, version: &str) -> Result<String, GitError> {
198 let branch = format!("release/{version}");
199 info!("finishing release branch: {branch}");
200 self.git(["checkout", &self.config.main])?;
201 self.git(["merge", "--no-ff", &branch])?;
202 self.git(["-c", "tag.gpgSign=false", "tag", &format!("v{version}")])?;
209 self.git(["checkout", &self.config.develop])?;
210 self.git(["merge", "--no-ff", &branch])?;
211 self.git(["branch", "-d", &branch])?;
212 Ok(branch)
213 }
214
215 pub fn tag(&self, tag: &str) -> Result<(), GitError> {
224 info!("tagging {tag}");
225 self.git(["-c", "tag.gpgSign=false", "tag", tag])
226 }
227
228 pub fn delete_branch(&self, branch: &str, force: bool) -> Result<(), GitError> {
234 if branch == self.config.main || branch == self.config.develop {
235 return Err(GitError::Command(format!(
236 "refusing to delete protected branch `{branch}`"
237 )));
238 }
239 let flag = if force { "-D" } else { "-d" };
240 if force {
241 warn!("force-deleting branch: {branch}");
242 } else {
243 info!("deleting branch: {branch}");
244 }
245 self.git(["branch", flag, branch])
246 }
247
248 pub fn branch_exists(&self, branch: &str) -> bool {
250 git_command(&self.root)
251 .args([
252 "rev-parse",
253 "--verify",
254 "--quiet",
255 &format!("refs/heads/{branch}"),
256 ])
257 .output()
258 .map(|o| o.status.success())
259 .unwrap_or(false)
260 }
261
262 pub fn branch_tip(&self, branch: &str) -> Result<String, GitError> {
264 Ok(self.git_output(["rev-parse", branch])?.trim().to_string())
265 }
266
267 pub fn ensure_branch(&self, branch: &str, start_point: &str) -> Result<(), GitError> {
270 if self.branch_exists(branch) {
271 return Ok(());
272 }
273 self.git(["branch", branch, start_point])
274 }
275
276 pub fn checkout(&self, branch: &str) -> Result<(), GitError> {
278 debug!("checking out branch: {branch}");
279 self.git(["checkout", branch])
280 }
281
282 pub fn delete_remote_branch(&self, branch: &str) -> Result<(), GitError> {
284 info!("deleting remote branch: {branch}");
285 self.git(["push", "origin", "--delete", branch])
286 }
287
288 pub fn has_remote(&self) -> bool {
290 self.git_output(["remote"])
291 .map(|s| !s.trim().is_empty())
292 .unwrap_or(false)
293 }
294
295 pub fn push(&self, branch: &str) -> Result<(), GitError> {
297 info!("pushing branch: {branch}");
298 self.git(["push", "-u", "origin", branch])
299 }
300
301 pub fn cleanup_merged(&self) -> Result<Vec<String>, GitError> {
317 let output = self.git_output(["branch", "--merged", &self.config.develop])?;
318 let protected = [self.config.main.as_str(), self.config.develop.as_str()];
319 let mut deleted = Vec::new();
320 for line in output.lines() {
321 let branch = line
327 .strip_prefix("* ")
328 .or_else(|| line.strip_prefix("+ "))
329 .unwrap_or(line)
330 .trim();
331 if branch.is_empty() || branch.starts_with('(') || protected.contains(&branch) {
334 continue;
335 }
336 info!("cleaning up merged branch: {branch}");
337 match self.git(["branch", "-D", branch]) {
338 Ok(()) => deleted.push(branch.to_string()),
339 Err(err) => warn!("could not delete merged branch {branch}: {err}"),
340 }
341 }
342 Ok(deleted)
343 }
344
345 pub fn commit_all(&self, message: &str) -> Result<(), GitError> {
348 debug!("committing all changes: {message}");
349 self.git(["add", "."])?;
350 match self.git_raw(&["commit", "--allow-empty", "-m", message]) {
352 Ok(()) => Ok(()),
353 Err(GitError::Command(ref msg)) if msg.contains("nothing to commit") => Ok(()),
356 Err(e) => Err(e),
357 }
358 }
359
360 pub fn commit_path(&self, relative_path: &str, message: &str) -> Result<(), GitError> {
369 debug!("committing {relative_path}: {message}");
370 self.git(["add", relative_path])?;
376 match self.git_raw_combined(&["commit", "-m", message, "--", relative_path]) {
377 Ok(()) => Ok(()),
378 Err(GitError::Command(ref msg)) if msg.contains("nothing to commit") => Ok(()),
383 Err(e) => Err(e),
384 }
385 }
386
387 pub fn divergence_from_develop(&self) -> Result<(usize, usize), GitError> {
393 let current = self
394 .git_output(["rev-parse", "--abbrev-ref", "HEAD"])?
395 .trim()
396 .to_string();
397 if current == self.config.develop {
398 return Ok((0, 0));
399 }
400 let ahead = self
401 .rev_count(&format!("{}..{current}", self.config.develop))
402 .unwrap_or(0);
403 let behind = self
404 .rev_count(&format!("{current}..{}", self.config.develop))
405 .unwrap_or(0);
406 Ok((ahead, behind))
407 }
408
409 pub fn list_feature_branches(&self) -> Result<Vec<BranchInfo>, GitError> {
414 let prefix = &self.config.feature_prefix;
415 let branches = self.git_output(["branch", "--format=%(refname:short)"])?;
416 let mut result = Vec::new();
417 for name in branches.lines().map(|l| l.trim()) {
418 if name.is_empty()
419 || name == self.config.main
420 || name == self.config.develop
421 || !name.starts_with(prefix)
422 {
423 continue;
424 }
425 let ahead = self
426 .rev_count(&format!("{dev}..{name}", dev = self.config.develop))
427 .unwrap_or(0);
428 let behind = self
429 .rev_count(&format!("{name}..{dev}", dev = self.config.develop))
430 .unwrap_or(0);
431 let last_commit = self
432 .git_output(["log", "-1", "--format=%aI", name])
433 .map(|s| s.trim().to_string())
434 .unwrap_or_default();
435 result.push(BranchInfo {
436 name: name.to_string(),
437 ahead,
438 behind,
439 last_commit,
440 });
441 }
442 result.sort_by(|a, b| a.name.cmp(&b.name));
444 Ok(result)
445 }
446
447 fn rev_count(&self, range: &str) -> Option<usize> {
449 self.git_output(["rev-list", "--count", range])
450 .ok()
451 .and_then(|s| s.trim().parse().ok())
452 }
453
454 fn git_raw(&self, args: &[&str]) -> Result<(), GitError> {
455 debug!("git {}", args.join(" "));
456 let output = git_command(&self.root)
462 .args(args)
463 .env("LC_ALL", "C")
464 .env("LANG", "C")
465 .output()?;
466 if output.status.success() {
467 Ok(())
468 } else {
469 Err(GitError::Command(stderr_or_status(&output)))
470 }
471 }
472
473 fn git_raw_combined(&self, args: &[&str]) -> Result<(), GitError> {
487 debug!("git {}", args.join(" "));
488 let output = git_command(&self.root)
489 .args(args)
490 .env("LC_ALL", "C")
491 .env("LANG", "C")
492 .output()?;
493 if output.status.success() {
494 Ok(())
495 } else {
496 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
497 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
498 let combined = match (stderr.is_empty(), stdout.is_empty()) {
499 (false, false) => format!("{stderr}\n{stdout}"),
500 (false, true) => stderr,
501 (true, false) => stdout,
502 (true, true) => format!("exited with {}", output.status),
503 };
504 Err(GitError::Command(combined))
505 }
506 }
507
508 fn git<const N: usize>(&self, args: [&str; N]) -> Result<(), GitError> {
509 debug!("git {}", args.iter().copied().collect::<Vec<_>>().join(" "));
510 let output = git_command(&self.root).args(args).output()?;
511 if output.status.success() {
512 Ok(())
513 } else {
514 Err(GitError::Command(stderr_or_status(&output)))
515 }
516 }
517
518 fn git_output<const N: usize>(&self, args: [&str; N]) -> Result<String, GitError> {
519 let output = git_command(&self.root).args(args).output()?;
520 if output.status.success() {
521 Ok(String::from_utf8_lossy(&output.stdout).to_string())
522 } else {
523 Err(GitError::Command(stderr_or_status(&output)))
524 }
525 }
526}
527
528#[derive(Debug, Clone, Copy, PartialEq, Eq)]
533pub enum AncestorStatus {
534 Ancestor,
536 Diverged,
540 RefAbsent,
545}
546
547pub fn origin_main_ancestor_status(project_root: &Path) -> AncestorStatus {
554 let ref_exists = git_command(project_root)
555 .args(["rev-parse", "--verify", "--quiet", "origin/main"])
556 .output()
557 .map(|out| out.status.success())
558 .unwrap_or(false);
559 if !ref_exists {
560 return AncestorStatus::RefAbsent;
561 }
562 let is_ancestor = git_command(project_root)
563 .args(["merge-base", "--is-ancestor", "origin/main", "HEAD"])
564 .output()
565 .map(|out| out.status.success())
566 .unwrap_or(false);
567 if is_ancestor {
568 AncestorStatus::Ancestor
569 } else {
570 AncestorStatus::Diverged
571 }
572}
573
574pub fn ref_is_ancestor(project_root: &Path, ancestor: &str, descendant: &str) -> AncestorStatus {
583 let both_exist = [ancestor, descendant].iter().all(|r| {
584 git_command(project_root)
585 .args(["rev-parse", "--verify", "--quiet", r])
586 .output()
587 .map(|out| out.status.success())
588 .unwrap_or(false)
589 });
590 if !both_exist {
591 return AncestorStatus::RefAbsent;
592 }
593 let is_ancestor = git_command(project_root)
594 .args(["merge-base", "--is-ancestor", ancestor, descendant])
595 .output()
596 .map(|out| out.status.success())
597 .unwrap_or(false);
598 if is_ancestor {
599 AncestorStatus::Ancestor
600 } else {
601 AncestorStatus::Diverged
602 }
603}
604
605pub fn publish_order(project_root: &Path) -> Vec<String> {
612 let Ok(root_contents) = std::fs::read_to_string(project_root.join("Cargo.toml")) else {
613 return Vec::new();
614 };
615 let member_paths = workspace_member_paths(&root_contents);
616
617 let mut members: Vec<(String, String)> = Vec::new();
618 for path in &member_paths {
619 let manifest = project_root.join(path).join("Cargo.toml");
620 let Ok(contents) = std::fs::read_to_string(&manifest) else {
621 continue;
622 };
623 let name = package_name(&contents).unwrap_or_else(|| path.clone());
624 members.push((name, contents));
625 }
626
627 let names: Vec<String> = members.iter().map(|(name, _)| name.clone()).collect();
628 let mut edges: Vec<(String, String)> = Vec::new();
629 for (name, contents) in &members {
630 for other in &names {
631 if other != name && member_depends_on(contents, other) {
632 edges.push((name.clone(), other.clone()));
633 }
634 }
635 }
636 topo_sort(names, edges)
637}
638
639fn workspace_member_paths(contents: &str) -> Vec<String> {
644 let Some(start) = contents.find("members") else {
645 return Vec::new();
646 };
647 let rest = &contents[start..];
648 let Some(open) = rest.find('[') else {
649 return Vec::new();
650 };
651 let Some(close) = rest[open..].find(']') else {
652 return Vec::new();
653 };
654 let inner = &rest[open + 1..open + close];
655 inner
656 .split(',')
657 .filter_map(|fragment| {
658 let fragment = fragment.trim();
659 let fragment = fragment.strip_prefix('"')?.strip_suffix('"')?;
660 (!fragment.is_empty()).then(|| fragment.to_string())
661 })
662 .collect()
663}
664
665fn package_name(contents: &str) -> Option<String> {
667 let mut current = String::new();
668 for line in contents.lines() {
669 let trimmed = line.trim();
670 if let Some(inner) = trimmed.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
671 current = inner.trim().to_string();
672 continue;
673 }
674 if current == "package"
675 && let Some((key, value)) = trimmed.split_once('=')
676 && key.trim() == "name"
677 {
678 return Some(value.trim().trim_matches('"').to_string());
679 }
680 }
681 None
682}
683
684fn member_depends_on(contents: &str, dep_name: &str) -> bool {
695 let mut current = String::new();
696 for line in contents.lines() {
697 let trimmed = line.trim();
698 if let Some(inner) = trimmed.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
699 current = inner.trim().to_string();
700 if let Some(name) = current.strip_prefix("dependencies.")
701 && name == dep_name
702 {
703 return true;
704 }
705 continue;
706 }
707 if current != "dependencies" {
708 continue;
709 }
710 let key = trimmed.split(['.', '=']).next().unwrap_or("").trim();
711 if key == dep_name {
712 return true;
713 }
714 }
715 false
716}
717
718fn topo_sort(names: Vec<String>, edges: Vec<(String, String)>) -> Vec<String> {
724 let mut result = Vec::new();
725 let mut published: Vec<String> = Vec::new();
726 let mut remaining = names;
727 while !remaining.is_empty() {
728 let ready: Vec<String> = remaining
729 .iter()
730 .filter(|name| {
731 edges
732 .iter()
733 .filter(|(dependent, _)| dependent == *name)
734 .all(|(_, dep)| published.contains(dep))
735 })
736 .cloned()
737 .collect();
738 if ready.is_empty() {
739 result.extend(remaining);
740 break;
741 }
742 for name in &ready {
743 published.push(name.clone());
744 result.push(name.clone());
745 }
746 remaining.retain(|name| !ready.contains(name));
747 }
748 result
749}
750
751fn stderr_or_status(output: &std::process::Output) -> String {
764 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
765 if stderr.is_empty() {
766 format!("exited with {}", output.status)
767 } else {
768 stderr
769 }
770}
771
772#[cfg(test)]
773mod tests {
774 use super::*;
775 use tempfile::TempDir;
776
777 fn git(root: &Path, args: &[&str]) {
779 let output = crate::test_support::git_command(root)
780 .args(args)
781 .output()
782 .expect("spawn git");
783 assert!(
784 output.status.success(),
785 "git {args:?} failed: {}",
786 String::from_utf8_lossy(&output.stderr)
787 );
788 }
789
790 fn current_branch(root: &Path) -> String {
791 let output = crate::test_support::git_command(root)
792 .args(["rev-parse", "--abbrev-ref", "HEAD"])
793 .output()
794 .expect("rev-parse");
795 String::from_utf8_lossy(&output.stdout).trim().to_string()
796 }
797
798 fn commit_file(root: &Path, name: &str) {
799 std::fs::write(root.join(name), name).unwrap();
800 git(root, &["add", "."]);
801 git(root, &["commit", "-q", "-m", &format!("add {name}")]);
802 }
803
804 fn init_repo() -> TempDir {
806 let dir = tempfile::tempdir().unwrap();
807 let root = dir.path();
808 git(root, &["init", "-q"]);
809 git(root, &["config", "user.email", "test@example.com"]);
810 git(root, &["config", "user.name", "Test"]);
811 git(root, &["config", "commit.gpgsign", "false"]);
812 git(root, &["config", "tag.gpgsign", "false"]);
813 git(root, &["config", "core.hooksPath", "/dev/null"]);
815 commit_file(root, "README.md");
816 git(root, &["branch", "-M", "main"]);
817 git(root, &["checkout", "-q", "-b", "develop"]);
818 dir
819 }
820
821 fn flow(root: &Path) -> GitFlow {
822 GitFlow::new(root)
823 }
824
825 #[test]
826 fn feature_start_branches_from_develop() {
827 let repo = init_repo();
828 let root = repo.path();
829 let branch = flow(root)
830 .feature_start(PhaseId::new(3))
831 .expect("feature_start");
832 assert_eq!(branch, "feature/phase-03");
833 assert_eq!(current_branch(root), "feature/phase-03");
834 }
835
836 #[test]
837 fn list_feature_branches_reports_ahead_and_behind_semantics() {
838 let repo = init_repo();
839 let root = repo.path();
840 let gf = flow(root);
841
842 gf.feature_start(PhaseId::new(12)).expect("feature_start");
843 commit_file(root, "feature-one.txt");
844 commit_file(root, "feature-two.txt");
845 git(root, &["checkout", "-q", "develop"]);
846 commit_file(root, "develop-only.txt");
847
848 let branches = gf.list_feature_branches().unwrap();
849 let branch = branches
850 .iter()
851 .find(|branch| branch.name == "feature/phase-12")
852 .unwrap();
853
854 assert_eq!(branch.ahead, 2);
855 assert_eq!(branch.behind, 1);
856 }
857
858 #[test]
859 fn feature_finish_merges_into_develop_and_deletes() {
860 let repo = init_repo();
861 let root = repo.path();
862 let gf = flow(root);
863
864 gf.feature_start(PhaseId::new(1)).expect("start");
865 commit_file(root, "feature.txt");
866
867 let branch = gf.feature_finish(PhaseId::new(1)).expect("finish");
868 assert_eq!(branch, "feature/phase-01");
869 assert_eq!(current_branch(root), "develop");
870
871 let branches = crate::test_support::git_command(root)
873 .args(["branch"])
874 .output()
875 .unwrap();
876 let listing = String::from_utf8_lossy(&branches.stdout);
877 assert!(!listing.contains("feature/phase-01"));
878 assert!(root.join("feature.txt").exists());
879 }
880
881 #[test]
882 fn release_start_and_finish_tags_main_and_merges_both() {
883 let repo = init_repo();
884 let root = repo.path();
885 let gf = flow(root);
886
887 commit_file(root, "work.txt");
889 let branch = gf.release_start("1.2.0").expect("release_start");
890 assert_eq!(branch, "release/1.2.0");
891
892 gf.release_finish("1.2.0").expect("release_finish");
893 assert_eq!(current_branch(root), "develop");
894
895 let tags = crate::test_support::git_command(root)
897 .args(["tag"])
898 .output()
899 .unwrap();
900 assert!(String::from_utf8_lossy(&tags.stdout).contains("v1.2.0"));
901
902 let branches = crate::test_support::git_command(root)
904 .args(["branch"])
905 .output()
906 .unwrap();
907 assert!(!String::from_utf8_lossy(&branches.stdout).contains("release/1.2.0"));
908 }
909
910 #[test]
917 fn tag_stays_lightweight_when_gpgsign_is_forced_on() {
918 let repo = init_repo();
919 let root = repo.path();
920 git(root, &["config", "tag.gpgsign", "true"]);
924
925 flow(root)
926 .tag("v9.9.9")
927 .expect("tag must not block on $EDITOR");
928
929 let tags = crate::test_support::git_command(root)
930 .args(["tag", "-l"])
931 .output()
932 .unwrap();
933 assert!(String::from_utf8_lossy(&tags.stdout).contains("v9.9.9"));
934
935 let obj_type = crate::test_support::git_command(root)
939 .args(["cat-file", "-t", "v9.9.9"])
940 .output()
941 .unwrap();
942 assert_eq!(
943 String::from_utf8_lossy(&obj_type.stdout).trim(),
944 "commit",
945 "tag() must stay lightweight even when tag.gpgsign=true"
946 );
947 }
948
949 #[test]
950 fn commit_path_stages_only_the_given_path_leaving_other_dirt_uncommitted() {
951 let repo = init_repo();
955 let root = repo.path();
956 std::fs::write(root.join("CHANGELOG.md"), "# Changelog\n").unwrap();
957 std::fs::write(root.join("unrelated.txt"), "not part of this commit\n").unwrap();
958
959 crate::test_support::git_command(root)
964 .args(["add", "unrelated.txt"])
965 .status()
966 .unwrap();
967
968 flow(root)
969 .commit_path("CHANGELOG.md", "docs: add changelog entry")
970 .expect("commit_path");
971
972 let committed = crate::test_support::git_command(root)
973 .args(["log", "-1", "--name-only", "--pretty=format:"])
974 .output()
975 .unwrap();
976 let committed_files = String::from_utf8_lossy(&committed.stdout);
977 assert!(committed_files.contains("CHANGELOG.md"));
978 assert!(!committed_files.contains("unrelated.txt"));
979
980 let status = crate::test_support::git_command(root)
981 .args(["status", "--porcelain"])
982 .output()
983 .unwrap();
984 let status = String::from_utf8_lossy(&status.stdout);
985 assert!(
986 status.contains("A unrelated.txt"),
987 "unrelated.txt must remain staged-but-uncommitted, got: {status}"
988 );
989 }
990
991 fn rev_list_count(root: &Path) -> u32 {
994 let output = crate::test_support::git_command(root)
995 .args(["rev-list", "--count", "HEAD"])
996 .output()
997 .unwrap();
998 assert!(output.status.success(), "git rev-list --count HEAD failed");
999 String::from_utf8_lossy(&output.stdout)
1000 .trim()
1001 .parse::<u32>()
1002 .expect("rev-list --count HEAD must print an integer")
1003 }
1004
1005 #[test]
1013 fn commit_path_twice_with_identical_content_creates_only_one_commit() {
1014 let repo = init_repo();
1015 let root = repo.path();
1016 std::fs::write(root.join("CHANGELOG.md"), "# Changelog\n").unwrap();
1017
1018 flow(root)
1019 .commit_path("CHANGELOG.md", "docs: add changelog entry")
1020 .expect("first commit_path call");
1021 let n1 = rev_list_count(root);
1022
1023 flow(root)
1026 .commit_path("CHANGELOG.md", "docs: add changelog entry")
1027 .expect("second commit_path call");
1028 let n2 = rev_list_count(root);
1029
1030 assert_eq!(
1031 n2, n1,
1032 "a repeat commit_path call on unchanged content must not add a \
1033 commit: n1={n1}, n2={n2}"
1034 );
1035 }
1036
1037 #[test]
1045 fn commit_path_with_no_changes_returns_ok_without_committing() {
1046 let repo = init_repo();
1047 let root = repo.path();
1048 std::fs::write(root.join("CHANGELOG.md"), "# Changelog\n").unwrap();
1049 flow(root)
1050 .commit_path("CHANGELOG.md", "docs: add changelog entry")
1051 .expect("initial commit_path");
1052 let n1 = rev_list_count(root);
1053
1054 let result = flow(root).commit_path("CHANGELOG.md", "docs: add changelog entry");
1057 let n2 = rev_list_count(root);
1058
1059 assert!(
1060 result.is_ok(),
1061 "no-op call must return Ok(()), got: {result:?}"
1062 );
1063 assert_eq!(
1064 n2, n1,
1065 "no-op call must not create a commit: n1={n1}, n2={n2}"
1066 );
1067 }
1068
1069 #[test]
1075 fn commit_path_on_nonexistent_path_still_errors() {
1076 let repo = init_repo();
1077 let root = repo.path();
1078
1079 let result = flow(root).commit_path("does-not-exist.md", "docs: add changelog entry");
1080
1081 assert!(
1082 result.is_err(),
1083 "commit_path on an unknown pathspec must still error, got: {result:?}"
1084 );
1085 }
1086
1087 #[test]
1088 fn release_start_branches_from_current_head_not_develop() {
1089 let repo = init_repo();
1090 let root = repo.path();
1091 let gf = flow(root);
1092
1093 gf.feature_start(PhaseId::new(5)).expect("feature_start");
1095 commit_file(root, "feature-only.txt");
1096 let feature_tip = gf.branch_tip("feature/phase-05").expect("feature tip");
1097
1098 let branch = gf.release_start("2.0.0").expect("release_start");
1099 assert_eq!(branch, "release/2.0.0");
1100 assert_eq!(current_branch(root), "release/2.0.0");
1101
1102 let release_tip = gf.branch_tip("release/2.0.0").expect("release tip");
1105 let is_ancestor = crate::test_support::git_command(root)
1106 .args(["merge-base", "--is-ancestor", &feature_tip, &release_tip])
1107 .output()
1108 .unwrap()
1109 .status
1110 .success();
1111 assert!(
1112 is_ancestor,
1113 "release branch must descend from the shipped feature commit"
1114 );
1115 assert!(root.join("feature-only.txt").exists());
1116 }
1117
1118 #[test]
1119 fn cleanup_merged_removes_merged_but_keeps_protected() {
1120 let repo = init_repo();
1121 let root = repo.path();
1122 let gf = flow(root);
1123
1124 gf.feature_start(PhaseId::new(2)).expect("start");
1126 commit_file(root, "f.txt");
1127 gf.feature_finish(PhaseId::new(2)).expect("finish");
1128
1129 git(root, &["branch", "stale-merged"]);
1131
1132 let deleted = gf.cleanup_merged().expect("cleanup");
1133 assert!(deleted.contains(&"stale-merged".to_string()));
1134 assert!(!deleted.contains(&"develop".to_string()));
1136 assert!(!deleted.contains(&"main".to_string()));
1137 }
1138
1139 #[test]
1146 fn cleanup_merged_is_relative_to_develop_not_current_head() {
1147 let repo = init_repo();
1148 let root = repo.path();
1149 let gf = flow(root);
1150
1151 git(root, &["checkout", "-q", "-b", "topic", "develop"]);
1155 commit_file(root, "topic-only.txt");
1156 git(root, &["checkout", "-q", "-b", "premature", "topic"]);
1157
1158 git(root, &["checkout", "-q", "topic"]);
1166
1167 let _ = gf.cleanup_merged();
1168 assert!(
1169 gf.branch_exists("premature"),
1170 "premature is merged into topic (current HEAD) but not into \
1171 develop — it must survive cleanup_merged when the baseline is develop"
1172 );
1173 }
1174
1175 #[test]
1183 fn cleanup_merged_skips_worktree_branch_and_continues_sweep() {
1184 let repo = init_repo();
1185 let root = repo.path();
1186 let gf = flow(root);
1187
1188 git(
1191 root,
1192 &["checkout", "-q", "-b", "worktree-merged", "develop"],
1193 );
1194 commit_file(root, "g.txt");
1195 git(root, &["checkout", "-q", "develop"]);
1196 git(root, &["merge", "-q", "--no-ff", "worktree-merged"]);
1197
1198 let wt_dir = tempfile::tempdir().unwrap();
1201 git(
1202 root,
1203 &[
1204 "worktree",
1205 "add",
1206 wt_dir.path().to_str().unwrap(),
1207 "worktree-merged",
1208 ],
1209 );
1210
1211 git(root, &["branch", "aa-stale"]);
1215 git(root, &["branch", "zz-stale"]);
1216
1217 let deleted = gf
1218 .cleanup_merged()
1219 .expect("a skipped worktree branch must not abort the sweep");
1220 assert!(deleted.contains(&"aa-stale".to_string()));
1221 assert!(deleted.contains(&"zz-stale".to_string()));
1222 assert!(
1223 !deleted.contains(&"worktree-merged".to_string()),
1224 "worktree checkout cannot be deleted"
1225 );
1226 assert!(gf.branch_exists("worktree-merged"));
1227 }
1228
1229 #[test]
1234 fn cleanup_merged_deletes_when_head_is_not_on_develop() {
1235 let repo = init_repo();
1236 let root = repo.path();
1237 let gf = flow(root);
1238
1239 git(root, &["checkout", "-q", "-b", "old", "develop"]);
1242 git(root, &["checkout", "-q", "develop"]);
1243 git(root, &["checkout", "-q", "-b", "merged-feature", "develop"]);
1244 commit_file(root, "h.txt");
1245 git(root, &["checkout", "-q", "develop"]);
1246 git(root, &["merge", "-q", "--no-ff", "merged-feature"]);
1247 git(root, &["checkout", "-q", "old"]);
1248
1249 let deleted = gf.cleanup_merged().expect("cleanup");
1250 assert!(
1251 deleted.contains(&"merged-feature".to_string()),
1252 "merged-into-develop branch must be deleted even when HEAD is elsewhere: {deleted:?}"
1253 );
1254 assert!(!gf.branch_exists("merged-feature"));
1255 }
1256
1257 #[test]
1258 fn delete_branch_removes_unmerged_with_force_and_protects_trunk() {
1259 let repo = init_repo();
1260 let root = repo.path();
1261 let gf = flow(root);
1262
1263 gf.feature_start(PhaseId::new(8)).expect("start");
1265 commit_file(root, "unmerged.txt");
1266 git(root, &["checkout", "-q", "develop"]);
1268
1269 assert!(gf.delete_branch("feature/phase-08", false).is_err());
1271 gf.delete_branch("feature/phase-08", true)
1272 .expect("force delete");
1273 let branches = crate::test_support::git_command(root)
1274 .args(["branch"])
1275 .output()
1276 .unwrap();
1277 assert!(!String::from_utf8_lossy(&branches.stdout).contains("feature/phase-08"));
1278
1279 assert!(gf.delete_branch("develop", true).is_err());
1281 assert!(gf.delete_branch("main", true).is_err());
1282 }
1283
1284 #[test]
1285 fn merge_of_missing_branch_is_an_error() {
1286 let repo = init_repo();
1287 let root = repo.path();
1288 let err = flow(root).feature_finish(PhaseId::new(99)).unwrap_err();
1291 assert!(matches!(err, GitError::Command(_)));
1292 }
1293
1294 #[test]
1299 fn workspace_member_paths_parses_multiline_array() {
1300 let contents = "[workspace]\nresolver = \"2\"\nmembers = [\n \"crates/devflow-core\",\n \"crates/devflow-cli\",\n]\n";
1301 assert_eq!(
1302 workspace_member_paths(contents),
1303 vec![
1304 "crates/devflow-core".to_string(),
1305 "crates/devflow-cli".to_string()
1306 ]
1307 );
1308 }
1309
1310 #[test]
1311 fn package_name_reads_the_package_section() {
1312 let contents = "[package]\nname = \"devflow-core\"\nversion.workspace = true\n";
1313 assert_eq!(package_name(contents), Some("devflow-core".to_string()));
1314 }
1315
1316 #[test]
1317 fn member_depends_on_matches_dotted_workspace_shorthand() {
1318 let contents = "[package]\nname = \"devflow\"\n\n[dependencies]\ndevflow-core.workspace = true\nclap.workspace = true\n";
1319 assert!(member_depends_on(contents, "devflow-core"));
1320 assert!(!member_depends_on(contents, "serde"));
1321 }
1322
1323 #[test]
1329 fn member_depends_on_matches_long_form_dependency_section() {
1330 let contents = "[package]\nname = \"devflow\"\n\n[dependencies.devflow-core]\nworkspace = true\n\n[dependencies.clap]\nversion = \"4\"\n";
1331 assert!(member_depends_on(contents, "devflow-core"));
1332 assert!(member_depends_on(contents, "clap"));
1333 assert!(!member_depends_on(contents, "serde"));
1334 }
1335
1336 #[test]
1337 fn topo_sort_orders_dependency_before_dependent() {
1338 let names = vec!["devflow".to_string(), "devflow-core".to_string()];
1339 let edges = vec![("devflow".to_string(), "devflow-core".to_string())];
1340 assert_eq!(
1341 topo_sort(names, edges),
1342 vec!["devflow-core".to_string(), "devflow".to_string()]
1343 );
1344 }
1345
1346 #[test]
1347 fn topo_sort_falls_back_to_input_order_on_a_cycle() {
1348 let names = vec!["a".to_string(), "b".to_string()];
1351 let edges = vec![
1352 ("a".to_string(), "b".to_string()),
1353 ("b".to_string(), "a".to_string()),
1354 ];
1355 let result = topo_sort(names, edges);
1356 assert_eq!(result.len(), 2);
1357 }
1358
1359 #[test]
1360 fn publish_order_derives_core_before_cli_from_a_fixture_workspace() {
1361 let dir = tempfile::tempdir().unwrap();
1362 let root = dir.path();
1363 std::fs::write(
1364 root.join("Cargo.toml"),
1365 "[workspace]\nmembers = [\n \"crates/devflow-core\",\n \"crates/devflow-cli\",\n]\n",
1366 )
1367 .unwrap();
1368 std::fs::create_dir_all(root.join("crates/devflow-core")).unwrap();
1369 std::fs::write(
1370 root.join("crates/devflow-core/Cargo.toml"),
1371 "[package]\nname = \"devflow-core\"\n\n[dependencies]\n",
1372 )
1373 .unwrap();
1374 std::fs::create_dir_all(root.join("crates/devflow-cli")).unwrap();
1375 std::fs::write(
1376 root.join("crates/devflow-cli/Cargo.toml"),
1377 "[package]\nname = \"devflow\"\n\n[dependencies]\ndevflow-core.workspace = true\n",
1378 )
1379 .unwrap();
1380
1381 assert_eq!(
1382 publish_order(root),
1383 vec!["devflow-core".to_string(), "devflow".to_string()]
1384 );
1385 }
1386
1387 #[test]
1394 fn publish_order_recognizes_long_form_dependency_section_self_dependency() {
1395 let dir = tempfile::tempdir().unwrap();
1396 let root = dir.path();
1397 std::fs::write(
1398 root.join("Cargo.toml"),
1399 "[workspace]\nmembers = [\n \"crates/devflow-core\",\n \"crates/devflow-cli\",\n]\n",
1400 )
1401 .unwrap();
1402 std::fs::create_dir_all(root.join("crates/devflow-core")).unwrap();
1403 std::fs::write(
1404 root.join("crates/devflow-core/Cargo.toml"),
1405 "[package]\nname = \"devflow-core\"\n\n[dependencies]\n",
1406 )
1407 .unwrap();
1408 std::fs::create_dir_all(root.join("crates/devflow-cli")).unwrap();
1409 std::fs::write(
1410 root.join("crates/devflow-cli/Cargo.toml"),
1411 "[package]\nname = \"devflow\"\n\n[dependencies.devflow-core]\nworkspace = true\n",
1412 )
1413 .unwrap();
1414
1415 assert_eq!(
1416 publish_order(root),
1417 vec!["devflow-core".to_string(), "devflow".to_string()],
1418 "the long-form dependency section must still order devflow-core before devflow"
1419 );
1420 }
1421
1422 #[test]
1427 fn origin_main_ancestor_status_is_ref_absent_without_a_remote() {
1428 let repo = init_repo();
1429 let root = repo.path();
1430 assert_eq!(origin_main_ancestor_status(root), AncestorStatus::RefAbsent);
1431 }
1432
1433 #[test]
1434 fn origin_main_ancestor_status_is_ancestor_when_head_is_up_to_date() {
1435 let repo = init_repo();
1436 let root = repo.path();
1437 let head = crate::test_support::git_command(root)
1438 .args(["rev-parse", "HEAD"])
1439 .output()
1440 .unwrap();
1441 let head_sha = String::from_utf8_lossy(&head.stdout).trim().to_string();
1442 git(root, &["update-ref", "refs/remotes/origin/main", &head_sha]);
1443 assert_eq!(origin_main_ancestor_status(root), AncestorStatus::Ancestor);
1444 }
1445
1446 #[test]
1451 fn ref_is_ancestor_is_ref_absent_without_remote_refs() {
1452 let repo = init_repo();
1453 let root = repo.path();
1454 assert_eq!(
1455 ref_is_ancestor(root, "origin/main", "origin/develop"),
1456 AncestorStatus::RefAbsent
1457 );
1458 }
1459
1460 #[test]
1461 fn ref_is_ancestor_is_ancestor_when_the_refs_are_in_order() {
1462 let repo = init_repo();
1463 let root = repo.path();
1464 let head = crate::test_support::git_command(root)
1465 .args(["rev-parse", "HEAD"])
1466 .output()
1467 .unwrap();
1468 let head_sha = String::from_utf8_lossy(&head.stdout).trim().to_string();
1469 git(root, &["update-ref", "refs/remotes/origin/main", &head_sha]);
1470 git(
1471 root,
1472 &["update-ref", "refs/remotes/origin/develop", &head_sha],
1473 );
1474 assert_eq!(
1475 ref_is_ancestor(root, "origin/main", "origin/develop"),
1476 AncestorStatus::Ancestor
1477 );
1478 }
1479
1480 #[test]
1485 fn ref_is_ancestor_is_diverged_for_unrelated_commits() {
1486 let repo = init_repo();
1487 let root = repo.path();
1488
1489 git(root, &["checkout", "--orphan", "orphan-main"]);
1492 commit_file(root, "orphan.txt");
1493 let orphan = crate::test_support::git_command(root)
1494 .args(["rev-parse", "HEAD"])
1495 .output()
1496 .unwrap();
1497 let orphan_sha = String::from_utf8_lossy(&orphan.stdout).trim().to_string();
1498
1499 git(root, &["checkout", "-q", "develop"]);
1500 let develop = crate::test_support::git_command(root)
1501 .args(["rev-parse", "HEAD"])
1502 .output()
1503 .unwrap();
1504 let develop_sha = String::from_utf8_lossy(&develop.stdout).trim().to_string();
1505
1506 git(
1507 root,
1508 &["update-ref", "refs/remotes/origin/main", &orphan_sha],
1509 );
1510 git(
1511 root,
1512 &["update-ref", "refs/remotes/origin/develop", &develop_sha],
1513 );
1514 assert_eq!(
1515 ref_is_ancestor(root, "origin/main", "origin/develop"),
1516 AncestorStatus::Diverged
1517 );
1518 }
1519
1520 #[test]
1529 fn hermetic_command_resolves_caller_root_even_under_a_hostile_git_dir() {
1530 let real_repo = init_repo();
1531 let real_root = real_repo.path();
1532
1533 let foreign_repo = TempDir::new().unwrap();
1534 git(foreign_repo.path(), &["init", "-q"]);
1535
1536 let output = git_command(real_root)
1537 .args(["rev-parse", "--show-toplevel"])
1538 .env("GIT_DIR", foreign_repo.path().join(".git"))
1542 .output()
1543 .expect("spawn git");
1544 assert!(
1545 output.status.success(),
1546 "rev-parse --show-toplevel failed: {}",
1547 String::from_utf8_lossy(&output.stderr)
1548 );
1549
1550 let resolved = std::fs::canonicalize(String::from_utf8_lossy(&output.stdout).trim())
1551 .expect("canonicalize resolved toplevel");
1552 let expected = std::fs::canonicalize(real_root).expect("canonicalize real_root");
1553 assert_eq!(
1554 resolved, expected,
1555 "hermetic_command must resolve real_root even with a foreign GIT_DIR set"
1556 );
1557 }
1558
1559 #[test]
1580 fn origin_main_ancestor_status_holds_under_a_hostile_git_dir() {
1581 let repo = init_repo();
1582 let root = repo.path();
1583 let head = crate::test_support::git_command(root)
1584 .args(["rev-parse", "HEAD"])
1585 .output()
1586 .unwrap();
1587 let head_sha = String::from_utf8_lossy(&head.stdout).trim().to_string();
1588 git(root, &["update-ref", "refs/remotes/origin/main", &head_sha]);
1589
1590 let cmd = git_command(root);
1592 assert!(
1593 cmd.get_envs()
1594 .any(|(key, value)| key == "GIT_DIR" && value.is_none()),
1595 "origin_main_ancestor_status's own Command must mark GIT_DIR for removal"
1596 );
1597
1598 assert_eq!(origin_main_ancestor_status(root), AncestorStatus::Ancestor);
1600 }
1601
1602 #[test]
1610 fn git_command_marks_every_redirecting_var_for_removal() {
1611 let cmd = git_command(Path::new("/tmp"));
1612 let removed: Vec<&str> = cmd
1613 .get_envs()
1614 .filter(|(_, value)| value.is_none())
1615 .filter_map(|(key, _)| key.to_str())
1616 .collect();
1617
1618 for var in REPO_LOCAL_GIT_VARS.iter().chain(ALSO_REDIRECTING_GIT_VARS) {
1619 assert!(
1620 removed.contains(var),
1621 "{var} is not cleared by git_command — a fixture inheriting it \
1622 would operate on that repository instead of its tempdir"
1623 );
1624 }
1625 }
1626
1627 #[test]
1631 fn git_command_preserves_git_exec_path() {
1632 let cmd = git_command(Path::new("/tmp"));
1633 assert!(
1634 !cmd.get_envs()
1635 .any(|(key, value)| key == "GIT_EXEC_PATH" && value.is_none()),
1636 "GIT_EXEC_PATH must not be cleared"
1637 );
1638 }
1639
1640 #[test]
1644 fn local_env_vars_match_git() {
1645 let output = git_command(Path::new("/tmp"))
1646 .args(["rev-parse", "--local-env-vars"])
1647 .output()
1648 .expect("run `git rev-parse --local-env-vars`");
1649 assert!(
1650 output.status.success(),
1651 "`git rev-parse --local-env-vars` failed"
1652 );
1653
1654 let mut from_git: Vec<String> = String::from_utf8_lossy(&output.stdout)
1655 .lines()
1656 .map(str::trim)
1657 .filter(|line| !line.is_empty())
1658 .map(str::to_string)
1659 .collect();
1660 let mut ours: Vec<String> = REPO_LOCAL_GIT_VARS
1661 .iter()
1662 .map(|v| (*v).to_string())
1663 .collect();
1664 from_git.sort();
1665 ours.sort();
1666
1667 assert_eq!(
1668 ours, from_git,
1669 "REPO_LOCAL_GIT_VARS has drifted from `git rev-parse --local-env-vars`"
1670 );
1671 }
1672}