use crate::config::GitFlowConfig;
use std::path::{Path, PathBuf};
use std::process::Command;
use tracing::{debug, info, warn};
#[derive(Debug, thiserror::Error)]
pub enum GitError {
#[error("failed to execute git: {0}")]
Io(#[from] std::io::Error),
#[error("git command failed: {0}")]
Command(String),
}
#[derive(Debug, Clone)]
pub struct GitFlow {
root: PathBuf,
config: GitFlowConfig,
}
#[derive(Debug, Clone)]
pub struct BranchInfo {
pub name: String,
pub ahead: usize,
pub behind: usize,
pub last_commit: String,
}
impl GitFlow {
pub fn new(root: impl AsRef<Path>) -> Self {
Self {
root: root.as_ref().to_path_buf(),
config: GitFlowConfig::default(),
}
}
pub fn feature_start(&self, phase: u32) -> Result<String, GitError> {
let branch = format!("{}phase-{:02}", self.config.feature_prefix, phase);
info!("creating feature branch: {branch}");
self.git(["checkout", &self.config.develop])?;
self.git(["checkout", "-b", &branch])?;
Ok(branch)
}
pub fn feature_start_force(&self, phase: u32) -> Result<String, GitError> {
let branch = format!("{}phase-{:02}", self.config.feature_prefix, phase);
warn!("force-creating feature branch: {branch}");
self.git(["checkout", &self.config.develop])?;
self.git(["checkout", "-B", &branch])?;
Ok(branch)
}
pub fn feature_finish(&self, phase: u32) -> Result<String, GitError> {
let branch = self.merge_feature_into_develop(phase)?;
self.git(["branch", "-d", &branch])?;
Ok(branch)
}
pub fn merge_feature_into_develop(&self, phase: u32) -> Result<String, GitError> {
let branch = format!("{}phase-{:02}", self.config.feature_prefix, phase);
info!("merging feature branch: {branch}");
self.git(["checkout", &self.config.develop])?;
self.git(["merge", "--no-ff", &branch])?;
Ok(branch)
}
pub fn is_merged_into_develop(&self, phase: u32) -> bool {
let branch = format!("{}phase-{:02}", self.config.feature_prefix, phase);
if !self.branch_exists(&branch) {
return false;
}
Command::new("git")
.args(["merge-base", "--is-ancestor", &branch, &self.config.develop])
.current_dir(&self.root)
.output()
.map(|output| output.status.success())
.unwrap_or(false)
}
pub fn release_start(&self, version: &str) -> Result<String, GitError> {
let branch = format!("release/{version}");
info!("creating release branch: {branch}");
self.git(["checkout", "-B", &branch])?;
Ok(branch)
}
pub fn release_finish(&self, version: &str) -> Result<String, GitError> {
let branch = format!("release/{version}");
info!("finishing release branch: {branch}");
self.git(["checkout", &self.config.main])?;
self.git(["merge", "--no-ff", &branch])?;
self.git(["-c", "tag.gpgSign=false", "tag", &format!("v{version}")])?;
self.git(["checkout", &self.config.develop])?;
self.git(["merge", "--no-ff", &branch])?;
self.git(["branch", "-d", &branch])?;
Ok(branch)
}
pub fn tag(&self, tag: &str) -> Result<(), GitError> {
info!("tagging {tag}");
self.git(["-c", "tag.gpgSign=false", "tag", tag])
}
pub fn delete_branch(&self, branch: &str, force: bool) -> Result<(), GitError> {
if branch == self.config.main || branch == self.config.develop {
return Err(GitError::Command(format!(
"refusing to delete protected branch `{branch}`"
)));
}
let flag = if force { "-D" } else { "-d" };
if force {
warn!("force-deleting branch: {branch}");
} else {
info!("deleting branch: {branch}");
}
self.git(["branch", flag, branch])
}
pub fn branch_exists(&self, branch: &str) -> bool {
Command::new("git")
.args([
"rev-parse",
"--verify",
"--quiet",
&format!("refs/heads/{branch}"),
])
.current_dir(&self.root)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
pub fn branch_tip(&self, branch: &str) -> Result<String, GitError> {
Ok(self.git_output(["rev-parse", branch])?.trim().to_string())
}
pub fn ensure_branch(&self, branch: &str, start_point: &str) -> Result<(), GitError> {
if self.branch_exists(branch) {
return Ok(());
}
self.git(["branch", branch, start_point])
}
pub fn fast_forward_branch(&self, target: &str, source: &str) -> Result<(), GitError> {
let is_ancestor = Command::new("git")
.args(["merge-base", "--is-ancestor", target, source])
.current_dir(&self.root)
.output()?
.status
.success();
if !is_ancestor {
return Err(GitError::Command(format!(
"{target} is not an ancestor of {source}; refusing non-fast-forward update"
)));
}
self.git(["branch", "-f", target, source])
}
pub fn rebase_in(&self, dir: &Path, onto: &str) -> Result<(), GitError> {
debug!("rebasing worktree at {} onto {onto}", dir.display());
match git_in(dir, &["rebase", onto]) {
Ok(()) => Ok(()),
Err(err) => {
warn!("rebase conflict in {}; aborting", dir.display());
let _ = git_in(dir, &["rebase", "--abort"]);
Err(err)
}
}
}
pub fn checkout(&self, branch: &str) -> Result<(), GitError> {
debug!("checking out branch: {branch}");
self.git(["checkout", branch])
}
pub fn delete_remote_branch(&self, branch: &str) -> Result<(), GitError> {
info!("deleting remote branch: {branch}");
self.git(["push", "origin", "--delete", branch])
}
pub fn has_remote(&self) -> bool {
self.git_output(["remote"])
.map(|s| !s.trim().is_empty())
.unwrap_or(false)
}
pub fn push(&self, branch: &str) -> Result<(), GitError> {
info!("pushing branch: {branch}");
self.git(["push", "-u", "origin", branch])
}
pub fn cleanup_merged(&self) -> Result<Vec<String>, GitError> {
let output = self.git_output(["branch", "--merged", &self.config.develop])?;
let protected = [self.config.main.as_str(), self.config.develop.as_str()];
let mut deleted = Vec::new();
for line in output.lines() {
let branch = line
.strip_prefix("* ")
.or_else(|| line.strip_prefix("+ "))
.unwrap_or(line)
.trim();
if branch.is_empty() || branch.starts_with('(') || protected.contains(&branch) {
continue;
}
info!("cleaning up merged branch: {branch}");
match self.git(["branch", "-D", branch]) {
Ok(()) => deleted.push(branch.to_string()),
Err(err) => warn!("could not delete merged branch {branch}: {err}"),
}
}
Ok(deleted)
}
pub fn commit_all(&self, message: &str) -> Result<(), GitError> {
debug!("committing all changes: {message}");
self.git(["add", "."])?;
match self.git_raw(&["commit", "--allow-empty", "-m", message]) {
Ok(()) => Ok(()),
Err(GitError::Command(ref msg)) if msg.contains("nothing to commit") => Ok(()),
Err(e) => Err(e),
}
}
pub fn commit_path(&self, relative_path: &str, message: &str) -> Result<(), GitError> {
debug!("committing {relative_path}: {message}");
self.git(["add", relative_path])?;
match self.git_raw_combined(&["commit", "-m", message, "--", relative_path]) {
Ok(()) => Ok(()),
Err(GitError::Command(ref msg)) if msg.contains("nothing to commit") => Ok(()),
Err(e) => Err(e),
}
}
pub fn divergence_from_develop(&self) -> Result<(usize, usize), GitError> {
let current = self
.git_output(["rev-parse", "--abbrev-ref", "HEAD"])?
.trim()
.to_string();
if current == self.config.develop {
return Ok((0, 0));
}
let ahead = self
.rev_count(&format!("{}..{current}", self.config.develop))
.unwrap_or(0);
let behind = self
.rev_count(&format!("{current}..{}", self.config.develop))
.unwrap_or(0);
Ok((ahead, behind))
}
pub fn list_feature_branches(&self) -> Result<Vec<BranchInfo>, GitError> {
let prefix = &self.config.feature_prefix;
let branches = self.git_output(["branch", "--format=%(refname:short)"])?;
let mut result = Vec::new();
for name in branches.lines().map(|l| l.trim()) {
if name.is_empty()
|| name == self.config.main
|| name == self.config.develop
|| !name.starts_with(prefix)
{
continue;
}
let ahead = self
.rev_count(&format!("{dev}..{name}", dev = self.config.develop))
.unwrap_or(0);
let behind = self
.rev_count(&format!("{name}..{dev}", dev = self.config.develop))
.unwrap_or(0);
let last_commit = self
.git_output(["log", "-1", "--format=%aI", name])
.map(|s| s.trim().to_string())
.unwrap_or_default();
result.push(BranchInfo {
name: name.to_string(),
ahead,
behind,
last_commit,
});
}
result.sort_by(|a, b| a.name.cmp(&b.name));
Ok(result)
}
fn rev_count(&self, range: &str) -> Option<usize> {
self.git_output(["rev-list", "--count", range])
.ok()
.and_then(|s| s.trim().parse().ok())
}
fn git_raw(&self, args: &[&str]) -> Result<(), GitError> {
debug!("git {}", args.join(" "));
let output = Command::new("git")
.args(args)
.env("LC_ALL", "C")
.env("LANG", "C")
.current_dir(&self.root)
.output()?;
if output.status.success() {
Ok(())
} else {
Err(GitError::Command(stderr_or_status(&output)))
}
}
fn git_raw_combined(&self, args: &[&str]) -> Result<(), GitError> {
debug!("git {}", args.join(" "));
let output = Command::new("git")
.args(args)
.env("LC_ALL", "C")
.env("LANG", "C")
.current_dir(&self.root)
.output()?;
if output.status.success() {
Ok(())
} else {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let combined = match (stderr.is_empty(), stdout.is_empty()) {
(false, false) => format!("{stderr}\n{stdout}"),
(false, true) => stderr,
(true, false) => stdout,
(true, true) => format!("exited with {}", output.status),
};
Err(GitError::Command(combined))
}
}
fn git<const N: usize>(&self, args: [&str; N]) -> Result<(), GitError> {
debug!("git {}", args.iter().copied().collect::<Vec<_>>().join(" "));
let output = Command::new("git")
.args(args)
.current_dir(&self.root)
.output()?;
if output.status.success() {
Ok(())
} else {
Err(GitError::Command(stderr_or_status(&output)))
}
}
fn git_output<const N: usize>(&self, args: [&str; N]) -> Result<String, GitError> {
let output = Command::new("git")
.args(args)
.current_dir(&self.root)
.output()?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).to_string())
} else {
Err(GitError::Command(stderr_or_status(&output)))
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AncestorStatus {
Ancestor,
Diverged,
RefAbsent,
}
pub fn origin_main_ancestor_status(project_root: &Path) -> AncestorStatus {
let ref_exists = Command::new("git")
.args(["rev-parse", "--verify", "--quiet", "origin/main"])
.current_dir(project_root)
.output()
.map(|out| out.status.success())
.unwrap_or(false);
if !ref_exists {
return AncestorStatus::RefAbsent;
}
let is_ancestor = Command::new("git")
.args(["merge-base", "--is-ancestor", "origin/main", "HEAD"])
.current_dir(project_root)
.output()
.map(|out| out.status.success())
.unwrap_or(false);
if is_ancestor {
AncestorStatus::Ancestor
} else {
AncestorStatus::Diverged
}
}
pub fn publish_order(project_root: &Path) -> Vec<String> {
let Ok(root_contents) = std::fs::read_to_string(project_root.join("Cargo.toml")) else {
return Vec::new();
};
let member_paths = workspace_member_paths(&root_contents);
let mut members: Vec<(String, String)> = Vec::new();
for path in &member_paths {
let manifest = project_root.join(path).join("Cargo.toml");
let Ok(contents) = std::fs::read_to_string(&manifest) else {
continue;
};
let name = package_name(&contents).unwrap_or_else(|| path.clone());
members.push((name, contents));
}
let names: Vec<String> = members.iter().map(|(name, _)| name.clone()).collect();
let mut edges: Vec<(String, String)> = Vec::new();
for (name, contents) in &members {
for other in &names {
if other != name && member_depends_on(contents, other) {
edges.push((name.clone(), other.clone()));
}
}
}
topo_sort(names, edges)
}
fn workspace_member_paths(contents: &str) -> Vec<String> {
let Some(start) = contents.find("members") else {
return Vec::new();
};
let rest = &contents[start..];
let Some(open) = rest.find('[') else {
return Vec::new();
};
let Some(close) = rest[open..].find(']') else {
return Vec::new();
};
let inner = &rest[open + 1..open + close];
inner
.split(',')
.filter_map(|fragment| {
let fragment = fragment.trim();
let fragment = fragment.strip_prefix('"')?.strip_suffix('"')?;
(!fragment.is_empty()).then(|| fragment.to_string())
})
.collect()
}
fn package_name(contents: &str) -> Option<String> {
let mut current = String::new();
for line in contents.lines() {
let trimmed = line.trim();
if let Some(inner) = trimmed.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
current = inner.trim().to_string();
continue;
}
if current == "package"
&& let Some((key, value)) = trimmed.split_once('=')
&& key.trim() == "name"
{
return Some(value.trim().trim_matches('"').to_string());
}
}
None
}
fn member_depends_on(contents: &str, dep_name: &str) -> bool {
let mut current = String::new();
for line in contents.lines() {
let trimmed = line.trim();
if let Some(inner) = trimmed.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
current = inner.trim().to_string();
if let Some(name) = current.strip_prefix("dependencies.")
&& name == dep_name
{
return true;
}
continue;
}
if current != "dependencies" {
continue;
}
let key = trimmed.split(['.', '=']).next().unwrap_or("").trim();
if key == dep_name {
return true;
}
}
false
}
fn topo_sort(names: Vec<String>, edges: Vec<(String, String)>) -> Vec<String> {
let mut result = Vec::new();
let mut published: Vec<String> = Vec::new();
let mut remaining = names;
while !remaining.is_empty() {
let ready: Vec<String> = remaining
.iter()
.filter(|name| {
edges
.iter()
.filter(|(dependent, _)| dependent == *name)
.all(|(_, dep)| published.contains(dep))
})
.cloned()
.collect();
if ready.is_empty() {
result.extend(remaining);
break;
}
for name in &ready {
published.push(name.clone());
result.push(name.clone());
}
remaining.retain(|name| !ready.contains(name));
}
result
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SigningStatus {
NoAgent,
AgentEmpty,
KeysListed,
Unknown(i32),
}
pub fn classify_ssh_add_status(exit_code: i32) -> SigningStatus {
match exit_code {
2 => SigningStatus::NoAgent,
1 => SigningStatus::AgentEmpty,
0 => SigningStatus::KeysListed,
other => SigningStatus::Unknown(other),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SigningViability {
Viable { fingerprint: Option<String> },
NotViable { reason: String },
Unknown { reason: String },
}
fn git_config(project_root: &Path, key: &str) -> Option<String> {
let output = Command::new("git")
.args(["config", "--get", key])
.current_dir(project_root)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
(!value.is_empty()).then_some(value)
}
fn public_key_fingerprint(pub_key_path: &Path) -> Option<String> {
let path_str = pub_key_path.to_str()?;
let output = Command::new("ssh-keygen")
.args(["-lf", path_str])
.output()
.ok()?;
if !output.status.success() {
return None;
}
String::from_utf8_lossy(&output.stdout)
.split_whitespace()
.nth(1)
.map(str::to_string)
}
fn check_ssh_signing_viability(project_root: &Path) -> SigningViability {
let Some(signingkey) = git_config(project_root, "user.signingkey") else {
return SigningViability::NotViable {
reason: "gpg.format=ssh but user.signingkey is not set".into(),
};
};
let key_path = Path::new(&signingkey);
if !key_path.exists() {
return SigningViability::NotViable {
reason: "user.signingkey is set but the key file does not exist".into(),
};
}
let output = match Command::new("ssh-add").arg("-l").output() {
Ok(out) => out,
Err(_) => {
return SigningViability::Unknown {
reason: "cannot verify signing viability — ssh-add not found".into(),
};
}
};
let exit_code = output.status.code().unwrap_or(-1);
match classify_ssh_add_status(exit_code) {
SigningStatus::NoAgent => SigningViability::NotViable {
reason: "no ssh-agent reachable (SSH_AUTH_SOCK unset or dead)".into(),
},
SigningStatus::AgentEmpty => SigningViability::NotViable {
reason: "ssh-agent reachable but has no identities loaded".into(),
},
SigningStatus::KeysListed => {
let stdout = String::from_utf8_lossy(&output.stdout);
match public_key_fingerprint(key_path) {
Some(fingerprint) if stdout.contains(&fingerprint) => SigningViability::Viable {
fingerprint: Some(fingerprint),
},
Some(_) => SigningViability::NotViable {
reason: "ssh-agent has keys loaded, but not the configured signing key".into(),
},
None => SigningViability::Unknown {
reason: "cannot verify signing viability — ssh-keygen not found or the key \
is unreadable"
.into(),
},
}
}
SigningStatus::Unknown(code) => SigningViability::Unknown {
reason: format!("ssh-add -l exited with an unexpected code {code}"),
},
}
}
fn check_gpg_signing_viability(project_root: &Path) -> SigningViability {
let Some(signingkey) = git_config(project_root, "user.signingkey") else {
return SigningViability::Unknown {
reason: "cannot verify signing viability — user.signingkey is not set".into(),
};
};
let output = match Command::new("gpg")
.args(["--list-secret-keys", &signingkey])
.output()
{
Ok(out) => out,
Err(_) => {
return SigningViability::Unknown {
reason: "cannot verify signing viability — gpg not found".into(),
};
}
};
if output.status.success() {
SigningViability::Viable {
fingerprint: Some(signingkey),
}
} else {
SigningViability::NotViable {
reason: "no secret key found for the configured user.signingkey".into(),
}
}
}
pub fn check_signing_viability(project_root: &Path) -> SigningViability {
match git_config(project_root, "gpg.format").as_deref() {
Some("ssh") => check_ssh_signing_viability(project_root),
_ => check_gpg_signing_viability(project_root),
}
}
fn git_in(dir: &Path, args: &[&str]) -> Result<(), GitError> {
debug!("git (in {}) {}", dir.display(), args.join(" "));
let output = Command::new("git").args(args).current_dir(dir).output()?;
if output.status.success() {
Ok(())
} else {
Err(GitError::Command(stderr_or_status(&output)))
}
}
fn stderr_or_status(output: &std::process::Output) -> String {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if stderr.is_empty() {
format!("exited with {}", output.status)
} else {
stderr
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command;
use tempfile::TempDir;
fn git(root: &Path, args: &[&str]) {
let output = Command::new("git")
.args(args)
.current_dir(root)
.output()
.expect("spawn git");
assert!(
output.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
fn current_branch(root: &Path) -> String {
let output = Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(root)
.output()
.expect("rev-parse");
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
fn commit_file(root: &Path, name: &str) {
std::fs::write(root.join(name), name).unwrap();
git(root, &["add", "."]);
git(root, &["commit", "-q", "-m", &format!("add {name}")]);
}
fn init_repo() -> TempDir {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
git(root, &["init", "-q"]);
git(root, &["config", "user.email", "test@example.com"]);
git(root, &["config", "user.name", "Test"]);
git(root, &["config", "commit.gpgsign", "false"]);
git(root, &["config", "tag.gpgsign", "false"]);
git(root, &["config", "core.hooksPath", "/dev/null"]);
commit_file(root, "README.md");
git(root, &["branch", "-M", "main"]);
git(root, &["checkout", "-q", "-b", "develop"]);
dir
}
fn flow(root: &Path) -> GitFlow {
GitFlow::new(root)
}
#[test]
fn feature_start_branches_from_develop() {
let repo = init_repo();
let root = repo.path();
let branch = flow(root).feature_start(3).expect("feature_start");
assert_eq!(branch, "feature/phase-03");
assert_eq!(current_branch(root), "feature/phase-03");
}
#[test]
fn list_feature_branches_reports_ahead_and_behind_semantics() {
let repo = init_repo();
let root = repo.path();
let gf = flow(root);
gf.feature_start(12).expect("feature_start");
commit_file(root, "feature-one.txt");
commit_file(root, "feature-two.txt");
git(root, &["checkout", "-q", "develop"]);
commit_file(root, "develop-only.txt");
let branches = gf.list_feature_branches().unwrap();
let branch = branches
.iter()
.find(|branch| branch.name == "feature/phase-12")
.unwrap();
assert_eq!(branch.ahead, 2);
assert_eq!(branch.behind, 1);
}
#[test]
fn feature_finish_merges_into_develop_and_deletes() {
let repo = init_repo();
let root = repo.path();
let gf = flow(root);
gf.feature_start(1).expect("start");
commit_file(root, "feature.txt");
let branch = gf.feature_finish(1).expect("finish");
assert_eq!(branch, "feature/phase-01");
assert_eq!(current_branch(root), "develop");
let branches = Command::new("git")
.args(["branch"])
.current_dir(root)
.output()
.unwrap();
let listing = String::from_utf8_lossy(&branches.stdout);
assert!(!listing.contains("feature/phase-01"));
assert!(root.join("feature.txt").exists());
}
#[test]
fn release_start_and_finish_tags_main_and_merges_both() {
let repo = init_repo();
let root = repo.path();
let gf = flow(root);
commit_file(root, "work.txt");
let branch = gf.release_start("1.2.0").expect("release_start");
assert_eq!(branch, "release/1.2.0");
gf.release_finish("1.2.0").expect("release_finish");
assert_eq!(current_branch(root), "develop");
let tags = Command::new("git")
.args(["tag"])
.current_dir(root)
.output()
.unwrap();
assert!(String::from_utf8_lossy(&tags.stdout).contains("v1.2.0"));
let branches = Command::new("git")
.args(["branch"])
.current_dir(root)
.output()
.unwrap();
assert!(!String::from_utf8_lossy(&branches.stdout).contains("release/1.2.0"));
}
#[test]
fn tag_stays_lightweight_when_gpgsign_is_forced_on() {
let repo = init_repo();
let root = repo.path();
git(root, &["config", "tag.gpgsign", "true"]);
flow(root)
.tag("v9.9.9")
.expect("tag must not block on $EDITOR");
let tags = Command::new("git")
.args(["tag", "-l"])
.current_dir(root)
.output()
.unwrap();
assert!(String::from_utf8_lossy(&tags.stdout).contains("v9.9.9"));
let obj_type = Command::new("git")
.args(["cat-file", "-t", "v9.9.9"])
.current_dir(root)
.output()
.unwrap();
assert_eq!(
String::from_utf8_lossy(&obj_type.stdout).trim(),
"commit",
"tag() must stay lightweight even when tag.gpgsign=true"
);
}
#[test]
fn commit_path_stages_only_the_given_path_leaving_other_dirt_uncommitted() {
let repo = init_repo();
let root = repo.path();
std::fs::write(root.join("CHANGELOG.md"), "# Changelog\n").unwrap();
std::fs::write(root.join("unrelated.txt"), "not part of this commit\n").unwrap();
Command::new("git")
.args(["add", "unrelated.txt"])
.current_dir(root)
.status()
.unwrap();
flow(root)
.commit_path("CHANGELOG.md", "docs: add changelog entry")
.expect("commit_path");
let committed = Command::new("git")
.args(["log", "-1", "--name-only", "--pretty=format:"])
.current_dir(root)
.output()
.unwrap();
let committed_files = String::from_utf8_lossy(&committed.stdout);
assert!(committed_files.contains("CHANGELOG.md"));
assert!(!committed_files.contains("unrelated.txt"));
let status = Command::new("git")
.args(["status", "--porcelain"])
.current_dir(root)
.output()
.unwrap();
let status = String::from_utf8_lossy(&status.stdout);
assert!(
status.contains("A unrelated.txt"),
"unrelated.txt must remain staged-but-uncommitted, got: {status}"
);
}
fn rev_list_count(root: &Path) -> u32 {
let output = Command::new("git")
.args(["rev-list", "--count", "HEAD"])
.current_dir(root)
.output()
.unwrap();
assert!(output.status.success(), "git rev-list --count HEAD failed");
String::from_utf8_lossy(&output.stdout)
.trim()
.parse::<u32>()
.expect("rev-list --count HEAD must print an integer")
}
#[test]
fn commit_path_twice_with_identical_content_creates_only_one_commit() {
let repo = init_repo();
let root = repo.path();
std::fs::write(root.join("CHANGELOG.md"), "# Changelog\n").unwrap();
flow(root)
.commit_path("CHANGELOG.md", "docs: add changelog entry")
.expect("first commit_path call");
let n1 = rev_list_count(root);
flow(root)
.commit_path("CHANGELOG.md", "docs: add changelog entry")
.expect("second commit_path call");
let n2 = rev_list_count(root);
assert_eq!(
n2, n1,
"a repeat commit_path call on unchanged content must not add a \
commit: n1={n1}, n2={n2}"
);
}
#[test]
fn commit_path_with_no_changes_returns_ok_without_committing() {
let repo = init_repo();
let root = repo.path();
std::fs::write(root.join("CHANGELOG.md"), "# Changelog\n").unwrap();
flow(root)
.commit_path("CHANGELOG.md", "docs: add changelog entry")
.expect("initial commit_path");
let n1 = rev_list_count(root);
let result = flow(root).commit_path("CHANGELOG.md", "docs: add changelog entry");
let n2 = rev_list_count(root);
assert!(
result.is_ok(),
"no-op call must return Ok(()), got: {result:?}"
);
assert_eq!(
n2, n1,
"no-op call must not create a commit: n1={n1}, n2={n2}"
);
}
#[test]
fn commit_path_on_nonexistent_path_still_errors() {
let repo = init_repo();
let root = repo.path();
let result = flow(root).commit_path("does-not-exist.md", "docs: add changelog entry");
assert!(
result.is_err(),
"commit_path on an unknown pathspec must still error, got: {result:?}"
);
}
#[test]
fn release_start_branches_from_current_head_not_develop() {
let repo = init_repo();
let root = repo.path();
let gf = flow(root);
gf.feature_start(5).expect("feature_start");
commit_file(root, "feature-only.txt");
let feature_tip = gf.branch_tip("feature/phase-05").expect("feature tip");
let branch = gf.release_start("2.0.0").expect("release_start");
assert_eq!(branch, "release/2.0.0");
assert_eq!(current_branch(root), "release/2.0.0");
let release_tip = gf.branch_tip("release/2.0.0").expect("release tip");
let is_ancestor = Command::new("git")
.args(["merge-base", "--is-ancestor", &feature_tip, &release_tip])
.current_dir(root)
.output()
.unwrap()
.status
.success();
assert!(
is_ancestor,
"release branch must descend from the shipped feature commit"
);
assert!(root.join("feature-only.txt").exists());
}
#[test]
fn cleanup_merged_removes_merged_but_keeps_protected() {
let repo = init_repo();
let root = repo.path();
let gf = flow(root);
gf.feature_start(2).expect("start");
commit_file(root, "f.txt");
gf.feature_finish(2).expect("finish");
git(root, &["branch", "stale-merged"]);
let deleted = gf.cleanup_merged().expect("cleanup");
assert!(deleted.contains(&"stale-merged".to_string()));
assert!(!deleted.contains(&"develop".to_string()));
assert!(!deleted.contains(&"main".to_string()));
}
#[test]
fn cleanup_merged_is_relative_to_develop_not_current_head() {
let repo = init_repo();
let root = repo.path();
let gf = flow(root);
git(root, &["checkout", "-q", "-b", "topic", "develop"]);
commit_file(root, "topic-only.txt");
git(root, &["checkout", "-q", "-b", "premature", "topic"]);
git(root, &["checkout", "-q", "topic"]);
let _ = gf.cleanup_merged();
assert!(
gf.branch_exists("premature"),
"premature is merged into topic (current HEAD) but not into \
develop — it must survive cleanup_merged when the baseline is develop"
);
}
#[test]
fn cleanup_merged_skips_worktree_branch_and_continues_sweep() {
let repo = init_repo();
let root = repo.path();
let gf = flow(root);
git(
root,
&["checkout", "-q", "-b", "worktree-merged", "develop"],
);
commit_file(root, "g.txt");
git(root, &["checkout", "-q", "develop"]);
git(root, &["merge", "-q", "--no-ff", "worktree-merged"]);
let wt_dir = tempfile::tempdir().unwrap();
git(
root,
&[
"worktree",
"add",
wt_dir.path().to_str().unwrap(),
"worktree-merged",
],
);
git(root, &["branch", "aa-stale"]);
git(root, &["branch", "zz-stale"]);
let deleted = gf
.cleanup_merged()
.expect("a skipped worktree branch must not abort the sweep");
assert!(deleted.contains(&"aa-stale".to_string()));
assert!(deleted.contains(&"zz-stale".to_string()));
assert!(
!deleted.contains(&"worktree-merged".to_string()),
"worktree checkout cannot be deleted"
);
assert!(gf.branch_exists("worktree-merged"));
}
#[test]
fn cleanup_merged_deletes_when_head_is_not_on_develop() {
let repo = init_repo();
let root = repo.path();
let gf = flow(root);
git(root, &["checkout", "-q", "-b", "old", "develop"]);
git(root, &["checkout", "-q", "develop"]);
git(root, &["checkout", "-q", "-b", "merged-feature", "develop"]);
commit_file(root, "h.txt");
git(root, &["checkout", "-q", "develop"]);
git(root, &["merge", "-q", "--no-ff", "merged-feature"]);
git(root, &["checkout", "-q", "old"]);
let deleted = gf.cleanup_merged().expect("cleanup");
assert!(
deleted.contains(&"merged-feature".to_string()),
"merged-into-develop branch must be deleted even when HEAD is elsewhere: {deleted:?}"
);
assert!(!gf.branch_exists("merged-feature"));
}
#[test]
fn delete_branch_removes_unmerged_with_force_and_protects_trunk() {
let repo = init_repo();
let root = repo.path();
let gf = flow(root);
gf.feature_start(8).expect("start");
commit_file(root, "unmerged.txt");
git(root, &["checkout", "-q", "develop"]);
assert!(gf.delete_branch("feature/phase-08", false).is_err());
gf.delete_branch("feature/phase-08", true)
.expect("force delete");
let branches = Command::new("git")
.args(["branch"])
.current_dir(root)
.output()
.unwrap();
assert!(!String::from_utf8_lossy(&branches.stdout).contains("feature/phase-08"));
assert!(gf.delete_branch("develop", true).is_err());
assert!(gf.delete_branch("main", true).is_err());
}
#[test]
fn sequentagent_helpers_integrate_and_rebase_cleanly() {
let repo = init_repo();
let root = repo.path();
let gf = flow(root);
gf.ensure_branch("feature/phase-07", "develop")
.expect("ensure base");
assert!(gf.branch_exists("feature/phase-07"));
assert!(!gf.branch_tip("feature/phase-07").unwrap().is_empty());
gf.ensure_branch("feature/phase-07", "develop")
.expect("ensure again");
let wt_a = root.join(".worktrees/a");
let wt_b = root.join(".worktrees/b");
crate::worktree::add(root, &wt_a, "feat-a", "feature/phase-07", true).expect("add A");
crate::worktree::add(root, &wt_b, "feat-b", "feature/phase-07", true).expect("add B");
std::fs::write(wt_a.join("a.txt"), "from-a\n").unwrap();
git(&wt_a, &["add", "."]);
git(&wt_a, &["commit", "-q", "-m", "a work"]);
gf.fast_forward_branch("feature/phase-07", "feat-a")
.expect("ff base to A");
assert_eq!(
gf.branch_tip("feature/phase-07").unwrap(),
gf.branch_tip("feat-a").unwrap()
);
gf.rebase_in(&wt_b, "feature/phase-07")
.expect("clean rebase");
assert!(wt_b.join("a.txt").exists());
}
#[test]
fn rebase_in_aborts_and_errors_on_conflict() {
let repo = init_repo();
let root = repo.path();
let gf = flow(root);
gf.ensure_branch("feature/phase-07", "develop")
.expect("ensure base");
let wt_b = root.join(".worktrees/b");
crate::worktree::add(root, &wt_b, "feat-b", "feature/phase-07", true).expect("add B");
std::fs::write(wt_b.join("a.txt"), "from-b\n").unwrap();
git(&wt_b, &["add", "."]);
git(&wt_b, &["commit", "-q", "-m", "b edits a"]);
let wt_a = root.join(".worktrees/a");
crate::worktree::add(root, &wt_a, "feat-a", "feature/phase-07", true).expect("add A");
std::fs::write(wt_a.join("a.txt"), "from-base\n").unwrap();
git(&wt_a, &["add", "."]);
git(&wt_a, &["commit", "-q", "-m", "base edits a"]);
gf.fast_forward_branch("feature/phase-07", "feat-a")
.expect("ff base to A");
let err = gf.rebase_in(&wt_b, "feature/phase-07").unwrap_err();
assert!(matches!(err, GitError::Command(_)));
assert!(!root.join(".git/worktrees/b/rebase-merge").exists());
assert_eq!(
std::fs::read_to_string(wt_b.join("a.txt")).unwrap(),
"from-b\n"
);
}
#[test]
fn merge_of_missing_branch_is_an_error() {
let repo = init_repo();
let root = repo.path();
let err = flow(root).feature_finish(99).unwrap_err();
assert!(matches!(err, GitError::Command(_)));
}
#[test]
fn workspace_member_paths_parses_multiline_array() {
let contents = "[workspace]\nresolver = \"2\"\nmembers = [\n \"crates/devflow-core\",\n \"crates/devflow-cli\",\n]\n";
assert_eq!(
workspace_member_paths(contents),
vec![
"crates/devflow-core".to_string(),
"crates/devflow-cli".to_string()
]
);
}
#[test]
fn package_name_reads_the_package_section() {
let contents = "[package]\nname = \"devflow-core\"\nversion.workspace = true\n";
assert_eq!(package_name(contents), Some("devflow-core".to_string()));
}
#[test]
fn member_depends_on_matches_dotted_workspace_shorthand() {
let contents = "[package]\nname = \"devflow\"\n\n[dependencies]\ndevflow-core.workspace = true\nclap.workspace = true\n";
assert!(member_depends_on(contents, "devflow-core"));
assert!(!member_depends_on(contents, "serde"));
}
#[test]
fn member_depends_on_matches_long_form_dependency_section() {
let contents = "[package]\nname = \"devflow\"\n\n[dependencies.devflow-core]\nworkspace = true\n\n[dependencies.clap]\nversion = \"4\"\n";
assert!(member_depends_on(contents, "devflow-core"));
assert!(member_depends_on(contents, "clap"));
assert!(!member_depends_on(contents, "serde"));
}
#[test]
fn topo_sort_orders_dependency_before_dependent() {
let names = vec!["devflow".to_string(), "devflow-core".to_string()];
let edges = vec![("devflow".to_string(), "devflow-core".to_string())];
assert_eq!(
topo_sort(names, edges),
vec!["devflow-core".to_string(), "devflow".to_string()]
);
}
#[test]
fn topo_sort_falls_back_to_input_order_on_a_cycle() {
let names = vec!["a".to_string(), "b".to_string()];
let edges = vec![
("a".to_string(), "b".to_string()),
("b".to_string(), "a".to_string()),
];
let result = topo_sort(names, edges);
assert_eq!(result.len(), 2);
}
#[test]
fn publish_order_derives_core_before_cli_from_a_fixture_workspace() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(
root.join("Cargo.toml"),
"[workspace]\nmembers = [\n \"crates/devflow-core\",\n \"crates/devflow-cli\",\n]\n",
)
.unwrap();
std::fs::create_dir_all(root.join("crates/devflow-core")).unwrap();
std::fs::write(
root.join("crates/devflow-core/Cargo.toml"),
"[package]\nname = \"devflow-core\"\n\n[dependencies]\n",
)
.unwrap();
std::fs::create_dir_all(root.join("crates/devflow-cli")).unwrap();
std::fs::write(
root.join("crates/devflow-cli/Cargo.toml"),
"[package]\nname = \"devflow\"\n\n[dependencies]\ndevflow-core.workspace = true\n",
)
.unwrap();
assert_eq!(
publish_order(root),
vec!["devflow-core".to_string(), "devflow".to_string()]
);
}
#[test]
fn publish_order_recognizes_long_form_dependency_section_self_dependency() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(
root.join("Cargo.toml"),
"[workspace]\nmembers = [\n \"crates/devflow-core\",\n \"crates/devflow-cli\",\n]\n",
)
.unwrap();
std::fs::create_dir_all(root.join("crates/devflow-core")).unwrap();
std::fs::write(
root.join("crates/devflow-core/Cargo.toml"),
"[package]\nname = \"devflow-core\"\n\n[dependencies]\n",
)
.unwrap();
std::fs::create_dir_all(root.join("crates/devflow-cli")).unwrap();
std::fs::write(
root.join("crates/devflow-cli/Cargo.toml"),
"[package]\nname = \"devflow\"\n\n[dependencies.devflow-core]\nworkspace = true\n",
)
.unwrap();
assert_eq!(
publish_order(root),
vec!["devflow-core".to_string(), "devflow".to_string()],
"the long-form dependency section must still order devflow-core before devflow"
);
}
#[test]
fn origin_main_ancestor_status_is_ref_absent_without_a_remote() {
let repo = init_repo();
let root = repo.path();
assert_eq!(origin_main_ancestor_status(root), AncestorStatus::RefAbsent);
}
#[test]
fn origin_main_ancestor_status_is_ancestor_when_head_is_up_to_date() {
let repo = init_repo();
let root = repo.path();
let head = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(root)
.output()
.unwrap();
let head_sha = String::from_utf8_lossy(&head.stdout).trim().to_string();
git(root, &["update-ref", "refs/remotes/origin/main", &head_sha]);
assert_eq!(origin_main_ancestor_status(root), AncestorStatus::Ancestor);
}
#[test]
fn classify_ssh_add_status_maps_all_three_documented_exit_codes() {
assert_eq!(classify_ssh_add_status(2), SigningStatus::NoAgent);
assert_eq!(classify_ssh_add_status(1), SigningStatus::AgentEmpty);
assert_eq!(classify_ssh_add_status(0), SigningStatus::KeysListed);
assert_eq!(classify_ssh_add_status(7), SigningStatus::Unknown(7));
}
static HOME_ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[test]
fn check_signing_viability_degrades_when_gpg_format_unset_and_no_signingkey() {
let _lock = HOME_ENV_MUTEX.lock().unwrap();
let repo = init_repo();
let root = repo.path();
let fake_home = tempfile::tempdir().unwrap();
let original_home = std::env::var_os("HOME");
unsafe { std::env::set_var("HOME", fake_home.path()) };
let result = check_signing_viability(root);
match original_home {
Some(home) => unsafe { std::env::set_var("HOME", home) },
None => unsafe { std::env::remove_var("HOME") },
}
match result {
SigningViability::Unknown { reason } => {
assert!(
reason.contains("user.signingkey"),
"unexpected reason: {reason}"
);
}
other => panic!("expected Unknown (fail-soft), got: {other:?}"),
}
}
}