diff --git a/src/cli/commands/prune.rs b/src/cli/commands/prune.rs index 773d0e5..4d11834 100644 --- a/src/cli/commands/prune.rs +++ b/src/cli/commands/prune.rs @@ -1,14 +1,15 @@ //! Prune command implementation //! -//! Deletes local branches that have been merged into the default branch. +//! Deletes local branches that have been merged into the manifest target. //! Optionally prunes remote tracking refs. use crate::cli::output::Output; use crate::core::manifest::Manifest; use crate::core::repo::{filter_repos, RepoInfo}; use crate::git::branch::{delete_local_branch, is_branch_merged, list_local_branches}; -use crate::git::{get_current_branch, open_repo, path_exists}; +use crate::git::{get_current_branch, get_default_branch, open_repo, path_exists}; use crate::util::log_cmd; +use std::collections::BTreeSet; use std::path::Path; use std::process::Command; @@ -63,11 +64,37 @@ pub fn run_prune( } }; + // Branches that must never be pruned. Three members, not two: the branch + // checked out, the manifest target, and the remote's DEFAULT branch. The + // default is the only one that asserts "permanent" rather than "currently + // interesting" -- and until the target moved off main it was protected + // only by coincidence, because target and default were the same value. + let mut protected: BTreeSet = BTreeSet::new(); + protected.insert(current_branch.clone()); + protected.insert(repo.target_branch().to_string()); + match get_default_branch(&git_repo, &repo.sync_remote) { + Some(default_branch) => { + protected.insert(default_branch); + } + None => { + // Unknown default: protect MORE, never less. A resolution failure + // that silently shrinks this set reproduces the exact defect the + // set exists to prevent, one level up, inside its own fix -- and + // it would fail with nothing going red. Say so out loud, because + // a protected-more that is silent still reads as "resolved fine". + protected.insert("main".to_string()); + protected.insert("dev".to_string()); + Output::warning(&format!( + "{}: could not determine the default branch (no {}/HEAD); protecting 'main' and 'dev' by name", + repo.name, repo.sync_remote + )); + } + } + let mut merged_branches: Vec = Vec::new(); for branch in &branches { - // Skip current branch and default branch - if branch == ¤t_branch || branch == repo.target_branch() { + if protected.contains(branch) { continue; } diff --git a/src/git/mod.rs b/src/git/mod.rs index cd7ebcc..4c49e84 100644 --- a/src/git/mod.rs +++ b/src/git/mod.rs @@ -198,6 +198,24 @@ pub fn get_current_branch(repo: &Repository) -> Result { } } +/// Resolve the remote's default branch from `refs/remotes//HEAD`. +/// +/// Local only: a cleanup verb must not acquire a network failure mode, so this +/// reads the ref that `clone` writes rather than asking the remote. Returns +/// `None` when the ref is absent, or present but not symbolic — both are normal +/// states for a clone that was never given one, and both mean "unknown" rather +/// than "no default exists". Callers must treat `None` as a reason to protect +/// more, never as a reason to protect less. +pub fn get_default_branch(repo: &Repository, remote: &str) -> Option { + let reference = repo + .find_reference(&format!("refs/remotes/{}/HEAD", remote)) + .ok()?; + let target = reference.symbolic_target()?; + target + .strip_prefix(&format!("refs/remotes/{}/", remote)) + .map(|name| name.to_string()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/tests/test_prune.rs b/tests/test_prune.rs index 53001d0..a84733a 100644 --- a/tests/test_prune.rs +++ b/tests/test_prune.rs @@ -2,9 +2,41 @@ mod common; +use assert_cmd::Command as AssertCommand; +use predicates::prelude::*; + use common::fixtures::WorkspaceBuilder; use common::git_helpers; +/// Put a fixture into the shape production actually runs in: manifest target is +/// `dev`, `dev` is checked out, and `main` exists as the release branch. Before +/// 2026-08-01 target and default were both `main`, which protected `main` by +/// coincidence; every prune test still encodes that retired arrangement. +fn dev_target_workspace(repo: &str) -> common::fixtures::WorkspaceFixture { + let ws = WorkspaceBuilder::new().add_repo(repo).build(); + let manifest_path = ws + .workspace_root + .join(".gitgrip") + .join("spaces") + .join("main") + .join("gripspace.yml"); + let yaml = std::fs::read_to_string(&manifest_path).unwrap(); + assert!( + yaml.contains("default_branch: main"), + "fixture no longer declares a target this helper knows how to move: {yaml}" + ); + std::fs::write( + &manifest_path, + yaml.replace("default_branch: main", "default_branch: dev"), + ) + .unwrap(); + + let repo_path = ws.repo_path(repo); + git_helpers::create_branch(&repo_path, "dev"); + assert!(git_helpers::branch_exists(&repo_path, "main")); + ws +} + #[test] fn test_prune_dry_run_lists_merged_branches() { let ws = WorkspaceBuilder::new().add_repo("alpha").build(); @@ -131,3 +163,94 @@ fn test_prune_no_merged_branches() { // Unmerged branch should still exist assert!(git_helpers::branch_exists(&repo_path, "feat/unmerged")); } + +#[test] +fn test_prune_protects_main_when_target_is_dev() { + // The production shape: target `dev`, standing on `dev`, `main` present. + // `main` is neither current nor target here, which is exactly the state the + // old two-slot guard left unprotected. + let ws = dev_target_workspace("alpha"); + let repo_path = ws.repo_path("alpha"); + + // A genuinely merged branch, so this test also proves prune still WORKS. + // Without it, a fix that protected everything would pass just as happily. + git_helpers::create_branch(&repo_path, "feat/spent"); + git_helpers::commit_file(&repo_path, "spent.txt", "x", "spent work"); + git_helpers::checkout(&repo_path, "dev"); + std::process::Command::new("git") + .args(["merge", "feat/spent", "--no-ff", "-m", "merge spent"]) + .current_dir(&repo_path) + .output() + .unwrap(); + + AssertCommand::cargo_bin("gr") + .unwrap() + .current_dir(&ws.workspace_root) + .args(["prune", "--execute", "--repo", "alpha"]) + .assert() + .success(); + + assert!( + git_helpers::branch_exists(&repo_path, "main"), + "the release branch must survive a prune run from dev" + ); + assert!(git_helpers::branch_exists(&repo_path, "dev")); + assert!( + !git_helpers::branch_exists(&repo_path, "feat/spent"), + "positive control: prune must still delete a merged branch, or this test \ + would pass against a guard that simply protects everything" + ); +} + +#[test] +fn test_prune_protects_more_and_says_so_when_default_is_unresolvable() { + // The default is made GENUINELY unresolvable -- origin/HEAD is deleted from a + // real clone -- rather than stubbed. A stub would assert the code path against + // a fixture instead of against the condition, and the real failure (a clone + // that was never given an origin/HEAD) would go unexercised. + let ws = dev_target_workspace("alpha"); + let repo_path = ws.repo_path("alpha"); + + let before = std::process::Command::new("git") + .args(["symbolic-ref", "refs/remotes/origin/HEAD"]) + .current_dir(&repo_path) + .output() + .unwrap(); + assert!( + before.status.success(), + "control: the clone must HAVE an origin/HEAD before we remove it, or this \ + test proves nothing about removing it" + ); + + std::process::Command::new("git") + .args(["symbolic-ref", "--delete", "refs/remotes/origin/HEAD"]) + .current_dir(&repo_path) + .output() + .unwrap(); + let after = std::process::Command::new("git") + .args(["symbolic-ref", "refs/remotes/origin/HEAD"]) + .current_dir(&repo_path) + .output() + .unwrap(); + assert!( + !after.status.success(), + "the removal must actually make resolution fail" + ); + + AssertCommand::cargo_bin("gr") + .unwrap() + .current_dir(&ws.workspace_root) + .args(["prune", "--execute", "--repo", "alpha"]) + .assert() + .success() + // Protecting more must not be silent. A silent protected-more reads as + // "the default resolved fine" to the next person who runs this. + // NOTE: Output::warning writes to stdout, not stderr -- asserted against + // the stream the binary actually uses, verified by running it. + .stdout(predicate::str::contains( + "could not determine the default branch", + )); + + assert!(git_helpers::branch_exists(&repo_path, "main")); + assert!(git_helpers::branch_exists(&repo_path, "dev")); +}