use crate::config::GitFlowConfig;
use crate::phase_id::PhaseId;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
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),
}
pub const REPO_LOCAL_GIT_VARS: &[&str] = &[
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
"GIT_CONFIG",
"GIT_CONFIG_PARAMETERS",
"GIT_CONFIG_COUNT",
"GIT_OBJECT_DIRECTORY",
"GIT_DIR",
"GIT_WORK_TREE",
"GIT_IMPLICIT_WORK_TREE",
"GIT_GRAFT_FILE",
"GIT_INDEX_FILE",
"GIT_NO_REPLACE_OBJECTS",
"GIT_REPLACE_REF_BASE",
"GIT_PREFIX",
"GIT_SHALLOW_FILE",
"GIT_COMMON_DIR",
];
pub const ALSO_REDIRECTING_GIT_VARS: &[&str] = &[
"GIT_NAMESPACE",
"GIT_DISCOVERY_ACROSS_FILESYSTEM",
"GIT_CEILING_DIRECTORIES",
];
pub fn git_command(repo: &Path) -> Command {
hermetic_command("git", repo)
}
pub fn hermetic_command(program: &str, dir: &Path) -> Command {
let mut cmd = Command::new(program);
cmd.current_dir(dir);
for var in REPO_LOCAL_GIT_VARS.iter().chain(ALSO_REDIRECTING_GIT_VARS) {
cmd.env_remove(var);
}
cmd
}
#[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: PhaseId) -> Result<String, GitError> {
let branch = format!("{}phase-{}", self.config.feature_prefix, phase.padded());
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: PhaseId) -> Result<String, GitError> {
let branch = format!("{}phase-{}", self.config.feature_prefix, phase.padded());
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: PhaseId) -> 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: PhaseId) -> Result<String, GitError> {
let branch = format!("{}phase-{}", self.config.feature_prefix, phase.padded());
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: PhaseId) -> bool {
let branch = format!("{}phase-{}", self.config.feature_prefix, phase.padded());
if !self.branch_exists(&branch) {
return false;
}
git_command(&self.root)
.args(["merge-base", "--is-ancestor", &branch, &self.config.develop])
.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 {
git_command(&self.root)
.args([
"rev-parse",
"--verify",
"--quiet",
&format!("refs/heads/{branch}"),
])
.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 = git_command(&self.root)
.args(args)
.env("LC_ALL", "C")
.env("LANG", "C")
.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 = git_command(&self.root)
.args(args)
.env("LC_ALL", "C")
.env("LANG", "C")
.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 = git_command(&self.root).args(args).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 = git_command(&self.root).args(args).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 = git_command(project_root)
.args(["rev-parse", "--verify", "--quiet", "origin/main"])
.output()
.map(|out| out.status.success())
.unwrap_or(false);
if !ref_exists {
return AncestorStatus::RefAbsent;
}
let is_ancestor = git_command(project_root)
.args(["merge-base", "--is-ancestor", "origin/main", "HEAD"])
.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, 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 = git_command(project_root)
.args(["config", "--get", key])
.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
}
}
const SSH_SIGN_NAMESPACE: &str = "git";
const SSH_SIGN_PROBE_TIMEOUT: Duration = Duration::from_secs(10);
const SSH_SIGN_PROBE_POLL: Duration = Duration::from_millis(25);
fn probe_workspace_name() -> String {
use std::sync::atomic::{AtomicU64, Ordering};
static PROBE_SEQ: AtomicU64 = AtomicU64::new(0);
let seq = PROBE_SEQ.fetch_add(1, Ordering::Relaxed);
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|since| since.as_nanos())
.unwrap_or(0);
format!(
"devflow-sign-probe-{}-{}-{}",
std::process::id(),
seq,
nanos
)
}
enum SignProbeOutcome {
Signed,
Rejected,
TimedOut,
ToolMissing,
NotRun,
}
struct ProbeWorkspace(PathBuf);
impl Drop for ProbeWorkspace {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn run_ssh_sign_probe(key_path: &Path) -> SignProbeOutcome {
let workspace = std::env::temp_dir().join(probe_workspace_name());
if !create_probe_workspace(&workspace) {
return SignProbeOutcome::NotRun;
}
let _cleanup = ProbeWorkspace(workspace.clone());
sign_probe_within(&workspace, key_path)
}
fn create_probe_workspace(workspace: &Path) -> bool {
let mut builder = std::fs::DirBuilder::new();
#[cfg(unix)]
std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
builder.create(workspace).is_ok()
}
fn sign_probe_within(workspace: &Path, key_path: &Path) -> SignProbeOutcome {
let payload = workspace.join("payload");
if std::fs::write(&payload, b"devflow signing viability probe\n").is_err() {
return SignProbeOutcome::NotRun;
}
let (Some(key_arg), Some(payload_arg)) = (key_path.to_str(), payload.to_str()) else {
return SignProbeOutcome::NotRun;
};
let mut command = Command::new("ssh-keygen");
command
.args([
"-Y",
"sign",
"-n",
SSH_SIGN_NAMESPACE,
"-f",
key_arg,
payload_arg,
])
.env("SSH_ASKPASS_REQUIRE", "never")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
unsafe {
std::os::unix::process::CommandExt::pre_exec(&mut command, || {
libc::setsid();
Ok(())
});
}
let mut child = match command.spawn() {
Ok(child) => child,
Err(_) => return SignProbeOutcome::ToolMissing,
};
let deadline = Instant::now() + SSH_SIGN_PROBE_TIMEOUT;
loop {
match child.try_wait() {
Ok(Some(status)) => {
return if status.success() {
SignProbeOutcome::Signed
} else {
SignProbeOutcome::Rejected
};
}
Ok(None) => {}
Err(_) => {
let _ = child.kill();
let _ = child.wait();
return SignProbeOutcome::NotRun;
}
}
if Instant::now() >= deadline {
break;
}
std::thread::sleep(SSH_SIGN_PROBE_POLL);
}
let _ = child.kill();
let _ = child.wait();
SignProbeOutcome::TimedOut
}
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(),
};
};
if inline_signing_key_blob(&signingkey).is_some() {
return SigningViability::Unknown {
reason: "cannot verify signing viability — an inline user.signingkey is not probed"
.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(),
};
}
sign_probe_verdict(run_ssh_sign_probe(key_path), key_path)
}
fn sign_probe_verdict(outcome: SignProbeOutcome, key_path: &Path) -> SigningViability {
match outcome {
SignProbeOutcome::Signed => SigningViability::Viable {
fingerprint: public_key_fingerprint(key_path),
},
SignProbeOutcome::Rejected => SigningViability::NotViable {
reason: "the configured signing key could not sign a test payload".into(),
},
SignProbeOutcome::TimedOut => SigningViability::Unknown {
reason: "cannot verify signing viability — the signing probe did not finish \
within its time limit"
.into(),
},
SignProbeOutcome::ToolMissing => SigningViability::Unknown {
reason: "cannot verify signing viability — ssh-keygen not found".into(),
},
SignProbeOutcome::NotRun => SigningViability::Unknown {
reason: "cannot verify signing viability — the signing probe could not be run".into(),
},
}
}
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(PhaseId::new(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(PhaseId::new(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(PhaseId::new(1)).expect("start");
commit_file(root, "feature.txt");
let branch = gf.feature_finish(PhaseId::new(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(PhaseId::new(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(PhaseId::new(2)).expect("start");
commit_file(root, "f.txt");
gf.feature_finish(PhaseId::new(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(PhaseId::new(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(PhaseId::new(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 hermetic_command_resolves_caller_root_even_under_a_hostile_git_dir() {
let real_repo = init_repo();
let real_root = real_repo.path();
let foreign_repo = TempDir::new().unwrap();
git(foreign_repo.path(), &["init", "-q"]);
let output = git_command(real_root)
.args(["rev-parse", "--show-toplevel"])
.env("GIT_DIR", foreign_repo.path().join(".git"))
.output()
.expect("spawn git");
assert!(
output.status.success(),
"rev-parse --show-toplevel failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let resolved = std::fs::canonicalize(String::from_utf8_lossy(&output.stdout).trim())
.expect("canonicalize resolved toplevel");
let expected = std::fs::canonicalize(real_root).expect("canonicalize real_root");
assert_eq!(
resolved, expected,
"hermetic_command must resolve real_root even with a foreign GIT_DIR set"
);
}
#[test]
fn origin_main_ancestor_status_holds_under_a_hostile_git_dir() {
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]);
let cmd = git_command(root);
assert!(
cmd.get_envs()
.any(|(key, value)| key == "GIT_DIR" && value.is_none()),
"origin_main_ancestor_status's own Command must mark GIT_DIR for removal"
);
assert_eq!(origin_main_ancestor_status(root), AncestorStatus::Ancestor);
}
#[test]
fn git_command_marks_every_redirecting_var_for_removal() {
let cmd = git_command(Path::new("/tmp"));
let removed: Vec<&str> = cmd
.get_envs()
.filter(|(_, value)| value.is_none())
.filter_map(|(key, _)| key.to_str())
.collect();
for var in REPO_LOCAL_GIT_VARS.iter().chain(ALSO_REDIRECTING_GIT_VARS) {
assert!(
removed.contains(var),
"{var} is not cleared by git_command — a fixture inheriting it \
would operate on that repository instead of its tempdir"
);
}
}
#[test]
fn git_command_preserves_git_exec_path() {
let cmd = git_command(Path::new("/tmp"));
assert!(
!cmd.get_envs()
.any(|(key, value)| key == "GIT_EXEC_PATH" && value.is_none()),
"GIT_EXEC_PATH must not be cleared"
);
}
#[test]
fn local_env_vars_match_git() {
let output = git_command(Path::new("/tmp"))
.args(["rev-parse", "--local-env-vars"])
.output()
.expect("run `git rev-parse --local-env-vars`");
assert!(
output.status.success(),
"`git rev-parse --local-env-vars` failed"
);
let mut from_git: Vec<String> = String::from_utf8_lossy(&output.stdout)
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(str::to_string)
.collect();
let mut ours: Vec<String> = REPO_LOCAL_GIT_VARS
.iter()
.map(|v| (*v).to_string())
.collect();
from_git.sort();
ours.sort();
assert_eq!(
ours, from_git,
"REPO_LOCAL_GIT_VARS has drifted from `git rev-parse --local-env-vars`"
);
}
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 check_signing_viability_never_hard_fails_on_an_unparseable_inline_key() {
const INLINE_REASON: &str =
"cannot verify signing viability — an inline user.signingkey is not probed";
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);
assert_eq!(
result,
SigningViability::Unknown {
reason: INLINE_REASON.into(),
},
"value {value:?} produced an unexpected hard fail: {result:?}"
);
}
}
#[test]
fn probe_workspace_name_is_unique_per_call() {
let first = probe_workspace_name();
let second = probe_workspace_name();
assert_ne!(
first, second,
"two successive calls on one thread produced the same probe workspace name"
);
const PER_THREAD: usize = 64;
let handles: Vec<_> = (0..2)
.map(|_| {
std::thread::spawn(|| {
(0..PER_THREAD)
.map(|_| probe_workspace_name())
.collect::<Vec<_>>()
})
})
.collect();
let mut names: Vec<String> = handles
.into_iter()
.flat_map(|handle| handle.join().expect("probe-name thread panicked"))
.collect();
let total = names.len();
assert_eq!(total, 2 * PER_THREAD, "fixture did not produce every name");
names.sort();
names.dedup();
assert_eq!(
names.len(),
total,
"two concurrently spawned threads produced duplicate probe workspace names"
);
}
#[test]
fn the_probe_workspace_is_owner_only_and_refuses_an_existing_path() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let workspace = dir.path().join("probe");
let plain = dir.path().join("plain");
let previous_umask = unsafe { libc::umask(0) };
let created = create_probe_workspace(&workspace);
let plain_created = std::fs::create_dir(&plain).is_ok();
unsafe {
libc::umask(previous_umask);
}
assert!(created, "the fixture needs the creation to succeed");
assert!(plain_created, "the fixture needs the control to be created");
let control = std::fs::metadata(&plain).unwrap().permissions().mode() & 0o777;
assert_eq!(
control, 0o777,
"NEGATIVE CONTROL: with the umask neutralized a default creation must be wide \
open. If it is not, the umask window did not take and the assertion below \
cannot distinguish the fix from the default"
);
let mode = std::fs::metadata(&workspace).unwrap().permissions().mode() & 0o777;
assert_eq!(
mode, 0o700,
"the workspace must be owner-only by request, not by whatever the umask happened \
to strip"
);
assert!(
!create_probe_workspace(&workspace),
"a pre-planted directory or symlink must not be adopted — that is how a payload \
gets written somewhere the probe did not choose"
);
}
#[test]
fn the_probe_workspace_guard_removes_its_directory_on_unwind() {
let dir = tempfile::tempdir().unwrap();
let workspace = dir.path().join("probe");
assert!(create_probe_workspace(&workspace));
assert!(
workspace.exists(),
"premise: the directory must exist before the panic, or its later absence \
establishes nothing"
);
let panicked = std::panic::catch_unwind({
let workspace = workspace.clone();
move || {
let _cleanup = ProbeWorkspace(workspace);
panic!("the probe panicked mid-flight");
}
})
.is_err();
assert!(panicked, "the fixture must actually unwind");
assert!(
!workspace.exists(),
"a panic inside the probe must not leak its workspace into the shared temp dir"
);
}
#[test]
fn a_probe_timeout_is_unknown_while_a_rejection_stays_not_viable() {
let unused_key = Path::new("/nonexistent/devflow-wr01");
let timed_out = sign_probe_verdict(SignProbeOutcome::TimedOut, unused_key);
match &timed_out {
SigningViability::Unknown { reason } => assert!(
reason.starts_with("cannot verify signing viability — "),
"a non-verdict must carry the file's fail-soft prefix, got: {reason:?}"
),
other => panic!(
"a timeout establishes nothing about the key and must not be a hard \
verdict, got: {other:?}"
),
}
let rejected = sign_probe_verdict(SignProbeOutcome::Rejected, unused_key);
assert!(
matches!(rejected, SigningViability::NotViable { .. }),
"NEGATIVE CONTROL: a key that ran the probe and could not sign IS evidence \
about the key and must stay a hard verdict, got: {rejected:?}"
);
for outcome in [SignProbeOutcome::ToolMissing, SignProbeOutcome::NotRun] {
assert!(
matches!(
sign_probe_verdict(outcome, unused_key),
SigningViability::Unknown { .. }
),
"every measurement failure maps to Unknown"
);
}
}
fn generate_keypair(stem: &Path, passphrase: &str) -> PathBuf {
let keygen = Command::new("ssh-keygen")
.args([
"-t",
"ed25519",
"-f",
stem.to_str().unwrap(),
"-N",
passphrase,
"-q",
])
.output()
.expect("spawn ssh-keygen");
assert!(
keygen.status.success(),
"ssh-keygen fixture setup failed: {}",
String::from_utf8_lossy(&keygen.stderr)
);
let pub_path = stem.with_extension("pub");
assert!(pub_path.exists(), "ssh-keygen wrote no public key");
pub_path
}
fn configure_ssh_signing(root: &Path, key: &Path) {
git(root, &["config", "gpg.format", "ssh"]);
git(root, &["config", "user.signingkey", key.to_str().unwrap()]);
}
fn assert_no_leak(result: &SigningViability, secret_dir: &Path) {
let rendered = format!("{result:?}");
assert!(
!rendered.contains(secret_dir.to_str().unwrap()),
"signing viability leaked a filesystem path: {rendered}"
);
for fragment in [
"PRIVATE KEY",
"No private key found",
"Couldn't load public key",
"Enter passphrase",
"incorrect passphrase",
] {
assert!(
!rendered.contains(fragment),
"signing viability leaked key material or ssh-keygen stderr ({fragment:?}): \
{rendered}"
);
}
}
#[test]
fn ssh_signing_probe_reports_viable_with_on_disk_private_key() {
let repo = init_repo();
let root = repo.path();
let keys = tempfile::tempdir().unwrap();
let pub_key = generate_keypair(&keys.path().join("probe-key"), "");
configure_ssh_signing(root, &pub_key);
let result = check_signing_viability(root);
match &result {
SigningViability::Viable { fingerprint } => {
let fingerprint = fingerprint
.as_deref()
.expect("Viable must carry the public key fingerprint");
assert!(
fingerprint.starts_with("SHA256:"),
"unexpected fingerprint shape: {fingerprint}"
);
}
other => panic!("expected Viable for an on-disk private key, got: {other:?}"),
}
assert_no_leak(&result, keys.path());
}
#[test]
fn ssh_signing_probe_reports_not_viable_without_a_private_key() {
let repo = init_repo();
let root = repo.path();
let keys = tempfile::tempdir().unwrap();
let stem = keys.path().join("probe-key");
let pub_key = generate_keypair(&stem, "");
std::fs::remove_file(&stem).expect("remove the private half");
assert!(!stem.exists(), "fixture still has a private key");
configure_ssh_signing(root, &pub_key);
let result = check_signing_viability(root);
assert_eq!(
result,
SigningViability::NotViable {
reason: "the configured signing key could not sign a test payload".into(),
},
"expected the verdict to flip without a private key, got: {result:?}"
);
assert_no_leak(&result, keys.path());
}
fn askpass_arm(dir: &Path, askpass_require: Option<&str>) -> std::process::Child {
use std::os::unix::process::CommandExt;
let payload = dir.join(probe_workspace_name());
std::fs::write(&payload, b"nc-10 payload\n").expect("write nc-10 payload");
let mut command = Command::new("ssh-keygen");
command
.args([
"-Y",
"sign",
"-n",
SSH_SIGN_NAMESPACE,
"-f",
dir.join("encrypted-key.pub").to_str().unwrap(),
payload.to_str().unwrap(),
])
.env("SSH_ASKPASS", dir.join("askpass.sh"))
.env("DISPLAY", ":0")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
match askpass_require {
Some(value) => command.env("SSH_ASKPASS_REQUIRE", value),
None => command.env_remove("SSH_ASKPASS_REQUIRE"),
};
unsafe {
command.pre_exec(|| {
if libc::setsid() == -1 {
return Err(std::io::Error::last_os_error());
}
Ok(())
});
}
command.spawn().expect("spawn ssh-keygen for NC-10")
}
fn encrypted_key_fixture() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
generate_keypair(&dir.path().join("encrypted-key"), "devflow-nc10-passphrase");
let askpass = dir.path().join("askpass.sh");
std::fs::write(
&askpass,
"#!/bin/sh\nsleep 5\necho devflow-nc10-passphrase\n",
)
.unwrap();
let mut perms = std::fs::metadata(&askpass).unwrap().permissions();
std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
std::fs::set_permissions(&askpass, perms).unwrap();
dir
}
fn wait_bounded(child: &mut std::process::Child, window: Duration) -> Option<Duration> {
let started = Instant::now();
let deadline = started + window;
loop {
match child.try_wait().expect("poll nc-10 child") {
Some(_) => return Some(started.elapsed()),
None => {
if Instant::now() >= deadline {
return None;
}
std::thread::sleep(Duration::from_millis(5));
}
}
}
}
#[test]
fn ssh_signing_probe_does_not_block_on_an_encrypted_key() {
let dir = encrypted_key_fixture();
let mut child = askpass_arm(dir.path(), Some("never"));
let elapsed = wait_bounded(&mut child, SSH_SIGN_PROBE_TIMEOUT / 2);
if elapsed.is_none() {
let _ = child.kill();
let _ = child.wait();
}
let elapsed = elapsed.expect(
"SSH_ASKPASS_REQUIRE=never did not stop ssh-keygen blocking on the askpass helper",
);
eprintln!("NC-10 non-blocking arm exited in {elapsed:?}");
assert!(
elapsed < Duration::from_secs(2),
"the non-blocking arm took {elapsed:?}, which is too slow to calibrate a control"
);
}
#[test]
fn encrypted_key_blocks_without_the_askpass_require_env_var() {
const CALIBRATION_MULTIPLE: u32 = 8;
const MIN_WINDOW: Duration = Duration::from_millis(1000);
let dir = encrypted_key_fixture();
let mut baseline_child = askpass_arm(dir.path(), Some("never"));
let baseline = wait_bounded(&mut baseline_child, SSH_SIGN_PROBE_TIMEOUT / 2);
if baseline.is_none() {
let _ = baseline_child.kill();
let _ = baseline_child.wait();
}
let baseline = baseline.expect(
"control uncalibrated: the non-blocking arm never exited, so there is no baseline \
to derive an observation window from",
);
let window = std::cmp::max(baseline * CALIBRATION_MULTIPLE, MIN_WINDOW);
assert!(
window >= baseline * 4,
"control uncalibrated: observation window {window:?} is not at least four times \
the measured non-blocking exit of {baseline:?}"
);
assert!(
window < SSH_SIGN_PROBE_TIMEOUT / 2,
"control uncalibrated: observation window {window:?} is not comfortably under the \
probe's own {SSH_SIGN_PROBE_TIMEOUT:?} ceiling, so it would measure the ceiling \
rather than SSH_ASKPASS_REQUIRE"
);
let mut blocking_child = askpass_arm(dir.path(), None);
let blocked = wait_bounded(&mut blocking_child, window);
let _ = blocking_child.kill();
let _ = blocking_child.wait();
eprintln!(
"NC-10 calibration: non-blocking exit {baseline:?}, observation window {window:?} \
({CALIBRATION_MULTIPLE}x, floored at {MIN_WINDOW:?}), blocking arm {blocked:?}"
);
assert!(
blocked.is_none(),
"NC-10 control FAILED: with SSH_ASKPASS_REQUIRE omitted the child still exited in \
{blocked:?}, inside the {window:?} window. A control that agrees with its positive \
case is a broken measurement, not evidence — nothing here supports the conclusion \
that the environment variable is what prevents the hang"
);
}
const TTY_PROBE_KEY_ENV: &str = "DEVFLOW_TTY_PROBE_KEY";
const TTY_PROBE_CHILD: &str = "git::tests::ssh_sign_probe_tty_child_entrypoint";
const EXIT_PROBE_REJECTED: i32 = 42;
const EXIT_PROBE_TIMED_OUT: i32 = 43;
const EXIT_PROBE_OTHER: i32 = 44;
const EXIT_NO_CONTROLLING_TTY: i32 = 97;
struct Pty {
master: libc::c_int,
slave: libc::c_int,
}
impl Pty {
fn open() -> Pty {
unsafe {
let master = libc::posix_openpt(libc::O_RDWR | libc::O_NOCTTY);
assert!(
master >= 0,
"posix_openpt failed: {}",
std::io::Error::last_os_error()
);
let mut pty = Pty { master, slave: -1 };
assert!(
libc::grantpt(master) == 0,
"grantpt failed: {}",
std::io::Error::last_os_error()
);
assert!(
libc::unlockpt(master) == 0,
"unlockpt failed: {}",
std::io::Error::last_os_error()
);
let mut name = [0 as libc::c_char; 128];
assert!(
libc::ptsname_r(master, name.as_mut_ptr(), name.len()) == 0,
"ptsname_r failed: {}",
std::io::Error::last_os_error()
);
let slave = libc::open(name.as_ptr(), libc::O_RDWR | libc::O_NOCTTY);
assert!(
slave >= 0,
"opening the pty slave failed: {}",
std::io::Error::last_os_error()
);
pty.slave = slave;
pty
}
}
}
impl Drop for Pty {
fn drop(&mut self) {
unsafe {
if self.slave >= 0 {
libc::close(self.slave);
}
libc::close(self.master);
}
}
}
fn spawn_owning_controlling_tty(command: &mut Command, pty: &Pty) -> std::process::Child {
use std::os::unix::process::CommandExt;
let slave = pty.slave;
unsafe {
command.pre_exec(move || {
if libc::setsid() == -1 {
return Err(std::io::Error::last_os_error());
}
if libc::ioctl(slave, libc::TIOCSCTTY, 0) == -1 {
return Err(std::io::Error::last_os_error());
}
Ok(())
});
}
command
.spawn()
.expect("spawn a child owning the pty as its controlling terminal")
}
fn spawn_detached_from_terminal(command: &mut Command) -> std::process::Child {
use std::os::unix::process::CommandExt;
unsafe {
command.pre_exec(|| {
if libc::setsid() == -1 {
return Err(std::io::Error::last_os_error());
}
Ok(())
});
}
command
.spawn()
.expect("spawn a child detached from any controlling terminal")
}
fn tty_control_arm(key_pub: &Path, payload: &Path) -> Command {
let mut command = Command::new("ssh-keygen");
command
.args([
"-Y",
"sign",
"-n",
SSH_SIGN_NAMESPACE,
"-f",
key_pub.to_str().unwrap(),
payload.to_str().unwrap(),
])
.env("SSH_ASKPASS_REQUIRE", "never")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
command
}
#[test]
fn ssh_sign_probe_tty_child_entrypoint() {
let Ok(key) = std::env::var(TTY_PROBE_KEY_ENV) else {
return;
};
let tty_path = std::ffi::CString::new("/dev/tty").unwrap();
let tty = unsafe { libc::open(tty_path.as_ptr(), libc::O_RDWR) };
if tty < 0 {
std::process::exit(EXIT_NO_CONTROLLING_TTY);
}
unsafe { libc::close(tty) };
std::process::exit(match run_ssh_sign_probe(Path::new(&key)) {
SignProbeOutcome::Rejected => EXIT_PROBE_REJECTED,
SignProbeOutcome::TimedOut => EXIT_PROBE_TIMED_OUT,
_ => EXIT_PROBE_OTHER,
});
}
#[test]
fn the_signing_probe_is_not_captured_by_a_controlling_terminal() {
const CALIBRATION_MULTIPLE: u32 = 8;
const MIN_WINDOW: Duration = Duration::from_millis(1000);
const PROBE_ARM_CAP: Duration = Duration::from_millis(3000);
let dir = tempfile::tempdir().unwrap();
let key_pub = generate_keypair(&dir.path().join("tty-key"), "devflow-d8-passphrase");
let payload = dir.path().join("payload");
std::fs::write(&payload, b"devflow d8 tty payload\n").unwrap();
let mut baseline_child =
spawn_detached_from_terminal(&mut tty_control_arm(&key_pub, &payload));
let baseline = wait_bounded(&mut baseline_child, SSH_SIGN_PROBE_TIMEOUT / 2);
if baseline.is_none() {
let _ = baseline_child.kill();
let _ = baseline_child.wait();
}
let baseline = baseline.expect(
"control uncalibrated: ssh-keygen blocked with NO controlling terminal, so nothing \
measured below can be attributed to the terminal",
);
let window = std::cmp::max(baseline * CALIBRATION_MULTIPLE, MIN_WINDOW);
assert!(
window >= baseline * 4,
"control uncalibrated: observation window {window:?} is not at least four times the \
measured no-terminal exit of {baseline:?}"
);
assert!(
window < SSH_SIGN_PROBE_TIMEOUT / 2,
"control uncalibrated: observation window {window:?} is not comfortably under the \
probe's own {SSH_SIGN_PROBE_TIMEOUT:?} ceiling, so it would measure the ceiling"
);
let blocked = {
let pty = Pty::open();
let mut child =
spawn_owning_controlling_tty(&mut tty_control_arm(&key_pub, &payload), &pty);
let blocked = wait_bounded(&mut child, window);
let _ = child.kill();
let _ = child.wait();
blocked
};
assert!(
blocked.is_none(),
"PREMISE FAILED: with a controlling terminal and no setsid, ssh-keygen exited in \
{blocked:?} — the same result as the no-terminal control ({baseline:?}). Either the \
pty was never acquired or this build does not consult /dev/tty, and either way the \
arm below would be fast for a reason unrelated to the production setsid. A control \
that agrees with its positive case is a broken measurement, not evidence"
);
let (elapsed, status) = {
let pty = Pty::open();
let mut command = Command::new(
std::env::current_exe().expect("locate this test binary for re-execution"),
);
command
.args([TTY_PROBE_CHILD, "--exact", "--test-threads=1"])
.env(TTY_PROBE_KEY_ENV, &key_pub)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
let mut child = spawn_owning_controlling_tty(&mut command, &pty);
let elapsed = wait_bounded(&mut child, PROBE_ARM_CAP);
if elapsed.is_none() {
let _ = child.kill();
let _ = child.wait();
}
let status = elapsed.map(|_| child.wait().expect("reap the production probe arm"));
(elapsed, status)
};
let code = status.and_then(|status| status.code());
eprintln!(
"D8: no-terminal baseline {baseline:?}; window {window:?} \
({CALIBRATION_MULTIPLE}x, floored at {MIN_WINDOW:?}); with-terminal control \
{blocked:?}; production probe {elapsed:?} exiting {code:?}"
);
let elapsed = elapsed.unwrap_or_else(|| {
panic!(
"REGRESSION: the production signing probe did not return within {PROBE_ARM_CAP:?} \
while its caller held a controlling terminal. That is the pre-setsid behaviour — \
it is parked on /dev/tty waiting for a passphrase nobody can type, and will stay \
there until SSH_SIGN_PROBE_TIMEOUT ({SSH_SIGN_PROBE_TIMEOUT:?}) expires. The \
no-terminal control exited in {baseline:?}, so the fixture and the environment \
are not what changed"
)
});
assert_ne!(
code,
Some(EXIT_NO_CONTROLLING_TTY),
"PREMISE FAILED: the re-executed child could not open /dev/tty, so it never held a \
controlling terminal and its {elapsed:?} says nothing about setsid"
);
assert_ne!(
code,
Some(0),
"the child exited 0, which no path in ssh_sign_probe_tty_child_entrypoint does: \
`--exact {TTY_PROBE_CHILD}` matched no test, so no probe ran at all"
);
assert_eq!(
code,
Some(EXIT_PROBE_REJECTED),
"the probe returned in {elapsed:?} but with the wrong verdict: {} means it hit its \
own ceiling and {} means it never reached one",
EXIT_PROBE_TIMED_OUT,
EXIT_PROBE_OTHER
);
}
#[test]
fn inline_signing_key_returns_unknown_without_probing() {
const INLINE_REASON: &str =
"cannot verify signing viability — an inline user.signingkey is not probed";
let keys = tempfile::tempdir().unwrap();
let pub_key = generate_keypair(&keys.path().join("inline-key"), "");
let blob = std::fs::read_to_string(&pub_key)
.unwrap()
.trim()
.to_string();
for value in [format!("key::{blob}"), blob.clone()] {
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::Unknown {
reason: INLINE_REASON.into(),
},
"inline value {value:?} was not routed to the unprobed Unknown arm: {result:?}"
);
assert_no_leak(&result, keys.path());
}
}
}