1use crate::config::GitFlowConfig;
4use std::path::{Path, PathBuf};
5use std::process::Command;
6use tracing::{debug, info, warn};
7
8#[derive(Debug, thiserror::Error)]
10pub enum GitError {
11 #[error("failed to execute git: {0}")]
13 Io(#[from] std::io::Error),
14 #[error("git command failed: {0}")]
16 Command(String),
17}
18
19#[derive(Debug, Clone)]
21pub struct GitFlow {
22 root: PathBuf,
23 config: GitFlowConfig,
24}
25
26#[derive(Debug, Clone)]
28pub struct BranchInfo {
29 pub name: String,
31 pub ahead: usize,
33 pub behind: usize,
35 pub last_commit: String,
37}
38
39impl GitFlow {
40 pub fn new(root: impl AsRef<Path>) -> Self {
43 Self {
44 root: root.as_ref().to_path_buf(),
45 config: GitFlowConfig::default(),
46 }
47 }
48
49 pub fn feature_start(&self, phase: u32) -> Result<String, GitError> {
54 let branch = format!("{}phase-{:02}", self.config.feature_prefix, phase);
55 info!("creating feature branch: {branch}");
56 self.git(["checkout", &self.config.develop])?;
57 self.git(["checkout", "-b", &branch])?;
58 Ok(branch)
59 }
60
61 pub fn feature_start_force(&self, phase: u32) -> Result<String, GitError> {
63 let branch = format!("{}phase-{:02}", self.config.feature_prefix, phase);
64 warn!("force-creating feature branch: {branch}");
65 self.git(["checkout", &self.config.develop])?;
66 self.git(["checkout", "-B", &branch])?;
67 Ok(branch)
68 }
69
70 pub fn feature_finish(&self, phase: u32) -> Result<String, GitError> {
72 let branch = self.merge_feature_into_develop(phase)?;
73 self.git(["branch", "-d", &branch])?;
74 Ok(branch)
75 }
76
77 pub fn merge_feature_into_develop(&self, phase: u32) -> Result<String, GitError> {
82 let branch = format!("{}phase-{:02}", self.config.feature_prefix, phase);
83 info!("merging feature branch: {branch}");
84 self.git(["checkout", &self.config.develop])?;
85 self.git(["merge", "--no-ff", &branch])?;
86 Ok(branch)
87 }
88
89 pub fn is_merged_into_develop(&self, phase: u32) -> bool {
94 let branch = format!("{}phase-{:02}", self.config.feature_prefix, phase);
95 if !self.branch_exists(&branch) {
96 return false;
97 }
98
99 Command::new("git")
100 .args(["merge-base", "--is-ancestor", &branch, &self.config.develop])
101 .current_dir(&self.root)
102 .output()
103 .map(|output| output.status.success())
104 .unwrap_or(false)
105 }
106
107 pub fn release_start(&self, version: &str) -> Result<String, GitError> {
114 let branch = format!("release/{version}");
115 info!("creating release branch: {branch}");
116 self.git(["checkout", "-B", &branch])?;
117 Ok(branch)
118 }
119
120 pub fn release_finish(&self, version: &str) -> Result<String, GitError> {
122 let branch = format!("release/{version}");
123 info!("finishing release branch: {branch}");
124 self.git(["checkout", &self.config.main])?;
125 self.git(["merge", "--no-ff", &branch])?;
126 self.git(["-c", "tag.gpgSign=false", "tag", &format!("v{version}")])?;
133 self.git(["checkout", &self.config.develop])?;
134 self.git(["merge", "--no-ff", &branch])?;
135 self.git(["branch", "-d", &branch])?;
136 Ok(branch)
137 }
138
139 pub fn tag(&self, tag: &str) -> Result<(), GitError> {
148 info!("tagging {tag}");
149 self.git(["-c", "tag.gpgSign=false", "tag", tag])
150 }
151
152 pub fn delete_branch(&self, branch: &str, force: bool) -> Result<(), GitError> {
158 if branch == self.config.main || branch == self.config.develop {
159 return Err(GitError::Command(format!(
160 "refusing to delete protected branch `{branch}`"
161 )));
162 }
163 let flag = if force { "-D" } else { "-d" };
164 if force {
165 warn!("force-deleting branch: {branch}");
166 } else {
167 info!("deleting branch: {branch}");
168 }
169 self.git(["branch", flag, branch])
170 }
171
172 pub fn branch_exists(&self, branch: &str) -> bool {
174 Command::new("git")
175 .args([
176 "rev-parse",
177 "--verify",
178 "--quiet",
179 &format!("refs/heads/{branch}"),
180 ])
181 .current_dir(&self.root)
182 .output()
183 .map(|o| o.status.success())
184 .unwrap_or(false)
185 }
186
187 pub fn branch_tip(&self, branch: &str) -> Result<String, GitError> {
189 Ok(self.git_output(["rev-parse", branch])?.trim().to_string())
190 }
191
192 pub fn ensure_branch(&self, branch: &str, start_point: &str) -> Result<(), GitError> {
195 if self.branch_exists(branch) {
196 return Ok(());
197 }
198 self.git(["branch", branch, start_point])
199 }
200
201 pub fn fast_forward_branch(&self, target: &str, source: &str) -> Result<(), GitError> {
206 let is_ancestor = Command::new("git")
207 .args(["merge-base", "--is-ancestor", target, source])
208 .current_dir(&self.root)
209 .output()?
210 .status
211 .success();
212 if !is_ancestor {
213 return Err(GitError::Command(format!(
214 "{target} is not an ancestor of {source}; refusing non-fast-forward update"
215 )));
216 }
217 self.git(["branch", "-f", target, source])
218 }
219
220 pub fn rebase_in(&self, dir: &Path, onto: &str) -> Result<(), GitError> {
225 debug!("rebasing worktree at {} onto {onto}", dir.display());
226 match git_in(dir, &["rebase", onto]) {
227 Ok(()) => Ok(()),
228 Err(err) => {
229 warn!("rebase conflict in {}; aborting", dir.display());
231 let _ = git_in(dir, &["rebase", "--abort"]);
232 Err(err)
233 }
234 }
235 }
236
237 pub fn checkout(&self, branch: &str) -> Result<(), GitError> {
239 debug!("checking out branch: {branch}");
240 self.git(["checkout", branch])
241 }
242
243 pub fn delete_remote_branch(&self, branch: &str) -> Result<(), GitError> {
245 info!("deleting remote branch: {branch}");
246 self.git(["push", "origin", "--delete", branch])
247 }
248
249 pub fn has_remote(&self) -> bool {
251 self.git_output(["remote"])
252 .map(|s| !s.trim().is_empty())
253 .unwrap_or(false)
254 }
255
256 pub fn push(&self, branch: &str) -> Result<(), GitError> {
258 info!("pushing branch: {branch}");
259 self.git(["push", "-u", "origin", branch])
260 }
261
262 pub fn cleanup_merged(&self) -> Result<Vec<String>, GitError> {
278 let output = self.git_output(["branch", "--merged", &self.config.develop])?;
279 let protected = [self.config.main.as_str(), self.config.develop.as_str()];
280 let mut deleted = Vec::new();
281 for line in output.lines() {
282 let branch = line
288 .strip_prefix("* ")
289 .or_else(|| line.strip_prefix("+ "))
290 .unwrap_or(line)
291 .trim();
292 if branch.is_empty() || branch.starts_with('(') || protected.contains(&branch) {
295 continue;
296 }
297 info!("cleaning up merged branch: {branch}");
298 match self.git(["branch", "-D", branch]) {
299 Ok(()) => deleted.push(branch.to_string()),
300 Err(err) => warn!("could not delete merged branch {branch}: {err}"),
301 }
302 }
303 Ok(deleted)
304 }
305
306 pub fn commit_all(&self, message: &str) -> Result<(), GitError> {
309 debug!("committing all changes: {message}");
310 self.git(["add", "."])?;
311 match self.git_raw(&["commit", "--allow-empty", "-m", message]) {
313 Ok(()) => Ok(()),
314 Err(GitError::Command(ref msg)) if msg.contains("nothing to commit") => Ok(()),
317 Err(e) => Err(e),
318 }
319 }
320
321 pub fn commit_path(&self, relative_path: &str, message: &str) -> Result<(), GitError> {
330 debug!("committing {relative_path}: {message}");
331 self.git(["add", relative_path])?;
337 match self.git_raw_combined(&["commit", "-m", message, "--", relative_path]) {
338 Ok(()) => Ok(()),
339 Err(GitError::Command(ref msg)) if msg.contains("nothing to commit") => Ok(()),
344 Err(e) => Err(e),
345 }
346 }
347
348 pub fn divergence_from_develop(&self) -> Result<(usize, usize), GitError> {
354 let current = self
355 .git_output(["rev-parse", "--abbrev-ref", "HEAD"])?
356 .trim()
357 .to_string();
358 if current == self.config.develop {
359 return Ok((0, 0));
360 }
361 let ahead = self
362 .rev_count(&format!("{}..{current}", self.config.develop))
363 .unwrap_or(0);
364 let behind = self
365 .rev_count(&format!("{current}..{}", self.config.develop))
366 .unwrap_or(0);
367 Ok((ahead, behind))
368 }
369
370 pub fn list_feature_branches(&self) -> Result<Vec<BranchInfo>, GitError> {
375 let prefix = &self.config.feature_prefix;
376 let branches = self.git_output(["branch", "--format=%(refname:short)"])?;
377 let mut result = Vec::new();
378 for name in branches.lines().map(|l| l.trim()) {
379 if name.is_empty()
380 || name == self.config.main
381 || name == self.config.develop
382 || !name.starts_with(prefix)
383 {
384 continue;
385 }
386 let ahead = self
387 .rev_count(&format!("{dev}..{name}", dev = self.config.develop))
388 .unwrap_or(0);
389 let behind = self
390 .rev_count(&format!("{name}..{dev}", dev = self.config.develop))
391 .unwrap_or(0);
392 let last_commit = self
393 .git_output(["log", "-1", "--format=%aI", name])
394 .map(|s| s.trim().to_string())
395 .unwrap_or_default();
396 result.push(BranchInfo {
397 name: name.to_string(),
398 ahead,
399 behind,
400 last_commit,
401 });
402 }
403 result.sort_by(|a, b| a.name.cmp(&b.name));
405 Ok(result)
406 }
407
408 fn rev_count(&self, range: &str) -> Option<usize> {
410 self.git_output(["rev-list", "--count", range])
411 .ok()
412 .and_then(|s| s.trim().parse().ok())
413 }
414
415 fn git_raw(&self, args: &[&str]) -> Result<(), GitError> {
416 debug!("git {}", args.join(" "));
417 let output = Command::new("git")
423 .args(args)
424 .env("LC_ALL", "C")
425 .env("LANG", "C")
426 .current_dir(&self.root)
427 .output()?;
428 if output.status.success() {
429 Ok(())
430 } else {
431 Err(GitError::Command(stderr_or_status(&output)))
432 }
433 }
434
435 fn git_raw_combined(&self, args: &[&str]) -> Result<(), GitError> {
449 debug!("git {}", args.join(" "));
450 let output = Command::new("git")
451 .args(args)
452 .env("LC_ALL", "C")
453 .env("LANG", "C")
454 .current_dir(&self.root)
455 .output()?;
456 if output.status.success() {
457 Ok(())
458 } else {
459 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
460 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
461 let combined = match (stderr.is_empty(), stdout.is_empty()) {
462 (false, false) => format!("{stderr}\n{stdout}"),
463 (false, true) => stderr,
464 (true, false) => stdout,
465 (true, true) => format!("exited with {}", output.status),
466 };
467 Err(GitError::Command(combined))
468 }
469 }
470
471 fn git<const N: usize>(&self, args: [&str; N]) -> Result<(), GitError> {
472 debug!("git {}", args.iter().copied().collect::<Vec<_>>().join(" "));
473 let output = Command::new("git")
474 .args(args)
475 .current_dir(&self.root)
476 .output()?;
477 if output.status.success() {
478 Ok(())
479 } else {
480 Err(GitError::Command(stderr_or_status(&output)))
481 }
482 }
483
484 fn git_output<const N: usize>(&self, args: [&str; N]) -> Result<String, GitError> {
485 let output = Command::new("git")
486 .args(args)
487 .current_dir(&self.root)
488 .output()?;
489 if output.status.success() {
490 Ok(String::from_utf8_lossy(&output.stdout).to_string())
491 } else {
492 Err(GitError::Command(stderr_or_status(&output)))
493 }
494 }
495}
496
497#[derive(Debug, Clone, Copy, PartialEq, Eq)]
502pub enum AncestorStatus {
503 Ancestor,
505 Diverged,
509 RefAbsent,
514}
515
516pub fn origin_main_ancestor_status(project_root: &Path) -> AncestorStatus {
523 let ref_exists = Command::new("git")
524 .args(["rev-parse", "--verify", "--quiet", "origin/main"])
525 .current_dir(project_root)
526 .output()
527 .map(|out| out.status.success())
528 .unwrap_or(false);
529 if !ref_exists {
530 return AncestorStatus::RefAbsent;
531 }
532 let is_ancestor = Command::new("git")
533 .args(["merge-base", "--is-ancestor", "origin/main", "HEAD"])
534 .current_dir(project_root)
535 .output()
536 .map(|out| out.status.success())
537 .unwrap_or(false);
538 if is_ancestor {
539 AncestorStatus::Ancestor
540 } else {
541 AncestorStatus::Diverged
542 }
543}
544
545pub fn publish_order(project_root: &Path) -> Vec<String> {
552 let Ok(root_contents) = std::fs::read_to_string(project_root.join("Cargo.toml")) else {
553 return Vec::new();
554 };
555 let member_paths = workspace_member_paths(&root_contents);
556
557 let mut members: Vec<(String, String)> = Vec::new();
558 for path in &member_paths {
559 let manifest = project_root.join(path).join("Cargo.toml");
560 let Ok(contents) = std::fs::read_to_string(&manifest) else {
561 continue;
562 };
563 let name = package_name(&contents).unwrap_or_else(|| path.clone());
564 members.push((name, contents));
565 }
566
567 let names: Vec<String> = members.iter().map(|(name, _)| name.clone()).collect();
568 let mut edges: Vec<(String, String)> = Vec::new();
569 for (name, contents) in &members {
570 for other in &names {
571 if other != name && member_depends_on(contents, other) {
572 edges.push((name.clone(), other.clone()));
573 }
574 }
575 }
576 topo_sort(names, edges)
577}
578
579fn workspace_member_paths(contents: &str) -> Vec<String> {
584 let Some(start) = contents.find("members") else {
585 return Vec::new();
586 };
587 let rest = &contents[start..];
588 let Some(open) = rest.find('[') else {
589 return Vec::new();
590 };
591 let Some(close) = rest[open..].find(']') else {
592 return Vec::new();
593 };
594 let inner = &rest[open + 1..open + close];
595 inner
596 .split(',')
597 .filter_map(|fragment| {
598 let fragment = fragment.trim();
599 let fragment = fragment.strip_prefix('"')?.strip_suffix('"')?;
600 (!fragment.is_empty()).then(|| fragment.to_string())
601 })
602 .collect()
603}
604
605fn package_name(contents: &str) -> Option<String> {
607 let mut current = String::new();
608 for line in contents.lines() {
609 let trimmed = line.trim();
610 if let Some(inner) = trimmed.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
611 current = inner.trim().to_string();
612 continue;
613 }
614 if current == "package"
615 && let Some((key, value)) = trimmed.split_once('=')
616 && key.trim() == "name"
617 {
618 return Some(value.trim().trim_matches('"').to_string());
619 }
620 }
621 None
622}
623
624fn member_depends_on(contents: &str, dep_name: &str) -> bool {
635 let mut current = String::new();
636 for line in contents.lines() {
637 let trimmed = line.trim();
638 if let Some(inner) = trimmed.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
639 current = inner.trim().to_string();
640 if let Some(name) = current.strip_prefix("dependencies.")
641 && name == dep_name
642 {
643 return true;
644 }
645 continue;
646 }
647 if current != "dependencies" {
648 continue;
649 }
650 let key = trimmed.split(['.', '=']).next().unwrap_or("").trim();
651 if key == dep_name {
652 return true;
653 }
654 }
655 false
656}
657
658fn topo_sort(names: Vec<String>, edges: Vec<(String, String)>) -> Vec<String> {
664 let mut result = Vec::new();
665 let mut published: Vec<String> = Vec::new();
666 let mut remaining = names;
667 while !remaining.is_empty() {
668 let ready: Vec<String> = remaining
669 .iter()
670 .filter(|name| {
671 edges
672 .iter()
673 .filter(|(dependent, _)| dependent == *name)
674 .all(|(_, dep)| published.contains(dep))
675 })
676 .cloned()
677 .collect();
678 if ready.is_empty() {
679 result.extend(remaining);
680 break;
681 }
682 for name in &ready {
683 published.push(name.clone());
684 result.push(name.clone());
685 }
686 remaining.retain(|name| !ready.contains(name));
687 }
688 result
689}
690
691#[derive(Debug, Clone, Copy, PartialEq, Eq)]
699pub enum SigningStatus {
700 NoAgent,
702 AgentEmpty,
704 KeysListed,
707 Unknown(i32),
710}
711
712pub fn classify_ssh_add_status(exit_code: i32) -> SigningStatus {
715 match exit_code {
716 2 => SigningStatus::NoAgent,
717 1 => SigningStatus::AgentEmpty,
718 0 => SigningStatus::KeysListed,
719 other => SigningStatus::Unknown(other),
720 }
721}
722
723#[derive(Debug, Clone, PartialEq, Eq)]
729pub enum SigningViability {
730 Viable { fingerprint: Option<String> },
733 NotViable { reason: String },
735 Unknown { reason: String },
738}
739
740fn git_config(project_root: &Path, key: &str) -> Option<String> {
743 let output = Command::new("git")
744 .args(["config", "--get", key])
745 .current_dir(project_root)
746 .output()
747 .ok()?;
748 if !output.status.success() {
749 return None;
750 }
751 let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
752 (!value.is_empty()).then_some(value)
753}
754
755fn public_key_fingerprint(pub_key_path: &Path) -> Option<String> {
759 let path_str = pub_key_path.to_str()?;
760 let output = Command::new("ssh-keygen")
761 .args(["-lf", path_str])
762 .output()
763 .ok()?;
764 if !output.status.success() {
765 return None;
766 }
767 String::from_utf8_lossy(&output.stdout)
769 .split_whitespace()
770 .nth(1)
771 .map(str::to_string)
772}
773
774fn check_ssh_signing_viability(project_root: &Path) -> SigningViability {
779 let Some(signingkey) = git_config(project_root, "user.signingkey") else {
780 return SigningViability::NotViable {
781 reason: "gpg.format=ssh but user.signingkey is not set".into(),
782 };
783 };
784 let key_path = Path::new(&signingkey);
785 if !key_path.exists() {
786 return SigningViability::NotViable {
787 reason: "user.signingkey is set but the key file does not exist".into(),
788 };
789 }
790
791 let output = match Command::new("ssh-add").arg("-l").output() {
792 Ok(out) => out,
793 Err(_) => {
794 return SigningViability::Unknown {
795 reason: "cannot verify signing viability — ssh-add not found".into(),
796 };
797 }
798 };
799 let exit_code = output.status.code().unwrap_or(-1);
800 match classify_ssh_add_status(exit_code) {
801 SigningStatus::NoAgent => SigningViability::NotViable {
802 reason: "no ssh-agent reachable (SSH_AUTH_SOCK unset or dead)".into(),
803 },
804 SigningStatus::AgentEmpty => SigningViability::NotViable {
805 reason: "ssh-agent reachable but has no identities loaded".into(),
806 },
807 SigningStatus::KeysListed => {
808 let stdout = String::from_utf8_lossy(&output.stdout);
809 match public_key_fingerprint(key_path) {
810 Some(fingerprint) if stdout.contains(&fingerprint) => SigningViability::Viable {
811 fingerprint: Some(fingerprint),
812 },
813 Some(_) => SigningViability::NotViable {
814 reason: "ssh-agent has keys loaded, but not the configured signing key".into(),
815 },
816 None => SigningViability::Unknown {
817 reason: "cannot verify signing viability — ssh-keygen not found or the key \
818 is unreadable"
819 .into(),
820 },
821 }
822 }
823 SigningStatus::Unknown(code) => SigningViability::Unknown {
824 reason: format!("ssh-add -l exited with an unexpected code {code}"),
825 },
826 }
827}
828
829fn check_gpg_signing_viability(project_root: &Path) -> SigningViability {
832 let Some(signingkey) = git_config(project_root, "user.signingkey") else {
833 return SigningViability::Unknown {
834 reason: "cannot verify signing viability — user.signingkey is not set".into(),
835 };
836 };
837 let output = match Command::new("gpg")
838 .args(["--list-secret-keys", &signingkey])
839 .output()
840 {
841 Ok(out) => out,
842 Err(_) => {
843 return SigningViability::Unknown {
844 reason: "cannot verify signing viability — gpg not found".into(),
845 };
846 }
847 };
848 if output.status.success() {
849 SigningViability::Viable {
850 fingerprint: Some(signingkey),
851 }
852 } else {
853 SigningViability::NotViable {
854 reason: "no secret key found for the configured user.signingkey".into(),
855 }
856 }
857}
858
859pub fn check_signing_viability(project_root: &Path) -> SigningViability {
866 match git_config(project_root, "gpg.format").as_deref() {
867 Some("ssh") => check_ssh_signing_viability(project_root),
868 _ => check_gpg_signing_viability(project_root),
869 }
870}
871
872fn git_in(dir: &Path, args: &[&str]) -> Result<(), GitError> {
874 debug!("git (in {}) {}", dir.display(), args.join(" "));
875 let output = Command::new("git").args(args).current_dir(dir).output()?;
876 if output.status.success() {
877 Ok(())
878 } else {
879 Err(GitError::Command(stderr_or_status(&output)))
880 }
881}
882
883fn stderr_or_status(output: &std::process::Output) -> String {
884 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
885 if stderr.is_empty() {
886 format!("exited with {}", output.status)
887 } else {
888 stderr
889 }
890}
891
892#[cfg(test)]
893mod tests {
894 use super::*;
895 use std::process::Command;
896 use tempfile::TempDir;
897
898 fn git(root: &Path, args: &[&str]) {
900 let output = Command::new("git")
901 .args(args)
902 .current_dir(root)
903 .output()
904 .expect("spawn git");
905 assert!(
906 output.status.success(),
907 "git {args:?} failed: {}",
908 String::from_utf8_lossy(&output.stderr)
909 );
910 }
911
912 fn current_branch(root: &Path) -> String {
913 let output = Command::new("git")
914 .args(["rev-parse", "--abbrev-ref", "HEAD"])
915 .current_dir(root)
916 .output()
917 .expect("rev-parse");
918 String::from_utf8_lossy(&output.stdout).trim().to_string()
919 }
920
921 fn commit_file(root: &Path, name: &str) {
922 std::fs::write(root.join(name), name).unwrap();
923 git(root, &["add", "."]);
924 git(root, &["commit", "-q", "-m", &format!("add {name}")]);
925 }
926
927 fn init_repo() -> TempDir {
929 let dir = tempfile::tempdir().unwrap();
930 let root = dir.path();
931 git(root, &["init", "-q"]);
932 git(root, &["config", "user.email", "test@example.com"]);
933 git(root, &["config", "user.name", "Test"]);
934 git(root, &["config", "commit.gpgsign", "false"]);
935 git(root, &["config", "tag.gpgsign", "false"]);
936 git(root, &["config", "core.hooksPath", "/dev/null"]);
938 commit_file(root, "README.md");
939 git(root, &["branch", "-M", "main"]);
940 git(root, &["checkout", "-q", "-b", "develop"]);
941 dir
942 }
943
944 fn flow(root: &Path) -> GitFlow {
945 GitFlow::new(root)
946 }
947
948 #[test]
949 fn feature_start_branches_from_develop() {
950 let repo = init_repo();
951 let root = repo.path();
952 let branch = flow(root).feature_start(3).expect("feature_start");
953 assert_eq!(branch, "feature/phase-03");
954 assert_eq!(current_branch(root), "feature/phase-03");
955 }
956
957 #[test]
958 fn list_feature_branches_reports_ahead_and_behind_semantics() {
959 let repo = init_repo();
960 let root = repo.path();
961 let gf = flow(root);
962
963 gf.feature_start(12).expect("feature_start");
964 commit_file(root, "feature-one.txt");
965 commit_file(root, "feature-two.txt");
966 git(root, &["checkout", "-q", "develop"]);
967 commit_file(root, "develop-only.txt");
968
969 let branches = gf.list_feature_branches().unwrap();
970 let branch = branches
971 .iter()
972 .find(|branch| branch.name == "feature/phase-12")
973 .unwrap();
974
975 assert_eq!(branch.ahead, 2);
976 assert_eq!(branch.behind, 1);
977 }
978
979 #[test]
980 fn feature_finish_merges_into_develop_and_deletes() {
981 let repo = init_repo();
982 let root = repo.path();
983 let gf = flow(root);
984
985 gf.feature_start(1).expect("start");
986 commit_file(root, "feature.txt");
987
988 let branch = gf.feature_finish(1).expect("finish");
989 assert_eq!(branch, "feature/phase-01");
990 assert_eq!(current_branch(root), "develop");
991
992 let branches = Command::new("git")
994 .args(["branch"])
995 .current_dir(root)
996 .output()
997 .unwrap();
998 let listing = String::from_utf8_lossy(&branches.stdout);
999 assert!(!listing.contains("feature/phase-01"));
1000 assert!(root.join("feature.txt").exists());
1001 }
1002
1003 #[test]
1004 fn release_start_and_finish_tags_main_and_merges_both() {
1005 let repo = init_repo();
1006 let root = repo.path();
1007 let gf = flow(root);
1008
1009 commit_file(root, "work.txt");
1011 let branch = gf.release_start("1.2.0").expect("release_start");
1012 assert_eq!(branch, "release/1.2.0");
1013
1014 gf.release_finish("1.2.0").expect("release_finish");
1015 assert_eq!(current_branch(root), "develop");
1016
1017 let tags = Command::new("git")
1019 .args(["tag"])
1020 .current_dir(root)
1021 .output()
1022 .unwrap();
1023 assert!(String::from_utf8_lossy(&tags.stdout).contains("v1.2.0"));
1024
1025 let branches = Command::new("git")
1027 .args(["branch"])
1028 .current_dir(root)
1029 .output()
1030 .unwrap();
1031 assert!(!String::from_utf8_lossy(&branches.stdout).contains("release/1.2.0"));
1032 }
1033
1034 #[test]
1041 fn tag_stays_lightweight_when_gpgsign_is_forced_on() {
1042 let repo = init_repo();
1043 let root = repo.path();
1044 git(root, &["config", "tag.gpgsign", "true"]);
1048
1049 flow(root)
1050 .tag("v9.9.9")
1051 .expect("tag must not block on $EDITOR");
1052
1053 let tags = Command::new("git")
1054 .args(["tag", "-l"])
1055 .current_dir(root)
1056 .output()
1057 .unwrap();
1058 assert!(String::from_utf8_lossy(&tags.stdout).contains("v9.9.9"));
1059
1060 let obj_type = Command::new("git")
1064 .args(["cat-file", "-t", "v9.9.9"])
1065 .current_dir(root)
1066 .output()
1067 .unwrap();
1068 assert_eq!(
1069 String::from_utf8_lossy(&obj_type.stdout).trim(),
1070 "commit",
1071 "tag() must stay lightweight even when tag.gpgsign=true"
1072 );
1073 }
1074
1075 #[test]
1076 fn commit_path_stages_only_the_given_path_leaving_other_dirt_uncommitted() {
1077 let repo = init_repo();
1081 let root = repo.path();
1082 std::fs::write(root.join("CHANGELOG.md"), "# Changelog\n").unwrap();
1083 std::fs::write(root.join("unrelated.txt"), "not part of this commit\n").unwrap();
1084
1085 Command::new("git")
1090 .args(["add", "unrelated.txt"])
1091 .current_dir(root)
1092 .status()
1093 .unwrap();
1094
1095 flow(root)
1096 .commit_path("CHANGELOG.md", "docs: add changelog entry")
1097 .expect("commit_path");
1098
1099 let committed = Command::new("git")
1100 .args(["log", "-1", "--name-only", "--pretty=format:"])
1101 .current_dir(root)
1102 .output()
1103 .unwrap();
1104 let committed_files = String::from_utf8_lossy(&committed.stdout);
1105 assert!(committed_files.contains("CHANGELOG.md"));
1106 assert!(!committed_files.contains("unrelated.txt"));
1107
1108 let status = Command::new("git")
1109 .args(["status", "--porcelain"])
1110 .current_dir(root)
1111 .output()
1112 .unwrap();
1113 let status = String::from_utf8_lossy(&status.stdout);
1114 assert!(
1115 status.contains("A unrelated.txt"),
1116 "unrelated.txt must remain staged-but-uncommitted, got: {status}"
1117 );
1118 }
1119
1120 fn rev_list_count(root: &Path) -> u32 {
1123 let output = Command::new("git")
1124 .args(["rev-list", "--count", "HEAD"])
1125 .current_dir(root)
1126 .output()
1127 .unwrap();
1128 assert!(output.status.success(), "git rev-list --count HEAD failed");
1129 String::from_utf8_lossy(&output.stdout)
1130 .trim()
1131 .parse::<u32>()
1132 .expect("rev-list --count HEAD must print an integer")
1133 }
1134
1135 #[test]
1143 fn commit_path_twice_with_identical_content_creates_only_one_commit() {
1144 let repo = init_repo();
1145 let root = repo.path();
1146 std::fs::write(root.join("CHANGELOG.md"), "# Changelog\n").unwrap();
1147
1148 flow(root)
1149 .commit_path("CHANGELOG.md", "docs: add changelog entry")
1150 .expect("first commit_path call");
1151 let n1 = rev_list_count(root);
1152
1153 flow(root)
1156 .commit_path("CHANGELOG.md", "docs: add changelog entry")
1157 .expect("second commit_path call");
1158 let n2 = rev_list_count(root);
1159
1160 assert_eq!(
1161 n2, n1,
1162 "a repeat commit_path call on unchanged content must not add a \
1163 commit: n1={n1}, n2={n2}"
1164 );
1165 }
1166
1167 #[test]
1175 fn commit_path_with_no_changes_returns_ok_without_committing() {
1176 let repo = init_repo();
1177 let root = repo.path();
1178 std::fs::write(root.join("CHANGELOG.md"), "# Changelog\n").unwrap();
1179 flow(root)
1180 .commit_path("CHANGELOG.md", "docs: add changelog entry")
1181 .expect("initial commit_path");
1182 let n1 = rev_list_count(root);
1183
1184 let result = flow(root).commit_path("CHANGELOG.md", "docs: add changelog entry");
1187 let n2 = rev_list_count(root);
1188
1189 assert!(
1190 result.is_ok(),
1191 "no-op call must return Ok(()), got: {result:?}"
1192 );
1193 assert_eq!(
1194 n2, n1,
1195 "no-op call must not create a commit: n1={n1}, n2={n2}"
1196 );
1197 }
1198
1199 #[test]
1205 fn commit_path_on_nonexistent_path_still_errors() {
1206 let repo = init_repo();
1207 let root = repo.path();
1208
1209 let result = flow(root).commit_path("does-not-exist.md", "docs: add changelog entry");
1210
1211 assert!(
1212 result.is_err(),
1213 "commit_path on an unknown pathspec must still error, got: {result:?}"
1214 );
1215 }
1216
1217 #[test]
1218 fn release_start_branches_from_current_head_not_develop() {
1219 let repo = init_repo();
1220 let root = repo.path();
1221 let gf = flow(root);
1222
1223 gf.feature_start(5).expect("feature_start");
1225 commit_file(root, "feature-only.txt");
1226 let feature_tip = gf.branch_tip("feature/phase-05").expect("feature tip");
1227
1228 let branch = gf.release_start("2.0.0").expect("release_start");
1229 assert_eq!(branch, "release/2.0.0");
1230 assert_eq!(current_branch(root), "release/2.0.0");
1231
1232 let release_tip = gf.branch_tip("release/2.0.0").expect("release tip");
1235 let is_ancestor = Command::new("git")
1236 .args(["merge-base", "--is-ancestor", &feature_tip, &release_tip])
1237 .current_dir(root)
1238 .output()
1239 .unwrap()
1240 .status
1241 .success();
1242 assert!(
1243 is_ancestor,
1244 "release branch must descend from the shipped feature commit"
1245 );
1246 assert!(root.join("feature-only.txt").exists());
1247 }
1248
1249 #[test]
1250 fn cleanup_merged_removes_merged_but_keeps_protected() {
1251 let repo = init_repo();
1252 let root = repo.path();
1253 let gf = flow(root);
1254
1255 gf.feature_start(2).expect("start");
1257 commit_file(root, "f.txt");
1258 gf.feature_finish(2).expect("finish");
1259
1260 git(root, &["branch", "stale-merged"]);
1262
1263 let deleted = gf.cleanup_merged().expect("cleanup");
1264 assert!(deleted.contains(&"stale-merged".to_string()));
1265 assert!(!deleted.contains(&"develop".to_string()));
1267 assert!(!deleted.contains(&"main".to_string()));
1268 }
1269
1270 #[test]
1277 fn cleanup_merged_is_relative_to_develop_not_current_head() {
1278 let repo = init_repo();
1279 let root = repo.path();
1280 let gf = flow(root);
1281
1282 git(root, &["checkout", "-q", "-b", "topic", "develop"]);
1286 commit_file(root, "topic-only.txt");
1287 git(root, &["checkout", "-q", "-b", "premature", "topic"]);
1288
1289 git(root, &["checkout", "-q", "topic"]);
1297
1298 let _ = gf.cleanup_merged();
1299 assert!(
1300 gf.branch_exists("premature"),
1301 "premature is merged into topic (current HEAD) but not into \
1302 develop — it must survive cleanup_merged when the baseline is develop"
1303 );
1304 }
1305
1306 #[test]
1314 fn cleanup_merged_skips_worktree_branch_and_continues_sweep() {
1315 let repo = init_repo();
1316 let root = repo.path();
1317 let gf = flow(root);
1318
1319 git(
1322 root,
1323 &["checkout", "-q", "-b", "worktree-merged", "develop"],
1324 );
1325 commit_file(root, "g.txt");
1326 git(root, &["checkout", "-q", "develop"]);
1327 git(root, &["merge", "-q", "--no-ff", "worktree-merged"]);
1328
1329 let wt_dir = tempfile::tempdir().unwrap();
1332 git(
1333 root,
1334 &[
1335 "worktree",
1336 "add",
1337 wt_dir.path().to_str().unwrap(),
1338 "worktree-merged",
1339 ],
1340 );
1341
1342 git(root, &["branch", "aa-stale"]);
1346 git(root, &["branch", "zz-stale"]);
1347
1348 let deleted = gf
1349 .cleanup_merged()
1350 .expect("a skipped worktree branch must not abort the sweep");
1351 assert!(deleted.contains(&"aa-stale".to_string()));
1352 assert!(deleted.contains(&"zz-stale".to_string()));
1353 assert!(
1354 !deleted.contains(&"worktree-merged".to_string()),
1355 "worktree checkout cannot be deleted"
1356 );
1357 assert!(gf.branch_exists("worktree-merged"));
1358 }
1359
1360 #[test]
1365 fn cleanup_merged_deletes_when_head_is_not_on_develop() {
1366 let repo = init_repo();
1367 let root = repo.path();
1368 let gf = flow(root);
1369
1370 git(root, &["checkout", "-q", "-b", "old", "develop"]);
1373 git(root, &["checkout", "-q", "develop"]);
1374 git(root, &["checkout", "-q", "-b", "merged-feature", "develop"]);
1375 commit_file(root, "h.txt");
1376 git(root, &["checkout", "-q", "develop"]);
1377 git(root, &["merge", "-q", "--no-ff", "merged-feature"]);
1378 git(root, &["checkout", "-q", "old"]);
1379
1380 let deleted = gf.cleanup_merged().expect("cleanup");
1381 assert!(
1382 deleted.contains(&"merged-feature".to_string()),
1383 "merged-into-develop branch must be deleted even when HEAD is elsewhere: {deleted:?}"
1384 );
1385 assert!(!gf.branch_exists("merged-feature"));
1386 }
1387
1388 #[test]
1389 fn delete_branch_removes_unmerged_with_force_and_protects_trunk() {
1390 let repo = init_repo();
1391 let root = repo.path();
1392 let gf = flow(root);
1393
1394 gf.feature_start(8).expect("start");
1396 commit_file(root, "unmerged.txt");
1397 git(root, &["checkout", "-q", "develop"]);
1399
1400 assert!(gf.delete_branch("feature/phase-08", false).is_err());
1402 gf.delete_branch("feature/phase-08", true)
1403 .expect("force delete");
1404 let branches = Command::new("git")
1405 .args(["branch"])
1406 .current_dir(root)
1407 .output()
1408 .unwrap();
1409 assert!(!String::from_utf8_lossy(&branches.stdout).contains("feature/phase-08"));
1410
1411 assert!(gf.delete_branch("develop", true).is_err());
1413 assert!(gf.delete_branch("main", true).is_err());
1414 }
1415
1416 #[test]
1417 fn sequentagent_helpers_integrate_and_rebase_cleanly() {
1418 let repo = init_repo();
1419 let root = repo.path();
1420 let gf = flow(root);
1421
1422 gf.ensure_branch("feature/phase-07", "develop")
1424 .expect("ensure base");
1425 assert!(gf.branch_exists("feature/phase-07"));
1426 assert!(!gf.branch_tip("feature/phase-07").unwrap().is_empty());
1427 gf.ensure_branch("feature/phase-07", "develop")
1429 .expect("ensure again");
1430
1431 let wt_a = root.join(".worktrees/a");
1433 let wt_b = root.join(".worktrees/b");
1434 crate::worktree::add(root, &wt_a, "feat-a", "feature/phase-07", true).expect("add A");
1435 crate::worktree::add(root, &wt_b, "feat-b", "feature/phase-07", true).expect("add B");
1436
1437 std::fs::write(wt_a.join("a.txt"), "from-a\n").unwrap();
1439 git(&wt_a, &["add", "."]);
1440 git(&wt_a, &["commit", "-q", "-m", "a work"]);
1441 gf.fast_forward_branch("feature/phase-07", "feat-a")
1442 .expect("ff base to A");
1443 assert_eq!(
1444 gf.branch_tip("feature/phase-07").unwrap(),
1445 gf.branch_tip("feat-a").unwrap()
1446 );
1447
1448 gf.rebase_in(&wt_b, "feature/phase-07")
1450 .expect("clean rebase");
1451 assert!(wt_b.join("a.txt").exists());
1453 }
1454
1455 #[test]
1456 fn rebase_in_aborts_and_errors_on_conflict() {
1457 let repo = init_repo();
1458 let root = repo.path();
1459 let gf = flow(root);
1460
1461 gf.ensure_branch("feature/phase-07", "develop")
1462 .expect("ensure base");
1463
1464 let wt_b = root.join(".worktrees/b");
1466 crate::worktree::add(root, &wt_b, "feat-b", "feature/phase-07", true).expect("add B");
1467 std::fs::write(wt_b.join("a.txt"), "from-b\n").unwrap();
1468 git(&wt_b, &["add", "."]);
1469 git(&wt_b, &["commit", "-q", "-m", "b edits a"]);
1470
1471 let wt_a = root.join(".worktrees/a");
1473 crate::worktree::add(root, &wt_a, "feat-a", "feature/phase-07", true).expect("add A");
1474 std::fs::write(wt_a.join("a.txt"), "from-base\n").unwrap();
1475 git(&wt_a, &["add", "."]);
1476 git(&wt_a, &["commit", "-q", "-m", "base edits a"]);
1477 gf.fast_forward_branch("feature/phase-07", "feat-a")
1478 .expect("ff base to A");
1479
1480 let err = gf.rebase_in(&wt_b, "feature/phase-07").unwrap_err();
1482 assert!(matches!(err, GitError::Command(_)));
1483 assert!(!root.join(".git/worktrees/b/rebase-merge").exists());
1485 assert_eq!(
1487 std::fs::read_to_string(wt_b.join("a.txt")).unwrap(),
1488 "from-b\n"
1489 );
1490 }
1491
1492 #[test]
1493 fn merge_of_missing_branch_is_an_error() {
1494 let repo = init_repo();
1495 let root = repo.path();
1496 let err = flow(root).feature_finish(99).unwrap_err();
1499 assert!(matches!(err, GitError::Command(_)));
1500 }
1501
1502 #[test]
1507 fn workspace_member_paths_parses_multiline_array() {
1508 let contents = "[workspace]\nresolver = \"2\"\nmembers = [\n \"crates/devflow-core\",\n \"crates/devflow-cli\",\n]\n";
1509 assert_eq!(
1510 workspace_member_paths(contents),
1511 vec![
1512 "crates/devflow-core".to_string(),
1513 "crates/devflow-cli".to_string()
1514 ]
1515 );
1516 }
1517
1518 #[test]
1519 fn package_name_reads_the_package_section() {
1520 let contents = "[package]\nname = \"devflow-core\"\nversion.workspace = true\n";
1521 assert_eq!(package_name(contents), Some("devflow-core".to_string()));
1522 }
1523
1524 #[test]
1525 fn member_depends_on_matches_dotted_workspace_shorthand() {
1526 let contents = "[package]\nname = \"devflow\"\n\n[dependencies]\ndevflow-core.workspace = true\nclap.workspace = true\n";
1527 assert!(member_depends_on(contents, "devflow-core"));
1528 assert!(!member_depends_on(contents, "serde"));
1529 }
1530
1531 #[test]
1537 fn member_depends_on_matches_long_form_dependency_section() {
1538 let contents = "[package]\nname = \"devflow\"\n\n[dependencies.devflow-core]\nworkspace = true\n\n[dependencies.clap]\nversion = \"4\"\n";
1539 assert!(member_depends_on(contents, "devflow-core"));
1540 assert!(member_depends_on(contents, "clap"));
1541 assert!(!member_depends_on(contents, "serde"));
1542 }
1543
1544 #[test]
1545 fn topo_sort_orders_dependency_before_dependent() {
1546 let names = vec!["devflow".to_string(), "devflow-core".to_string()];
1547 let edges = vec![("devflow".to_string(), "devflow-core".to_string())];
1548 assert_eq!(
1549 topo_sort(names, edges),
1550 vec!["devflow-core".to_string(), "devflow".to_string()]
1551 );
1552 }
1553
1554 #[test]
1555 fn topo_sort_falls_back_to_input_order_on_a_cycle() {
1556 let names = vec!["a".to_string(), "b".to_string()];
1559 let edges = vec![
1560 ("a".to_string(), "b".to_string()),
1561 ("b".to_string(), "a".to_string()),
1562 ];
1563 let result = topo_sort(names, edges);
1564 assert_eq!(result.len(), 2);
1565 }
1566
1567 #[test]
1568 fn publish_order_derives_core_before_cli_from_a_fixture_workspace() {
1569 let dir = tempfile::tempdir().unwrap();
1570 let root = dir.path();
1571 std::fs::write(
1572 root.join("Cargo.toml"),
1573 "[workspace]\nmembers = [\n \"crates/devflow-core\",\n \"crates/devflow-cli\",\n]\n",
1574 )
1575 .unwrap();
1576 std::fs::create_dir_all(root.join("crates/devflow-core")).unwrap();
1577 std::fs::write(
1578 root.join("crates/devflow-core/Cargo.toml"),
1579 "[package]\nname = \"devflow-core\"\n\n[dependencies]\n",
1580 )
1581 .unwrap();
1582 std::fs::create_dir_all(root.join("crates/devflow-cli")).unwrap();
1583 std::fs::write(
1584 root.join("crates/devflow-cli/Cargo.toml"),
1585 "[package]\nname = \"devflow\"\n\n[dependencies]\ndevflow-core.workspace = true\n",
1586 )
1587 .unwrap();
1588
1589 assert_eq!(
1590 publish_order(root),
1591 vec!["devflow-core".to_string(), "devflow".to_string()]
1592 );
1593 }
1594
1595 #[test]
1602 fn publish_order_recognizes_long_form_dependency_section_self_dependency() {
1603 let dir = tempfile::tempdir().unwrap();
1604 let root = dir.path();
1605 std::fs::write(
1606 root.join("Cargo.toml"),
1607 "[workspace]\nmembers = [\n \"crates/devflow-core\",\n \"crates/devflow-cli\",\n]\n",
1608 )
1609 .unwrap();
1610 std::fs::create_dir_all(root.join("crates/devflow-core")).unwrap();
1611 std::fs::write(
1612 root.join("crates/devflow-core/Cargo.toml"),
1613 "[package]\nname = \"devflow-core\"\n\n[dependencies]\n",
1614 )
1615 .unwrap();
1616 std::fs::create_dir_all(root.join("crates/devflow-cli")).unwrap();
1617 std::fs::write(
1618 root.join("crates/devflow-cli/Cargo.toml"),
1619 "[package]\nname = \"devflow\"\n\n[dependencies.devflow-core]\nworkspace = true\n",
1620 )
1621 .unwrap();
1622
1623 assert_eq!(
1624 publish_order(root),
1625 vec!["devflow-core".to_string(), "devflow".to_string()],
1626 "the long-form dependency section must still order devflow-core before devflow"
1627 );
1628 }
1629
1630 #[test]
1635 fn origin_main_ancestor_status_is_ref_absent_without_a_remote() {
1636 let repo = init_repo();
1637 let root = repo.path();
1638 assert_eq!(origin_main_ancestor_status(root), AncestorStatus::RefAbsent);
1639 }
1640
1641 #[test]
1642 fn origin_main_ancestor_status_is_ancestor_when_head_is_up_to_date() {
1643 let repo = init_repo();
1644 let root = repo.path();
1645 let head = Command::new("git")
1646 .args(["rev-parse", "HEAD"])
1647 .current_dir(root)
1648 .output()
1649 .unwrap();
1650 let head_sha = String::from_utf8_lossy(&head.stdout).trim().to_string();
1651 git(root, &["update-ref", "refs/remotes/origin/main", &head_sha]);
1652 assert_eq!(origin_main_ancestor_status(root), AncestorStatus::Ancestor);
1653 }
1654
1655 #[test]
1660 fn classify_ssh_add_status_maps_all_three_documented_exit_codes() {
1661 assert_eq!(classify_ssh_add_status(2), SigningStatus::NoAgent);
1662 assert_eq!(classify_ssh_add_status(1), SigningStatus::AgentEmpty);
1663 assert_eq!(classify_ssh_add_status(0), SigningStatus::KeysListed);
1664 assert_eq!(classify_ssh_add_status(7), SigningStatus::Unknown(7));
1665 }
1666
1667 static HOME_ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
1674
1675 #[test]
1676 fn check_signing_viability_degrades_when_gpg_format_unset_and_no_signingkey() {
1677 let _lock = HOME_ENV_MUTEX.lock().unwrap();
1680 let repo = init_repo();
1681 let root = repo.path();
1682 let fake_home = tempfile::tempdir().unwrap();
1683 let original_home = std::env::var_os("HOME");
1684 unsafe { std::env::set_var("HOME", fake_home.path()) };
1687
1688 let result = check_signing_viability(root);
1689
1690 match original_home {
1692 Some(home) => unsafe { std::env::set_var("HOME", home) },
1693 None => unsafe { std::env::remove_var("HOME") },
1694 }
1695
1696 match result {
1697 SigningViability::Unknown { reason } => {
1698 assert!(
1699 reason.contains("user.signingkey"),
1700 "unexpected reason: {reason}"
1701 );
1702 }
1703 other => panic!("expected Unknown (fail-soft), got: {other:?}"),
1704 }
1705 }
1706}