use crate::config::GitFlowConfig;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
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 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 inline_signing_key_blob(signingkey: &str) -> Option<&str> {
let trimmed = signingkey.trim();
if let Some(remainder) = trimmed.strip_prefix("key::") {
Some(remainder)
} else if trimmed.starts_with("ssh-") {
Some(trimmed)
} else {
None
}
}
fn inline_key_fingerprint(key_blob: &str) -> Option<String> {
let mut child = Command::new("ssh-keygen")
.args(["-lf", "-"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.ok()?;
let mut stdin = child.stdin.take()?;
stdin.write_all(key_blob.as_bytes()).ok()?;
drop(stdin);
let output = child.wait_with_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 inline_blob = inline_signing_key_blob(&signingkey);
if inline_blob.is_none() {
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);
let fingerprint = match inline_blob {
Some(blob) => inline_key_fingerprint(blob),
None => public_key_fingerprint(Path::new(&signingkey)),
};
match fingerprint {
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 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 tempfile::TempDir;
fn git(root: &Path, args: &[&str]) {
let output = crate::test_support::git_command(root)
.args(args)
.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 = crate::test_support::git_command(root)
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.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 = crate::test_support::git_command(root)
.args(["branch"])
.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 = crate::test_support::git_command(root)
.args(["tag"])
.output()
.unwrap();
assert!(String::from_utf8_lossy(&tags.stdout).contains("v1.2.0"));
let branches = crate::test_support::git_command(root)
.args(["branch"])
.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 = crate::test_support::git_command(root)
.args(["tag", "-l"])
.output()
.unwrap();
assert!(String::from_utf8_lossy(&tags.stdout).contains("v9.9.9"));
let obj_type = crate::test_support::git_command(root)
.args(["cat-file", "-t", "v9.9.9"])
.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();
crate::test_support::git_command(root)
.args(["add", "unrelated.txt"])
.status()
.unwrap();
flow(root)
.commit_path("CHANGELOG.md", "docs: add changelog entry")
.expect("commit_path");
let committed = crate::test_support::git_command(root)
.args(["log", "-1", "--name-only", "--pretty=format:"])
.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 = crate::test_support::git_command(root)
.args(["status", "--porcelain"])
.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 = crate::test_support::git_command(root)
.args(["rev-list", "--count", "HEAD"])
.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 = crate::test_support::git_command(root)
.args(["merge-base", "--is-ancestor", &feature_tip, &release_tip])
.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 = crate::test_support::git_command(root)
.args(["branch"])
.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 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 = crate::test_support::git_command(root)
.args(["rev-parse", "HEAD"])
.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:?}"),
}
}
#[test]
fn check_signing_viability_never_reports_key_file_missing_for_inline_key() {
const MISSING_FILE_REASON: &str = "user.signingkey is set but the key file does not exist";
let inline_values = [
"key::ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFAKEFIXTUREKEYMATERIALZZZZZZZZZZZZZZZZZZZZZZ devflow-fixture",
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFAKEFIXTUREKEYMATERIALZZZZZZZZZZZZZZZZZZZZZZ devflow-fixture",
];
for value in inline_values {
let repo = init_repo();
let root = repo.path();
git(root, &["config", "gpg.format", "ssh"]);
git(root, &["config", "user.signingkey", value]);
let result = check_signing_viability(root);
if let SigningViability::NotViable { reason } = &result {
assert_ne!(
reason, MISSING_FILE_REASON,
"inline signingkey value {value:?} incorrectly classified as a \
missing file: {result:?}"
);
}
}
}
#[test]
fn inline_signing_key_blob_follows_git_prefix_precedence() {
assert_eq!(
inline_signing_key_blob("key::ssh-rsa AAAAB3 id"),
Some("ssh-rsa AAAAB3 id")
);
assert_eq!(
inline_signing_key_blob("key::ssh-ed25519 AAAAC3 id"),
Some("ssh-ed25519 AAAAC3 id")
);
assert_eq!(
inline_signing_key_blob("key::ecdsa-sha2-nistp256 AAAAE2 id"),
Some("ecdsa-sha2-nistp256 AAAAE2 id")
);
assert_eq!(inline_signing_key_blob("key::"), Some(""));
assert_eq!(
inline_signing_key_blob("ssh-ed25519 AAAAC3 id"),
Some("ssh-ed25519 AAAAC3 id")
);
assert_eq!(
inline_signing_key_blob(" key::ssh-ed25519 AAAAC3 id "),
Some("ssh-ed25519 AAAAC3 id")
);
assert_eq!(inline_signing_key_blob("ssh-key.pub"), Some("ssh-key.pub"));
assert_eq!(
inline_signing_key_blob("/home/operator/.ssh/id_ed25519.pub"),
None
);
assert_eq!(
inline_signing_key_blob("ecdsa-sha2-nistp256 AAAAE2 id"),
None
);
assert_eq!(
inline_signing_key_blob("sk-ssh-ed25519@openssh.com AAAAG id"),
None
);
assert_eq!(inline_signing_key_blob("ABCD1234"), None);
}
#[test]
fn check_signing_viability_still_reports_missing_file_for_a_path_value() {
const MISSING_FILE_REASON: &str = "user.signingkey is set but the key file does not exist";
let path_values = [
"/nonexistent/path/to/a/signing/key/that/does/not/exist",
"ecdsa-sha2-nistp256 AAAAE2 devflow-fixture",
"sk-ssh-ed25519@openssh.com AAAAG devflow-fixture",
];
for value in path_values {
let repo = init_repo();
let root = repo.path();
git(root, &["config", "gpg.format", "ssh"]);
git(root, &["config", "user.signingkey", value]);
let result = check_signing_viability(root);
assert_eq!(
result,
SigningViability::NotViable {
reason: MISSING_FILE_REASON.to_string(),
},
"value {value:?} did not take the path branch: {result:?}"
);
}
}
#[test]
fn inline_key_fingerprint_matches_the_path_branch_for_the_same_key() {
let dir = tempfile::tempdir().unwrap();
let key_path = dir.path().join("devflow-fixture-key");
let keygen = Command::new("ssh-keygen")
.args([
"-t",
"ed25519",
"-f",
key_path.to_str().unwrap(),
"-N",
"",
"-q",
])
.output()
.expect("spawn ssh-keygen");
assert!(
keygen.status.success(),
"ssh-keygen fixture setup failed: {}",
String::from_utf8_lossy(&keygen.stderr)
);
let pub_key_path = dir.path().join("devflow-fixture-key.pub");
let blob = std::fs::read_to_string(&pub_key_path)
.unwrap()
.trim()
.to_string();
let inline_fp = inline_key_fingerprint(&blob);
assert!(
inline_fp.is_some(),
"inline_key_fingerprint returned None for a real key"
);
let inline_fp = inline_fp.unwrap();
assert!(
inline_fp.starts_with("SHA256:"),
"unexpected fingerprint shape: {inline_fp}"
);
let path_fp = public_key_fingerprint(&pub_key_path);
assert!(
path_fp.is_some(),
"public_key_fingerprint returned None for a real key"
);
let path_fp = path_fp.unwrap();
assert_eq!(inline_fp, path_fp);
let prefixed = format!("key::{blob}");
let classified_blob = inline_signing_key_blob(&prefixed).unwrap();
let chained_fp = inline_key_fingerprint(classified_blob).unwrap();
assert_eq!(chained_fp, path_fp);
}
#[test]
fn check_signing_viability_never_hard_fails_on_an_unparseable_inline_key() {
const NO_AGENT_REASON: &str = "no ssh-agent reachable (SSH_AUTH_SOCK unset or dead)";
const AGENT_EMPTY_REASON: &str = "ssh-agent reachable but has no identities loaded";
let unparseable_values = ["key::", "key::this is not a key at all"];
for value in unparseable_values {
let repo = init_repo();
let root = repo.path();
git(root, &["config", "gpg.format", "ssh"]);
git(root, &["config", "user.signingkey", value]);
let result = check_signing_viability(root);
if let SigningViability::NotViable { reason } = &result {
assert!(
reason == NO_AGENT_REASON || reason == AGENT_EMPTY_REASON,
"value {value:?} produced an unexpected hard fail: {result:?}"
);
}
}
assert_eq!(inline_key_fingerprint(""), None);
assert_eq!(inline_key_fingerprint("not a key\n"), None);
}
}