From ca6f6740282d81cd4dee502465e46d87ed121a2e Mon Sep 17 00:00:00 2001 From: reckerp Date: Mon, 29 Jun 2026 22:03:21 +0200 Subject: [PATCH 1/5] feat(config): add workspace clean settings and protected-branch list Extend WorkspaceConfig with a [workspace.clean] table (threshold_days, auto) and a [workspace] protected_branches list. Add config::store so the protect/unprotect commands can persist the protected-branch list back to the global gx config. All fields default via serde so existing config files keep loading unchanged. --- src/config.rs | 94 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/src/config.rs b/src/config.rs index 4395a72..ffe8a4c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -116,6 +116,17 @@ pub struct WorkspaceConfig { /// skipped silently. #[serde(default = "default_copy_files")] pub copy_files: Vec, + + /// Branches that cleanup must never remove, on top of the always-protected + /// set (default branch, `main`, `master`, the current branch, and any + /// branch checked out in an active worktree). Maps to `[workspace] + /// protected_branches` in the config TOML. + #[serde(default)] + pub protected_branches: Vec, + + /// Cleanup-lifecycle settings (`[workspace.clean]`). + #[serde(default)] + pub clean: WorkspaceCleanConfig, } fn default_workspace_root() -> String { @@ -131,6 +142,34 @@ impl Default for WorkspaceConfig { WorkspaceConfig { root: default_workspace_root(), copy_files: default_copy_files(), + protected_branches: Vec::new(), + clean: WorkspaceCleanConfig::default(), + } + } +} + +/// Cleanup-lifecycle settings, mapped to the `[workspace.clean]` table. +#[derive(Debug, Serialize, Deserialize)] +pub struct WorkspaceCleanConfig { + /// A workspace is "stale" once its conservative age reaches this many days. + /// Only consulted by `gx workspace clean --auto --use-threshold`. + #[serde(default = "default_threshold_days")] + pub threshold_days: u64, + + /// When true, bare `gx workspace clean` behaves as `--auto`. + #[serde(default)] + pub auto: bool, +} + +fn default_threshold_days() -> u64 { + 7 +} + +impl Default for WorkspaceCleanConfig { + fn default() -> Self { + WorkspaceCleanConfig { + threshold_days: default_threshold_days(), + auto: false, } } } @@ -197,6 +236,14 @@ pub fn load() -> miette::Result { Ok(config) } +/// Persist `config` back to the user's global gx config file. Used by +/// `gx workspace protect`/`unprotect` to update the protected-branch list. +pub fn store(config: &Config) -> miette::Result<()> { + confy::store("gx", Some("config"), config) + .map_err(|e| miette::miette!("Failed to save config: {}", e))?; + Ok(()) +} + pub fn load_path() -> miette::Result { let path = confy::get_configuration_file_path("gx", Some("config")) .map_err(|e| miette::miette!("Failed to get config path: {}", e))?; @@ -214,12 +261,59 @@ mod tests { assert_eq!(config.ai.model, "opencode/big-pickle"); assert_eq!(config.workspace.root, "~/gx/workspaces/{repo}"); assert_eq!(config.workspace.copy_files, vec![".env".to_string()]); + assert!(config.workspace.protected_branches.is_empty()); + assert_eq!(config.workspace.clean.threshold_days, 7); + assert!(!config.workspace.clean.auto); assert_eq!(config.aliases.get("gpr").map(String::as_str), Some("pr")); assert_eq!(config.pr.merge_method, "squash"); assert!(config.pr.reviewer_ai_fallback); assert!(config.pr.orgs.is_empty()); } + #[test] + fn test_default_workspace_clean_config() { + let clean = WorkspaceCleanConfig::default(); + assert_eq!(clean.threshold_days, 7); + assert!(!clean.auto); + + let workspace = WorkspaceConfig::default(); + assert!(workspace.protected_branches.is_empty()); + assert_eq!(workspace.clean.threshold_days, 7); + } + + #[test] + fn test_workspace_config_roundtrips_clean_and_protected() { + // Serialize a config with the new fields and read it back, exercising + // both the Serialize and Deserialize derives end-to-end. + let mut config = Config::default(); + config.workspace.protected_branches = + vec!["staging".to_string(), "release".to_string()]; + config.workspace.clean.threshold_days = 10; + config.workspace.clean.auto = true; + + let json = serde_json::to_string(&config).expect("config should serialize"); + let restored: Config = serde_json::from_str(&json).expect("config should deserialize"); + + assert_eq!( + restored.workspace.protected_branches, + vec!["staging".to_string(), "release".to_string()] + ); + assert_eq!(restored.workspace.clean.threshold_days, 10); + assert!(restored.workspace.clean.auto); + } + + #[test] + fn test_workspace_config_without_clean_keeps_defaults() { + // Older config files omit the new tables entirely; serde defaults must + // keep them loading and populate sensible values. + let restored: Config = + serde_json::from_str(r#"{"workspace":{"root":".worktrees"}}"#).expect("parses"); + assert_eq!(restored.workspace.root, ".worktrees"); + assert_eq!(restored.workspace.clean.threshold_days, 7); + assert!(!restored.workspace.clean.auto); + assert!(restored.workspace.protected_branches.is_empty()); + } + #[test] fn test_default_pr_config() { let pr = PrConfig::default(); From aa347d06a85dc56a9b3f78363d35935850b92eb0 Mon Sep 17 00:00:00 2001 From: reckerp Date: Mon, 29 Jun 2026 22:03:29 +0200 Subject: [PATCH 2/5] feat(git): add cleanup scan primitives to worktree helpers Add the git helpers the cleanup lifecycle needs: - list_local_branches and branches_without_worktree for orphan detection - has_upstream/unpushed_count/has_unpushed for safety checks - orphan_branches annotating each branch with upstream/unpushed state - remote_gone_branches (parses for-each-ref upstream:track == [gone]) - workspace_age_days using the smaller of branch-tip age and the .git pointer mtime, so a fresh workspace off an old branch reads as fresh - prune_metadata wrapping git worktree prune from the main root Pure helpers (age_days_from, branches_without_worktree, parse_gone_branches) are extracted and unit-tested. --- src/git/worktree.rs | 287 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 287 insertions(+) diff --git a/src/git/worktree.rs b/src/git/worktree.rs index 95c2622..6f02348 100644 --- a/src/git/worktree.rs +++ b/src/git/worktree.rs @@ -557,6 +557,217 @@ pub fn default_remote_branch() -> Result, GitError> { Ok(None) } +/// Enumerate local branch shorthands (full names, including any '/'). +pub fn list_local_branches() -> Result, GitError> { + let repo = get_repo()?; + + let branches_iter = match repo.branches(Some(git2::BranchType::Local)) { + Ok(iter) => iter, + Err(e) if e.code() == git2::ErrorCode::UnbornBranch => return Ok(vec![]), + Err(e) => return Err(e.into()), + }; + + let names = branches_iter + .filter_map(|res| res.ok()) + .filter_map(|(branch, _)| branch.get().shorthand().map(|s| s.to_string())) + .collect(); + + Ok(names) +} + +/// True when the local branch `branch` has a configured upstream. +pub fn has_upstream(branch: &str) -> Result { + let repo = get_repo()?; + let local = repo.find_branch(branch, git2::BranchType::Local)?; + Ok(local.upstream().is_ok()) +} + +/// Number of commits the local branch `branch` is ahead of its upstream. +/// Returns `Ok(None)` when the branch has no upstream configured (so callers +/// can distinguish "no upstream" from "0 ahead"). Branch-name variant of the +/// path-based [`ahead_behind`]. +pub fn unpushed_count(branch: &str) -> Result, GitError> { + let repo = get_repo()?; + + let local = match repo.find_branch(branch, git2::BranchType::Local) { + Ok(b) => b, + Err(_) => return Ok(None), + }; + + let upstream = match local.upstream() { + Ok(u) => u, + Err(_) => return Ok(None), + }; + + let local_oid = local.get().peel_to_commit()?.id(); + let upstream_oid = upstream.get().peel_to_commit()?.id(); + + let (ahead, _behind) = repo.graph_ahead_behind(local_oid, upstream_oid)?; + Ok(Some(ahead)) +} + +/// True when `branch` has an upstream and is ahead of it. A branch with no +/// upstream is *not* reported as unpushed here; callers that need to treat a +/// missing upstream as unsafe should consult [`has_upstream`] separately. +pub fn has_unpushed(branch: &str) -> Result { + Ok(unpushed_count(branch)?.map(|ahead| ahead > 0).unwrap_or(false)) +} + +/// Committer epoch seconds of the tip commit of `branch`. +pub fn branch_last_commit_secs(branch: &str) -> Result { + let repo = get_repo()?; + let obj = repo.revparse_single(branch)?; + let commit = obj.peel_to_commit()?; + Ok(commit.time().seconds()) +} + +/// Conservative age of a workspace in whole days. The smaller of: +/// - days since the branch's last commit (when the worktree is on a branch), and +/// - days since the worktree's `.git` file (the gitdir pointer) was last modified. +/// +/// Using the `.git` pointer's mtime means a freshly-created workspace from an +/// old branch reads as fresh, so cleanup never deletes it just for pointing at +/// stale commits. +pub fn workspace_age_days(worktree: &Worktree, now_secs: i64) -> Result { + let branch_secs = match worktree.branch.as_deref() { + Some(branch) => Some(branch_last_commit_secs(branch)?), + None => None, + }; + + let git_file = worktree.path.join(".git"); + let git_file_secs = std::fs::metadata(&git_file) + .and_then(|m| m.modified()) + .map(system_time_to_secs) + // A missing/unreadable .git pointer should not make a workspace look + // ancient (which could lead to deletion); fall back to "now". + .unwrap_or(now_secs); + + Ok(age_days_from(branch_secs, git_file_secs, now_secs)) +} + +fn system_time_to_secs(time: std::time::SystemTime) -> i64 { + time.duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +/// Pure age computation in whole days, used by [`workspace_age_days`]. Returns +/// the smaller of the branch age and the gitdir-pointer age; a future timestamp +/// (clock skew) clamps to 0. +fn age_days_from(branch_secs: Option, git_file_secs: i64, now_secs: i64) -> u64 { + const DAY: i64 = 86_400; + + let to_days = |secs: i64| -> u64 { + let diff = (now_secs - secs).max(0); + (diff / DAY) as u64 + }; + + let git_file_days = to_days(git_file_secs); + match branch_secs { + Some(branch_secs) => git_file_days.min(to_days(branch_secs)), + None => git_file_days, + } +} + +/// A local branch that is not checked out in any worktree, annotated with the +/// upstream/unpushed state cleanup needs to decide whether it is safe to delete. +#[derive(Debug, Clone)] +pub struct OrphanBranch { + pub name: String, + pub has_unpushed: bool, + pub has_upstream: bool, +} + +/// Local branches with no associated worktree, each annotated with its +/// unpushed/upstream state. +pub fn orphan_branches(worktrees: &[Worktree]) -> Result, GitError> { + let all_local = list_local_branches()?; + let candidates = branches_without_worktree(&all_local, worktrees); + + candidates + .into_iter() + .map(|name| { + let has_upstream = has_upstream(&name)?; + let has_unpushed = has_unpushed(&name)?; + Ok(OrphanBranch { + name, + has_unpushed, + has_upstream, + }) + }) + .collect() +} + +/// Local branches that are not checked out in any worktree. Pure set difference, +/// extracted for unit testing. +fn branches_without_worktree(all_local: &[String], worktrees: &[Worktree]) -> Vec { + let checked_out: std::collections::HashSet<&str> = worktrees + .iter() + .filter_map(|w| w.branch.as_deref()) + .collect(); + + all_local + .iter() + .filter(|b| !checked_out.contains(b.as_str())) + .cloned() + .collect() +} + +/// Local branches whose configured upstream no longer resolves (`[gone]`), for +/// the interactive cleaner's "remote tracking branch is gone" section. +pub fn remote_gone_branches() -> Result, GitError> { + let output = git_exec::exec( + vec![ + "for-each-ref".to_string(), + "--format=%(refname:short) %(upstream:track)".to_string(), + "refs/heads".to_string(), + ], + ExecOptions { + capture: true, + ..Default::default() + }, + )?; + + Ok(parse_gone_branches(&output)) +} + +/// Parse `git for-each-ref --format='%(refname:short) %(upstream:track)'` +/// output, selecting branches whose tracking state is `[gone]`. +fn parse_gone_branches(output: &str) -> Vec { + output + .lines() + .filter_map(|line| { + let line = line.trim_end(); + // The track field is the last whitespace-separated token, but the + // branch name comes first and never contains spaces, so split once. + let (name, track) = line.split_once(' ')?; + if track.trim() == "[gone]" { + Some(name.to_string()) + } else { + None + } + }) + .collect() +} + +/// Run `git worktree prune` from `from` (the main worktree) to drop metadata for +/// worktrees whose directories no longer exist. +pub fn prune_metadata(from: &Path) -> Result<(), GitError> { + git_exec::exec( + vec![ + "-C".to_string(), + from.display().to_string(), + "worktree".to_string(), + "prune".to_string(), + ], + ExecOptions { + silent: true, + ..Default::default() + }, + )?; + Ok(()) +} + fn parse_porcelain(output: &str, current_root: Option<&Path>) -> Vec { let current_canonical = current_root.and_then(|p| p.canonicalize().ok()); let mut worktrees = Vec::new(); @@ -819,6 +1030,82 @@ mod tests { assert!(!detached.matches_exactly("fix/null-check")); } + #[test] + fn test_age_days_from_uses_smaller_of_branch_and_gitfile() { + const DAY: i64 = 86_400; + let now = 100 * DAY; + + // branch tip 30 days ago, gitdir pointer 2 days ago -> 2 (the smaller). + let branch_secs = now - 30 * DAY; + let git_file_secs = now - 2 * DAY; + assert_eq!(age_days_from(Some(branch_secs), git_file_secs, now), 2); + + // ...and the other way round: branch newer than the gitdir pointer. + assert_eq!(age_days_from(Some(now - 2 * DAY), now - 30 * DAY, now), 2); + } + + #[test] + fn test_age_days_from_fresh_workspace_from_old_branch_is_fresh() { + // The spec's headline guarantee: a workspace created today from a + // branch whose tip is 200 days old reads as fresh (0 days). + const DAY: i64 = 86_400; + let now = 365 * DAY; + let branch_secs = now - 200 * DAY; + let git_file_secs = now; // .git pointer created just now + + assert_eq!(age_days_from(Some(branch_secs), git_file_secs, now), 0); + } + + #[test] + fn test_age_days_from_detached_uses_gitfile_only() { + const DAY: i64 = 86_400; + let now = 100 * DAY; + assert_eq!(age_days_from(None, now - 5 * DAY, now), 5); + } + + #[test] + fn test_age_days_from_future_timestamp_clamps_to_zero() { + const DAY: i64 = 86_400; + let now = 100 * DAY; + // Clock skew puts the branch/gitfile in the future; age never goes negative. + assert_eq!(age_days_from(Some(now + 10 * DAY), now + 3 * DAY, now), 0); + } + + #[test] + fn test_branches_without_worktree() { + let worktrees = vec![ + worktree("repo", Some("main")), + worktree("feat", Some("feat/x")), + worktree("detached", None), + ]; + let all_local = vec![ + "main".to_string(), + "feat/x".to_string(), + "old-fix".to_string(), + "experiment".to_string(), + ]; + + let mut orphans = branches_without_worktree(&all_local, &worktrees); + orphans.sort(); + + assert_eq!(orphans, vec!["experiment".to_string(), "old-fix".to_string()]); + } + + #[test] + fn test_parse_gone_branches() { + let output = "main [ahead 1]\nfeat/x [gone]\nrelease \nold-thing [gone]\nahead-only [behind 2]"; + let gone = parse_gone_branches(output); + assert_eq!(gone, vec!["feat/x".to_string(), "old-thing".to_string()]); + } + + #[test] + fn test_parse_gone_branches_empty() { + assert!(parse_gone_branches("").is_empty()); + // A branch with no upstream has an empty track field, not "[gone]". + assert!(parse_gone_branches("solo ").is_empty()); + assert!(parse_gone_branches("solo").is_empty()); + } + #[test] fn test_match_score_slash_dash_interchangeable() { let matcher = SkimMatcherV2::default(); From 586e7f474142603a2efeca3650111f1db1ee7cc9 Mon Sep 17 00:00:00 2001 From: reckerp Date: Mon, 29 Jun 2026 22:03:35 +0200 Subject: [PATCH 3/5] feat(args): add workspace clean, prune, protect, unprotect subcommands Wire the cleanup-lifecycle CLI surface under 'gx workspace': - clean [--auto --dry-run --use-threshold --force] - prune [--dry-run --no-branches] - protect [branch] - unprotect [branch] Each subcommand dispatches to the new workspace_clean handler. --- src/args.rs | 62 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/src/args.rs b/src/args.rs index f281385..382fb8c 100644 --- a/src/args.rs +++ b/src/args.rs @@ -229,6 +229,52 @@ pub enum WorkspaceCommands { /// Copy setup files (e.g. .env) from the main worktree into this workspace Setup, + + /// Clean up workspaces: interactive multi-section picker, or --auto to + /// remove every workspace that passes the safety checks + Clean { + /// Remove all safe workspaces without per-workspace prompts (still asks + /// for one final confirmation). Also enabled by `[workspace.clean] auto`. + #[arg(long)] + auto: bool, + + /// Show what would be removed without deleting anything + #[arg(long)] + dry_run: bool, + + /// In --auto mode, only consider workspaces older than the configured + /// threshold ([workspace.clean] threshold_days, default 7) + #[arg(long)] + use_threshold: bool, + + /// Bypass the dirty/untracked/unpushed safety checks. Still protects the + /// main and current worktrees, locked worktrees, and protected branches. + #[arg(long)] + force: bool, + }, + + /// Prune stale worktree metadata and delete safe orphan branches + Prune { + /// Show what would be deleted without deleting anything + #[arg(long)] + dry_run: bool, + + /// Only prune worktree metadata; do not touch branches + #[arg(long)] + no_branches: bool, + }, + + /// Protect a branch from cleanup (defaults to the current branch) + Protect { + /// Branch to protect (defaults to the current branch) + branch: Option, + }, + + /// Remove a branch from the protected list (defaults to the current branch) + Unprotect { + /// Branch to unprotect (defaults to the current branch) + branch: Option, + }, } impl Commands { @@ -287,6 +333,22 @@ impl Commands { delete_branch, }) => commands::workspace::run_remove(query, force, delete_branch), Some(WorkspaceCommands::Setup) => commands::workspace::run_setup(), + Some(WorkspaceCommands::Clean { + auto, + dry_run, + use_threshold, + force, + }) => commands::workspace_clean::run_clean(auto, dry_run, use_threshold, force), + Some(WorkspaceCommands::Prune { + dry_run, + no_branches, + }) => commands::workspace_clean::run_prune(dry_run, no_branches), + Some(WorkspaceCommands::Protect { branch }) => { + commands::workspace_clean::run_protect(branch) + } + Some(WorkspaceCommands::Unprotect { branch }) => { + commands::workspace_clean::run_unprotect(branch) + } }, Commands::Pr { action } => match action { None => commands::pr::run_interactive(), From 61c69df0b77abec3dbb64fd206debc021b3f7699 Mon Sep 17 00:00:00 2001 From: reckerp Date: Mon, 29 Jun 2026 22:03:42 +0200 Subject: [PATCH 4/5] feat(ui): add multi-section workspace clean picker Add the interactive cleaner TUI with three sections: workspaces, local branches without workspaces, and orphan branches whose upstream is gone. Protected, main, current, and locked rows are shown but disabled with a tag explaining why, so a skipped row never looks like a scanner miss. Reuses the background summary lookup and stderr terminal setup. Row building, sectioning, safety tagging, and selection-to-action resolution are pure and unit-tested. --- src/ui/clean_picker.rs | 801 +++++++++++++++++++++++++++++++++++++++++ src/ui/mod.rs | 1 + 2 files changed, 802 insertions(+) create mode 100644 src/ui/clean_picker.rs diff --git a/src/ui/clean_picker.rs b/src/ui/clean_picker.rs new file mode 100644 index 0000000..7148801 --- /dev/null +++ b/src/ui/clean_picker.rs @@ -0,0 +1,801 @@ +use super::{TermStderr, adjust_scroll, render_help_bar}; +use crate::git::time::format_relative; +use crate::git::worktree::{ + SummaryLookup, Worktree, WorktreeSummary, apply_local_summaries, pending_summaries, +}; +use crossterm::event::{self, Event, KeyCode, KeyModifiers}; +use ratatui::prelude::*; +use ratatui::widgets::*; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use std::time::Duration; + +/// Why a row cannot be selected for removal. Surfaced in the UI so the user +/// understands the cleaner did not simply miss the row. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DisabledReason { + Main, + Current, + Locked, + Protected, +} + +impl DisabledReason { + fn tag(&self) -> &'static str { + match self { + DisabledReason::Main => "(main)", + DisabledReason::Current => "(current)", + DisabledReason::Locked => "(locked)", + DisabledReason::Protected => "(protected)", + } + } +} + +/// A single line in the multi-section cleaner. Section headers are +/// non-selectable; the remaining variants each carry their selectable state. +#[derive(Debug, Clone)] +pub enum CleanRow { + Header(&'static str), + /// A workspace (worktree). + Workspace { + worktree: Worktree, + disabled: Option, + /// Precomputed age in whole days, or `None` when it could not be + /// resolved. Computed once when rows are built so the render loop does + /// not re-run repository discovery + a revparse per row per frame. + age_days: Option, + }, + /// A local branch with no worktree. + OrphanBranch { + name: String, + has_unpushed: bool, + has_upstream: bool, + disabled: Option, + }, + /// A local branch whose upstream tracking branch is gone. + GoneBranch { + name: String, + disabled: Option, + }, +} + +impl CleanRow { + fn is_header(&self) -> bool { + matches!(self, CleanRow::Header(_)) + } + + fn disabled_reason(&self) -> Option<&DisabledReason> { + match self { + CleanRow::Header(_) => None, + CleanRow::Workspace { disabled, .. } + | CleanRow::OrphanBranch { disabled, .. } + | CleanRow::GoneBranch { disabled, .. } => disabled.as_ref(), + } + } + + /// A stable key for selection tracking. Workspaces key on their path, + /// branches on a `branch:` prefix so a branch and a same-named directory + /// never collide. + fn selection_key(&self) -> Option { + match self { + CleanRow::Header(_) => None, + CleanRow::Workspace { worktree, .. } => { + Some(format!("ws:{}", worktree.path.display())) + } + CleanRow::OrphanBranch { name, .. } | CleanRow::GoneBranch { name, .. } => { + Some(format!("branch:{}", name)) + } + } + } + + fn is_selectable(&self) -> bool { + !self.is_header() && self.disabled_reason().is_none() + } +} + +/// Inputs the picker needs that depend on protection/safety decisions made by +/// the caller (which owns the config). Kept as plain data so [`build_rows`] is a +/// pure, unit-testable function. +pub struct CleanInputs { + pub worktrees: Vec, + /// Local branches with no worktree (name, has_unpushed, has_upstream). + pub orphan_branches: Vec<(String, bool, bool)>, + /// Branches whose upstream tracking branch is gone. + pub gone_branches: Vec, + /// Branch names that are protected (never removable). + pub protected: HashSet, + /// Precomputed workspace age in whole days, keyed by worktree path. Computed + /// once by the caller so the render loop never recomputes it per frame; a + /// missing entry renders as "age?". + pub ages: HashMap, +} + +/// The user's confirmed cleanup choices, consumed by the command handler. +#[derive(Debug, Clone, Default)] +pub struct CleanAction { + pub remove_worktrees: Vec, + pub delete_branches: Vec, + pub confirmed: bool, +} + +/// Build the ordered, sectioned row list from already-resolved inputs. Pure so +/// the sectioning and safety-tagging is unit-testable; the TUI loop is not. +pub fn build_rows(inputs: &CleanInputs) -> Vec { + let mut rows = Vec::new(); + + rows.push(CleanRow::Header("Workspaces")); + for worktree in &inputs.worktrees { + let disabled = workspace_disabled_reason(worktree, &inputs.protected); + rows.push(CleanRow::Workspace { + worktree: worktree.clone(), + disabled, + age_days: inputs.ages.get(&worktree.path).copied(), + }); + } + + rows.push(CleanRow::Header("Local branches without workspaces")); + for (name, has_unpushed, has_upstream) in &inputs.orphan_branches { + let disabled = inputs + .protected + .contains(name) + .then_some(DisabledReason::Protected); + rows.push(CleanRow::OrphanBranch { + name: name.clone(), + has_unpushed: *has_unpushed, + has_upstream: *has_upstream, + disabled, + }); + } + + rows.push(CleanRow::Header( + "Orphan branches whose remote tracking branch is gone", + )); + for name in &inputs.gone_branches { + let disabled = inputs + .protected + .contains(name) + .then_some(DisabledReason::Protected); + rows.push(CleanRow::GoneBranch { + name: name.clone(), + disabled, + }); + } + + rows +} + +/// Why a workspace row cannot be removed. Order matters only for the displayed +/// tag; all of these block removal. +fn workspace_disabled_reason( + worktree: &Worktree, + protected: &HashSet, +) -> Option { + if worktree.is_main { + Some(DisabledReason::Main) + } else if worktree.is_current { + Some(DisabledReason::Current) + } else if worktree.is_locked { + Some(DisabledReason::Locked) + } else if worktree + .branch + .as_deref() + .is_some_and(|b| protected.contains(b)) + { + Some(DisabledReason::Protected) + } else { + None + } +} + +/// Selection keys for every selectable row, in display order. Used by select-all. +fn selectable_keys(rows: &[CleanRow]) -> Vec { + rows.iter() + .filter(|r| r.is_selectable()) + .filter_map(|r| r.selection_key()) + .collect() +} + +/// Resolve the user's selection into the concrete cleanup action. +fn build_action(rows: &[CleanRow], selected_keys: &HashSet) -> CleanAction { + let mut action = CleanAction::default(); + // Two rows can share a branch name (a `[gone]` branch with no worktree fits + // both branch sections); dedupe so a branch is never deleted twice. + let mut seen_branches: HashSet = HashSet::new(); + + for row in rows { + let Some(key) = row.selection_key() else { + continue; + }; + if !selected_keys.contains(&key) { + continue; + } + match row { + CleanRow::Workspace { worktree, .. } => { + action.remove_worktrees.push(worktree.clone()); + } + CleanRow::OrphanBranch { name, .. } | CleanRow::GoneBranch { name, .. } => { + if seen_branches.insert(name.clone()) { + action.delete_branches.push(name.clone()); + } + } + CleanRow::Header(_) => {} + } + } + + action +} + +fn age_label(age_days: Option) -> String { + match age_days { + Some(days) => format_relative(days as i64 * 86_400), + None => "age?".to_string(), + } +} + +fn render_row<'a>( + row: &'a CleanRow, + is_highlighted: bool, + is_selected: bool, + summaries: &HashMap, +) -> ListItem<'a> { + if let CleanRow::Header(title) = row { + return ListItem::new(Line::from(Span::styled( + format!("── {} ──", title), + Style::default().fg(Color::Cyan).bold(), + ))); + } + + let disabled = row.disabled_reason(); + let checkbox = if disabled.is_some() { + " - " + } else if is_selected { + "[x]" + } else { + "[ ]" + }; + + let name_style = if disabled.is_some() { + Style::default().fg(Color::DarkGray) + } else if is_highlighted { + Style::default().fg(Color::Yellow).bold() + } else { + Style::default().fg(Color::White) + }; + + let mut spans = vec![ + Span::styled( + format!(" {} ", checkbox), + if is_selected { + Style::default().fg(Color::Green) + } else { + Style::default().fg(Color::DarkGray) + }, + ), + ]; + + match row { + CleanRow::Workspace { + worktree, age_days, .. + } => { + spans.push(Span::styled(worktree.name.clone(), name_style)); + if let Some(branch) = &worktree.branch { + spans.push(Span::raw(" ")); + spans.push(Span::styled( + format!("[{}]", branch), + Style::default().fg(Color::Cyan), + )); + } + spans.push(Span::styled( + format!(" {}", age_label(*age_days)), + Style::default().fg(Color::DarkGray), + )); + if let Some(summary) = summaries.get(&worktree.path) { + spans.extend(badges(summary)); + } + if worktree.is_main { + spans.push(Span::styled(" (main)", Style::default().fg(Color::Green))); + } + if worktree.is_current { + spans.push(Span::styled( + " (current)", + Style::default().fg(Color::Magenta), + )); + } + if worktree.is_locked { + spans.push(Span::styled(" (locked)", Style::default().fg(Color::Red))); + } + } + CleanRow::OrphanBranch { + name, + has_unpushed, + has_upstream, + .. + } => { + spans.push(Span::styled(name.clone(), name_style)); + if !has_upstream { + spans.push(Span::styled( + " (no upstream)", + Style::default().fg(Color::Yellow), + )); + } else if *has_unpushed { + spans.push(Span::styled(" (unpushed)", Style::default().fg(Color::Red))); + } + } + CleanRow::GoneBranch { name, .. } => { + spans.push(Span::styled(name.clone(), name_style)); + spans.push(Span::styled( + " (upstream gone)", + Style::default().fg(Color::Yellow), + )); + } + CleanRow::Header(_) => {} + } + + if let Some(reason) = disabled { + spans.push(Span::styled( + format!(" {}", reason.tag()), + Style::default().fg(Color::DarkGray), + )); + } + + let line = Line::from(spans); + if is_highlighted { + ListItem::new(line).style(Style::default().bg(Color::DarkGray)) + } else { + ListItem::new(line) + } +} + +fn badges(summary: &WorktreeSummary) -> Vec> { + let mut out = Vec::new(); + if summary.tracked_changes > 0 { + out.push(Span::styled( + format!(" dirty:{}", summary.tracked_changes), + Style::default().fg(Color::Yellow), + )); + } + if summary.untracked_changes > 0 { + out.push(Span::styled( + format!(" untracked:{}", summary.untracked_changes), + Style::default().fg(Color::Red), + )); + } + if let Some(ahead) = summary.ahead + && ahead > 0 + { + out.push(Span::styled( + format!(" +{}", ahead), + Style::default().fg(Color::Green), + )); + } + if summary.status_error { + out.push(Span::styled( + " status?".to_string(), + Style::default().fg(Color::DarkGray), + )); + } else if !summary.status_loaded { + out.push(Span::styled( + " status...".to_string(), + Style::default().fg(Color::DarkGray), + )); + } + out +} + +/// Move the highlight to the next selectable row in `direction` (+1/-1), +/// skipping headers and disabled rows. Returns the new index (unchanged when no +/// other selectable row exists). +fn next_selectable(rows: &[CleanRow], current: usize, direction: isize) -> usize { + let len = rows.len() as isize; + let mut idx = current as isize; + loop { + idx += direction; + if idx < 0 || idx >= len { + return current; + } + if rows[idx as usize].is_selectable() { + return idx as usize; + } + } +} + +fn first_selectable(rows: &[CleanRow]) -> usize { + rows.iter().position(CleanRow::is_selectable).unwrap_or(0) +} + +#[derive(Clone)] +enum Mode { + List, + Confirm, +} + +/// Run the interactive multi-section cleaner. Returns `None` when the user +/// cancels, or a confirmed [`CleanAction`] otherwise (an empty action means the +/// user confirmed with nothing selected, which the caller treats as a no-op). +pub fn run( + terminal: &mut TermStderr, + inputs: CleanInputs, + summary_lookup: SummaryLookup, +) -> miette::Result> { + use miette::IntoDiagnostic; + + let rows = build_rows(&inputs); + let mut summaries = pending_summaries(&inputs.worktrees); + + let mut highlighted = first_selectable(&rows); + let mut scroll_offset = 0; + let mut selected_keys: HashSet = HashSet::new(); + let mut mode = Mode::List; + + let nothing_selectable = !rows.iter().any(CleanRow::is_selectable); + + loop { + if let Ok(lookup) = summary_lookup.try_recv() { + apply_local_summaries(&mut summaries, lookup); + } + + terminal + .draw(|f| { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(0), Constraint::Length(3)]) + .split(f.area()); + + match mode { + Mode::List => { + let visible_height = chunks[0].height.saturating_sub(2) as usize; + scroll_offset = + adjust_scroll(highlighted, scroll_offset, visible_height); + + let items: Vec = rows + .iter() + .enumerate() + .skip(scroll_offset) + .take(visible_height) + .map(|(i, row)| { + let is_selected = row + .selection_key() + .is_some_and(|k| selected_keys.contains(&k)); + render_row(row, i == highlighted, is_selected, &summaries) + }) + .collect(); + + let title = format!(" Clean ({} selected) ", selected_keys.len()); + f.render_widget( + List::new(items) + .block(Block::default().borders(Borders::ALL).title(title)), + chunks[0], + ); + f.render_widget( + render_help_bar(&[ + ("^/k", "Up"), + ("v/j", "Down"), + ("Space", "Select"), + ("^a", "All"), + ("^u", "Clear"), + ("Enter", "Clean"), + ("Esc", "Cancel"), + ]), + chunks[1], + ); + } + Mode::Confirm => { + let action = build_action(&rows, &selected_keys); + f.render_widget(render_confirm(&action), chunks[0]); + f.render_widget( + render_help_bar(&[("Enter/y", "Confirm"), ("Esc/n", "Back")]), + chunks[1], + ); + } + } + }) + .into_diagnostic()?; + + if !event::poll(Duration::from_millis(50)).into_diagnostic()? { + continue; + } + let Event::Key(key) = event::read().into_diagnostic()? else { + continue; + }; + + match mode { + Mode::List => match (key.code, key.modifiers) { + (KeyCode::Esc, _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => { + return Ok(None); + } + (KeyCode::Up, _) | (KeyCode::Char('k'), KeyModifiers::NONE) => { + highlighted = next_selectable(&rows, highlighted, -1); + } + (KeyCode::Down, _) | (KeyCode::Char('j'), KeyModifiers::NONE) => { + highlighted = next_selectable(&rows, highlighted, 1); + } + (KeyCode::Char(' '), _) => { + if let Some(row) = rows.get(highlighted) + && row.is_selectable() + && let Some(key) = row.selection_key() + && !selected_keys.insert(key.clone()) + { + selected_keys.remove(&key); + } + } + (KeyCode::Char('a'), KeyModifiers::CONTROL) => { + let keys = selectable_keys(&rows); + let all_selected = keys.iter().all(|k| selected_keys.contains(k)); + if all_selected { + for k in &keys { + selected_keys.remove(k); + } + } else { + selected_keys.extend(keys); + } + } + (KeyCode::Char('u'), KeyModifiers::CONTROL) => { + selected_keys.clear(); + } + (KeyCode::Enter, _) => { + if selected_keys.is_empty() { + return Ok(None); + } + mode = Mode::Confirm; + } + _ => {} + }, + Mode::Confirm => match (key.code, key.modifiers) { + (KeyCode::Esc, _) + | (KeyCode::Char('n'), _) + | (KeyCode::Char('c'), KeyModifiers::CONTROL) => { + mode = Mode::List; + } + (KeyCode::Enter, _) | (KeyCode::Char('y'), _) => { + let mut action = build_action(&rows, &selected_keys); + action.confirmed = true; + return Ok(Some(action)); + } + _ => {} + }, + } + + // Defensive: if a render shrank the list to nothing selectable, keep the + // highlight in-bounds. + if nothing_selectable { + highlighted = highlighted.min(rows.len().saturating_sub(1)); + } + } +} + +fn render_confirm<'a>(action: &CleanAction) -> Paragraph<'a> { + let mut lines = vec![Line::from(Span::styled( + format!( + "Remove {} workspace(s) and delete {} branch(es)?", + action.remove_worktrees.len(), + action.delete_branches.len() + ), + Style::default().fg(Color::Red).bold(), + ))]; + + if action.remove_worktrees.iter().any(|w| w.is_current) { + lines.push(Line::from( + "The current workspace is selected; gx will switch you to the main workspace.", + )); + } + lines.push(Line::from("")); + + for worktree in action.remove_worktrees.iter().take(10) { + lines.push(Line::from(format!( + " workspace: {} ({})", + worktree.name, + worktree.path.display() + ))); + } + for branch in action.delete_branches.iter().take(10) { + lines.push(Line::from(format!(" branch: {}", branch))); + } + + lines.push(Line::from("")); + lines.push(Line::from("Press enter/y to confirm, esc/n to go back.")); + + Paragraph::new(lines) + .block( + Block::default() + .borders(Borders::ALL) + .title(" Confirm Cleanup "), + ) + .wrap(Wrap { trim: false }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn worktree(name: &str, branch: Option<&str>) -> Worktree { + Worktree { + name: name.to_string(), + path: PathBuf::from(format!("/ws/{}", name)), + branch: branch.map(|b| b.to_string()), + head: None, + is_main: false, + is_current: false, + is_bare: false, + is_locked: false, + } + } + + fn inputs() -> CleanInputs { + let mut main = worktree("repo", Some("main")); + main.is_main = true; + let mut current = worktree("current-ws", Some("feature")); + current.is_current = true; + let mut locked = worktree("locked-ws", Some("locked-branch")); + locked.is_locked = true; + let removable = worktree("old-ws", Some("old-feature")); + let protected_ws = worktree("staging-ws", Some("staging")); + + CleanInputs { + worktrees: vec![main, current, locked, removable, protected_ws], + orphan_branches: vec![ + ("orphan".to_string(), false, true), + ("staging".to_string(), false, true), + ], + gone_branches: vec!["gone-branch".to_string()], + protected: HashSet::from(["staging".to_string()]), + ages: HashMap::new(), + } + } + + #[test] + fn test_build_rows_sections_and_headers() { + let rows = build_rows(&inputs()); + let headers: Vec<&str> = rows + .iter() + .filter_map(|r| match r { + CleanRow::Header(h) => Some(*h), + _ => None, + }) + .collect(); + assert_eq!( + headers, + vec![ + "Workspaces", + "Local branches without workspaces", + "Orphan branches whose remote tracking branch is gone", + ] + ); + } + + #[test] + fn test_build_rows_disables_main_current_locked_protected() { + let rows = build_rows(&inputs()); + + let reason_for = |name: &str| -> Option { + rows.iter().find_map(|r| match r { + CleanRow::Workspace { + worktree, disabled, .. + } if worktree.name == name => disabled.clone(), + _ => None, + }) + }; + + assert_eq!(reason_for("repo"), Some(DisabledReason::Main)); + assert_eq!(reason_for("current-ws"), Some(DisabledReason::Current)); + assert_eq!(reason_for("locked-ws"), Some(DisabledReason::Locked)); + assert_eq!(reason_for("staging-ws"), Some(DisabledReason::Protected)); + assert_eq!(reason_for("old-ws"), None); + } + + #[test] + fn test_build_rows_disables_protected_orphan_branch() { + let rows = build_rows(&inputs()); + let protected_orphan = rows.iter().find_map(|r| match r { + CleanRow::OrphanBranch { name, disabled, .. } if name == "staging" => { + Some(disabled.clone()) + } + _ => None, + }); + assert_eq!(protected_orphan, Some(Some(DisabledReason::Protected))); + } + + #[test] + fn test_selectable_keys_excludes_headers_and_disabled() { + let rows = build_rows(&inputs()); + let keys = selectable_keys(&rows); + + // Only the removable workspace, the non-protected orphan, and the gone + // branch are selectable. + assert_eq!(keys.len(), 3); + assert!(keys.iter().any(|k| k == "ws:/ws/old-ws")); + assert!(keys.iter().any(|k| k == "branch:orphan")); + assert!(keys.iter().any(|k| k == "branch:gone-branch")); + assert!(!keys.iter().any(|k| k == "branch:staging")); + } + + #[test] + fn test_build_action_splits_workspaces_and_branches() { + let rows = build_rows(&inputs()); + let selected = HashSet::from([ + "ws:/ws/old-ws".to_string(), + "branch:orphan".to_string(), + "branch:gone-branch".to_string(), + ]); + + let action = build_action(&rows, &selected); + assert_eq!(action.remove_worktrees.len(), 1); + assert_eq!(action.remove_worktrees[0].name, "old-ws"); + let mut branches = action.delete_branches.clone(); + branches.sort(); + assert_eq!(branches, vec!["gone-branch".to_string(), "orphan".to_string()]); + } + + #[test] + fn test_build_action_dedupes_branch_listed_in_two_sections() { + // A branch that appears as both an OrphanBranch and a GoneBranch shares + // the "branch:" key; build_action must not emit it twice (deleting + // it twice would fail the second `git branch -d`). + let rows = vec![ + CleanRow::OrphanBranch { + name: "dup".to_string(), + has_unpushed: false, + has_upstream: false, + disabled: None, + }, + CleanRow::GoneBranch { + name: "dup".to_string(), + disabled: None, + }, + ]; + let selected = HashSet::from(["branch:dup".to_string()]); + + let action = build_action(&rows, &selected); + assert_eq!(action.delete_branches, vec!["dup".to_string()]); + } + + #[test] + fn test_build_rows_uses_precomputed_age() { + let mut inputs = inputs(); + let removable_path = PathBuf::from("/ws/old-ws"); + inputs.ages = HashMap::from([(removable_path.clone(), 42)]); + + let rows = build_rows(&inputs); + let age = rows.iter().find_map(|r| match r { + CleanRow::Workspace { + worktree, + age_days, + .. + } if worktree.path == removable_path => Some(*age_days), + _ => None, + }); + assert_eq!(age, Some(Some(42))); + + // A workspace with no precomputed age falls back to None ("age?"). + let missing = rows.iter().find_map(|r| match r { + CleanRow::Workspace { + worktree, + age_days, + .. + } if worktree.name == "current-ws" => Some(*age_days), + _ => None, + }); + assert_eq!(missing, Some(None)); + } + + #[test] + fn test_next_selectable_skips_headers_and_disabled() { + let rows = build_rows(&inputs()); + let first = first_selectable(&rows); + // The first selectable row is the removable workspace, not a header or + // the disabled main/current/locked rows that precede it. + match &rows[first] { + CleanRow::Workspace { worktree, .. } => assert_eq!(worktree.name, "old-ws"), + other => panic!("expected workspace row, got {:?}", other), + } + + // Moving up from the first selectable row stays put (nothing above it). + assert_eq!(next_selectable(&rows, first, -1), first); + + // Moving down lands on the next selectable row, skipping the + // intervening section header. + let next = next_selectable(&rows, first, 1); + assert!(next > first); + assert!(rows[next].is_selectable()); + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 6837e15..af289e4 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,4 +1,5 @@ pub mod branch_picker; +pub mod clean_picker; pub mod confirm; pub mod file_picker; pub mod log_viewer; From d95a8eb152c376a72f387c77c86a37f7b4c3169d Mon Sep 17 00:00:00 2001 From: reckerp Date: Mon, 29 Jun 2026 22:03:52 +0200 Subject: [PATCH 5/5] feat(workspace): implement clean, prune, protect, unprotect handlers Add the workspace_clean command module: - clean: interactive multi-section picker, or --auto to remove every workspace passing the safety checks (final confirmation still required). --use-threshold limits --auto to workspaces older than threshold_days; --force bypasses dirty/untracked/unpushed but never the main, current, locked, or protected guards. - prune: git worktree prune, then delete safe orphan branches after confirmation; --no-branches stops after metadata cleanup; --dry-run reports without deleting. - protect/unprotect: manage the configured list, defaulting to the current branch and erroring on detached HEAD; advise when a branch stays implicitly protected. Reuse ui::confirm and keep all human output on stderr; expose main_worktree_root, delete_local_branch, and print_go_path from the existing workspace module. delete_local_branch now treats an already-deleted branch as success during multi-step cleanup. Safety and prune-eligibility logic is pure and unit-tested. --- src/commands/mod.rs | 1 + src/commands/workspace.rs | 10 +- src/commands/workspace_clean.rs | 803 ++++++++++++++++++++++++++++++++ 3 files changed, 812 insertions(+), 2 deletions(-) create mode 100644 src/commands/workspace_clean.rs diff --git a/src/commands/mod.rs b/src/commands/mod.rs index c807491..4cf2258 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -10,3 +10,4 @@ pub mod setup; pub mod stash; pub mod status; pub mod workspace; +pub mod workspace_clean; diff --git a/src/commands/workspace.rs b/src/commands/workspace.rs index 1a8c052..08efc19 100644 --- a/src/commands/workspace.rs +++ b/src/commands/workspace.rs @@ -835,9 +835,15 @@ fn confirm_force_remove(worktree: &Worktree) -> Result { Ok(confirmed) } -fn delete_local_branch(main_root: &Path, branch: &str) -> Result<()> { +pub(crate) fn delete_local_branch(main_root: &Path, branch: &str) -> Result<()> { match git::worktree::delete_branch(main_root, branch, false) { Ok(()) => Ok(()), + // The branch is already gone (e.g. removed earlier in the same cleanup + // run): treat that as success rather than aborting the whole operation. + Err(GitError::CommandFailed(msg)) if msg.contains("not found") => { + eprintln!("Branch '{}' was already deleted", branch); + Ok(()) + } Err(GitError::CommandFailed(msg)) if msg.contains("not fully merged") => { let confirmed = ui::confirm::run_on_stderr(&format!( "Branch '{}' is not fully merged. Force delete it?", @@ -929,7 +935,7 @@ pub(crate) fn print_go_path(path: &Path) { } } -fn main_worktree_root(worktrees: &[Worktree]) -> Result { +pub(crate) fn main_worktree_root(worktrees: &[Worktree]) -> Result { worktrees .iter() .find(|w| w.is_main) diff --git a/src/commands/workspace_clean.rs b/src/commands/workspace_clean.rs new file mode 100644 index 0000000..863258f --- /dev/null +++ b/src/commands/workspace_clean.rs @@ -0,0 +1,803 @@ +use crate::commands::workspace::{delete_local_branch, main_worktree_root}; +use crate::config::{self, Config}; +use crate::git::time::now_secs; +use crate::git::worktree::{OrphanBranch, Worktree, WorktreeSummary}; +use crate::git::{self, GitError}; +use crate::ui; +use crate::ui::clean_picker::{CleanAction, CleanInputs}; +use miette::{Diagnostic, Result}; +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use thiserror::Error; + +#[derive(Error, Debug, Diagnostic)] +pub enum WorkspaceCleanError { + #[error("Git error: {0}")] + #[diagnostic(code(gx::workspace_clean::git_error))] + GitError(#[from] GitError), + + #[error("TUI error: {0}")] + #[diagnostic(code(gx::workspace_clean::tui_error))] + TuiError(String), + + #[error("Cannot protect a detached HEAD; check out a branch first")] + #[diagnostic(code(gx::workspace_clean::detached_head))] + DetachedHead, + + #[error("No branch resolved: {0}")] + #[diagnostic(code(gx::workspace_clean::no_branch))] + NoBranch(String), +} + +/// The set of branches cleanup must never remove: the configured list plus the +/// always-protected branches (default branch, `main`, `master`, the current +/// branch, and any branch checked out in an active worktree). +#[derive(Debug, Clone, Default)] +pub(crate) struct ProtectionSet { + names: HashSet, +} + +impl ProtectionSet { + pub(crate) fn is_protected(&self, branch: &str) -> bool { + self.names.contains(branch) + } + + pub(crate) fn names(&self) -> &HashSet { + &self.names + } +} + +/// Pure assembly of the protected-branch set from already-resolved inputs. +/// Extracted from [`resolve_protected`] so it is unit-testable without a repo. +fn build_protection_set( + configured: &[String], + default_branch: Option<&str>, + current_branch: Option<&str>, + checked_out: impl IntoIterator, +) -> ProtectionSet { + let mut names: HashSet = HashSet::new(); + names.extend(configured.iter().cloned()); + names.insert("main".to_string()); + names.insert("master".to_string()); + if let Some(default_branch) = default_branch { + names.insert(default_branch.to_string()); + } + if let Some(current) = current_branch { + names.insert(current.to_string()); + } + names.extend(checked_out); + + ProtectionSet { names } +} + +/// Resolve the active protection set for the repository. +fn resolve_protected(cfg: &Config, worktrees: &[Worktree]) -> ProtectionSet { + // origin's default branch comes back as "origin/main"; protect the local + // branch name, so strip the remote prefix. + let default_branch = git::worktree::default_remote_branch() + .ok() + .flatten() + .map(|remote| strip_remote_prefix(&remote).to_string()); + + let current_branch = git::branch::get_current_branch() + .ok() + .filter(|b| !b.is_detached) + .map(|b| b.name); + + let checked_out = worktrees + .iter() + .filter_map(|w| w.branch.clone()) + .collect::>(); + + build_protection_set( + &cfg.workspace.protected_branches, + default_branch.as_deref(), + current_branch.as_deref(), + checked_out, + ) +} + +/// Strip a leading "/" component (e.g. "origin/main" -> "main"). +fn strip_remote_prefix(remote_branch: &str) -> &str { + remote_branch + .split_once('/') + .map(|(_, tail)| tail) + .unwrap_or(remote_branch) +} + +/// The outcome of evaluating a workspace against the safety rules. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum SafetyResult { + Safe, + Unsafe(&'static str), +} + +/// Decide whether a workspace is safe to remove. Pure over its inputs so the +/// dirty/unpushed/locked/protected/main/current rules are unit-testable. +/// +/// `force` bypasses ONLY the dirty/untracked/unpushed checks. The main worktree, +/// current worktree, locked worktrees, and protected branches are never removed, +/// even with `--force`. +pub(crate) fn is_safe_to_remove( + worktree: &Worktree, + summary: &WorktreeSummary, + protection: &ProtectionSet, + force: bool, +) -> SafetyResult { + if worktree.is_main { + return SafetyResult::Unsafe("main worktree"); + } + if worktree.is_current { + return SafetyResult::Unsafe("current worktree"); + } + if worktree.is_locked { + return SafetyResult::Unsafe("locked"); + } + if worktree + .branch + .as_deref() + .is_some_and(|b| protection.is_protected(b)) + { + return SafetyResult::Unsafe("protected branch"); + } + + if force { + return SafetyResult::Safe; + } + + if summary.tracked_changes > 0 { + return SafetyResult::Unsafe("uncommitted changes"); + } + if summary.untracked_changes > 0 { + return SafetyResult::Unsafe("untracked files"); + } + if summary.has_unpushed_commits() { + return SafetyResult::Unsafe("unpushed commits"); + } + // Mirror the orphan-branch rule: a branch with no configured upstream has + // never been pushed, so its commits exist only here. `ahead == None` (with a + // branch present) means the upstream comparison could not be made; treat that + // as unsafe rather than assuming "0 unpushed". A detached worktree (no + // branch) has no branch to lose, so it is exempt. + if worktree.branch.is_some() && summary.ahead.is_none() { + return SafetyResult::Unsafe("no upstream (never pushed)"); + } + + SafetyResult::Safe +} + +/// `gx workspace clean [--auto --dry-run --use-threshold --force]`. +pub fn run_clean(auto: bool, dry_run: bool, use_threshold: bool, force: bool) -> Result<()> { + let cfg = config::load()?; + + // CLI flag OR config: an explicit --auto flag or `clean.auto = true` both + // select the non-interactive path. + let auto = auto || cfg.workspace.clean.auto; + + if auto { + run_clean_auto(&cfg, dry_run, use_threshold, force) + } else { + run_clean_interactive(&cfg) + } +} + +fn run_clean_auto(cfg: &Config, dry_run: bool, use_threshold: bool, force: bool) -> Result<()> { + let worktrees = git::worktree::list().map_err(WorkspaceCleanError::GitError)?; + let protection = resolve_protected(cfg, &worktrees); + let main_root = main_worktree_root(&worktrees)?; + let now = now_secs(); + let threshold = cfg.workspace.clean.threshold_days; + + let mut safe: Vec = Vec::new(); + + for worktree in &worktrees { + if worktree.is_main || worktree.is_current { + continue; + } + + if use_threshold { + let age = git::worktree::workspace_age_days(worktree, now) + .map_err(WorkspaceCleanError::GitError)?; + if age < threshold { + eprintln!( + "Skipping '{}': below threshold ({} < {} days)", + worktree.name, age, threshold + ); + continue; + } + } + + let summary = git::worktree::summarize(worktree).map_err(WorkspaceCleanError::GitError)?; + match is_safe_to_remove(worktree, &summary, &protection, force) { + SafetyResult::Safe => safe.push(worktree.clone()), + SafetyResult::Unsafe(reason) => { + eprintln!("Skipping '{}': {}", worktree.name, reason); + } + } + } + + if safe.is_empty() { + eprintln!("No workspaces are safe to remove."); + return Ok(()); + } + + if dry_run { + eprintln!("Would remove {} workspace(s):", safe.len()); + for worktree in &safe { + eprintln!(" - {} ({})", worktree.name, worktree.path.display()); + } + eprintln!("(dry run; nothing was deleted)"); + return Ok(()); + } + + let prompt = clean_confirm_prompt(&safe); + if !ui::confirm::run_on_stderr(&prompt)? { + eprintln!("Cancelled"); + return Ok(()); + } + + for worktree in &safe { + eprintln!("Removing workspace '{}'...", worktree.name); + // Safety was already enforced above; `force` here only relaxes git's own + // dirty-file guard so a --force run can actually proceed. + git::worktree::remove(&main_root, &worktree.path, force) + .map_err(WorkspaceCleanError::GitError)?; + eprintln!("Removed workspace '{}'", worktree.name); + } + + Ok(()) +} + +fn clean_confirm_prompt(safe: &[Worktree]) -> String { + if let [worktree] = safe { + return format!( + "Remove workspace '{}' ({})?", + worktree.name, + worktree.path.display() + ); + } + + let mut lines = vec![format!("Remove {} workspace(s)?", safe.len()), String::new()]; + lines.extend( + safe.iter() + .map(|w| format!(" - {} ({})", w.name, w.path.display())), + ); + lines.join("\n") +} + +fn run_clean_interactive(cfg: &Config) -> Result<()> { + let worktrees = git::worktree::list().map_err(WorkspaceCleanError::GitError)?; + let protection = resolve_protected(cfg, &worktrees); + let main_root = main_worktree_root(&worktrees)?; + + let gone_branches = + git::worktree::remote_gone_branches().map_err(WorkspaceCleanError::GitError)?; + let gone_set: HashSet<&str> = gone_branches.iter().map(String::as_str).collect(); + + let orphans = + git::worktree::orphan_branches(&worktrees).map_err(WorkspaceCleanError::GitError)?; + // A `[gone]` branch with no worktree would otherwise appear in BOTH this + // section and the "remote tracking branch is gone" section below, sharing + // the same `branch:` selection key and producing a duplicate delete. + // Surface each branch in exactly one section. + let orphan_branches: Vec<(String, bool, bool)> = orphans + .into_iter() + .filter(|o: &OrphanBranch| !gone_set.contains(o.name.as_str())) + .map(|o: OrphanBranch| (o.name, o.has_unpushed, o.has_upstream)) + .collect(); + + // Compute each workspace's age once here instead of on every TUI frame: the + // render loop redraws ~20x/sec and the age lookup does repository discovery + // plus a revparse per row, which is wasted git I/O if recomputed live. + let now = now_secs(); + let ages: HashMap = worktrees + .iter() + .filter_map(|w| git::worktree::workspace_age_days(w, now).ok().map(|d| (w.path.clone(), d))) + .collect(); + + let inputs = CleanInputs { + worktrees: worktrees.clone(), + orphan_branches, + gone_branches, + protected: protection.names().clone(), + ages, + }; + + let Some(action) = pick_clean(inputs, &worktrees)? else { + eprintln!("Cancelled"); + return Ok(()); + }; + + if !action.confirmed + || (action.remove_worktrees.is_empty() && action.delete_branches.is_empty()) + { + eprintln!("Nothing selected"); + return Ok(()); + } + + apply_clean_action(&action, &main_root) +} + +fn pick_clean(inputs: CleanInputs, worktrees: &[Worktree]) -> Result> { + let mut terminal = ui::terminal::setup_terminal_stderr() + .map_err(|e| WorkspaceCleanError::TuiError(e.to_string()))?; + let summary_lookup = git::worktree::spawn_summary_lookup(worktrees); + let result = ui::clean_picker::run(&mut terminal, inputs, summary_lookup); + ui::terminal::restore_terminal_stderr(terminal) + .map_err(|e| WorkspaceCleanError::TuiError(e.to_string()))?; + result +} + +fn apply_clean_action(action: &CleanAction, main_root: &Path) -> Result<()> { + // Remove the current workspace last so a failure elsewhere does not strand + // the user's shell inside a deleted directory. + let mut targets = action.remove_worktrees.clone(); + targets.sort_by_key(|w| w.is_current); + let removes_current = targets.iter().any(|w| w.is_current); + + for worktree in &targets { + eprintln!("Removing workspace '{}'...", worktree.name); + match git::worktree::remove(main_root, &worktree.path, false) { + Ok(()) => {} + Err(GitError::CommandFailed(msg)) + if msg.contains("contains modified or untracked files") => + { + let confirmed = ui::confirm::run_on_stderr(&format!( + "Workspace '{}' has modified or untracked files. Remove anyway?", + worktree.name + ))?; + if !confirmed { + eprintln!("Skipped '{}'", worktree.name); + continue; + } + git::worktree::remove(main_root, &worktree.path, true) + .map_err(WorkspaceCleanError::GitError)?; + } + Err(e) => return Err(WorkspaceCleanError::GitError(e).into()), + } + + match &worktree.branch { + Some(branch) => eprintln!( + "Removed workspace '{}' (branch '{}' kept)", + worktree.name, branch + ), + None => eprintln!("Removed workspace '{}'", worktree.name), + } + } + + for branch in &action.delete_branches { + eprintln!("Deleting branch '{}'...", branch); + delete_local_branch(main_root, branch)?; + } + + if removes_current { + eprintln!("Switching to main workspace"); + crate::commands::workspace::print_go_path(main_root); + } + + Ok(()) +} + +/// `gx workspace prune [--dry-run --no-branches]`. +pub fn run_prune(dry_run: bool, no_branches: bool) -> Result<()> { + let cfg = config::load()?; + let worktrees = git::worktree::list().map_err(WorkspaceCleanError::GitError)?; + let main_root = main_worktree_root(&worktrees)?; + + // Step 1: prune stale worktree metadata. + if dry_run { + eprintln!("Would run 'git worktree prune' to drop stale worktree metadata"); + } else { + git::worktree::prune_metadata(&main_root).map_err(WorkspaceCleanError::GitError)?; + eprintln!("Pruned stale worktree metadata"); + } + + if no_branches { + eprintln!("--no-branches: skipping branch cleanup"); + return Ok(()); + } + + // Re-list after pruning so removed worktrees no longer pin their branches. + let worktrees = git::worktree::list().map_err(WorkspaceCleanError::GitError)?; + let protection = resolve_protected(&cfg, &worktrees); + + let orphans = + git::worktree::orphan_branches(&worktrees).map_err(WorkspaceCleanError::GitError)?; + + let deletable = prunable_branches(&orphans, &protection); + + if deletable.is_empty() { + eprintln!("No orphan branches are safe to delete."); + return Ok(()); + } + + if dry_run { + eprintln!("Would delete {} orphan branch(es):", deletable.len()); + for branch in &deletable { + eprintln!(" - {}", branch); + } + eprintln!("(dry run; nothing was deleted)"); + return Ok(()); + } + + let prompt = prune_confirm_prompt(&deletable); + if !ui::confirm::run_on_stderr(&prompt)? { + eprintln!("Cancelled"); + return Ok(()); + } + + for branch in &deletable { + eprintln!("Deleting branch '{}'...", branch); + delete_local_branch(&main_root, branch)?; + } + + Ok(()) +} + +/// Select orphan branches that are safe to delete: not protected, no unpushed +/// commits, and (per spec) not lacking an upstream. Pure for unit testing. +fn prunable_branches(orphans: &[OrphanBranch], protection: &ProtectionSet) -> Vec { + orphans + .iter() + .filter(|o| !protection.is_protected(&o.name)) + .filter(|o| !o.has_unpushed) + // A branch with no upstream is treated as unsafe: it has never been + // pushed, so deleting it could discard the only copy of the work. + .filter(|o| o.has_upstream) + .map(|o| o.name.clone()) + .collect() +} + +fn prune_confirm_prompt(branches: &[String]) -> String { + let mut lines = vec![ + format!("Delete {} orphan branch(es)?", branches.len()), + String::new(), + ]; + lines.extend(branches.iter().map(|b| format!(" - {}", b))); + lines.join("\n") +} + +/// `gx workspace protect [branch]`. +pub fn run_protect(branch: Option) -> Result<()> { + let branch = resolve_branch_arg(branch)?; + let mut cfg = config::load()?; + + if cfg.workspace.protected_branches.iter().any(|b| b == &branch) { + eprintln!("Branch '{}' is already protected", branch); + return Ok(()); + } + + cfg.workspace.protected_branches.push(branch.clone()); + config::store(&cfg)?; + + eprintln!("Protected branch '{}'", branch); + if is_implicitly_protected(&branch) { + eprintln!( + "note: '{}' was already protected by default rules; it is now listed explicitly too", + branch + ); + } + Ok(()) +} + +/// `gx workspace unprotect [branch]`. +pub fn run_unprotect(branch: Option) -> Result<()> { + let branch = resolve_branch_arg(branch)?; + let mut cfg = config::load()?; + + let before = cfg.workspace.protected_branches.len(); + cfg.workspace.protected_branches.retain(|b| b != &branch); + + if cfg.workspace.protected_branches.len() == before { + eprintln!("'{}' was not in the protected list", branch); + } else { + config::store(&cfg)?; + eprintln!("Removed '{}' from the protected list", branch); + } + + // Even after removal, the always-protected rules may still cover the branch. + if is_implicitly_protected(&branch) { + eprintln!( + "note: '{}' remains protected by default rules (default/main/master/current/checked-out)", + branch + ); + } + + Ok(()) +} + +/// Resolve a `[branch]` argument: an explicit value is used as-is; otherwise the +/// current branch is resolved, erroring on a detached HEAD. +fn resolve_branch_arg(branch: Option) -> Result { + match branch { + Some(branch) => Ok(branch), + None => { + let status = + git::branch::get_current_branch().map_err(WorkspaceCleanError::GitError)?; + if status.is_detached { + return Err(WorkspaceCleanError::DetachedHead.into()); + } + if status.name == "(no commits)" { + return Err(WorkspaceCleanError::NoBranch( + "the repository has no commits yet".to_string(), + ) + .into()); + } + Ok(status.name) + } + } +} + +/// True when a branch is protected by the always-protected rules regardless of +/// the configured list (best-effort: a non-repo context just answers `main`/ +/// `master`). Used only for advisory messaging. +fn is_implicitly_protected(branch: &str) -> bool { + if branch == "main" || branch == "master" { + return true; + } + + let default_branch = git::worktree::default_remote_branch() + .ok() + .flatten() + .map(|remote| strip_remote_prefix(&remote).to_string()); + if default_branch.as_deref() == Some(branch) { + return true; + } + + let current = git::branch::get_current_branch() + .ok() + .filter(|b| !b.is_detached) + .map(|b| b.name); + if current.as_deref() == Some(branch) { + return true; + } + + git::worktree::list() + .map(|wts| { + wts.iter() + .any(|w| w.branch.as_deref() == Some(branch)) + }) + .unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn worktree(name: &str, branch: Option<&str>) -> Worktree { + Worktree { + name: name.to_string(), + path: PathBuf::from(format!("/ws/{}", name)), + branch: branch.map(|b| b.to_string()), + head: None, + is_main: false, + is_current: false, + is_bare: false, + is_locked: false, + } + } + + /// A clean, fully-pushed branch: no changes and an upstream that HEAD is + /// level with (`ahead: Some(0)`, i.e. upstream exists but nothing unpushed). + fn clean_summary() -> WorktreeSummary { + WorktreeSummary { + ahead: Some(0), + status_loaded: true, + ..Default::default() + } + } + + fn protection(branches: &[&str]) -> ProtectionSet { + ProtectionSet { + names: branches.iter().map(|b| b.to_string()).collect(), + } + } + + #[test] + fn test_build_protection_set_includes_always_protected() { + let set = build_protection_set( + &["staging".to_string(), "release".to_string()], + Some("develop"), + Some("feature/x"), + vec!["checked-out".to_string()], + ); + + for branch in [ + "staging", + "release", + "main", + "master", + "develop", + "feature/x", + "checked-out", + ] { + assert!(set.is_protected(branch), "{} should be protected", branch); + } + assert!(!set.is_protected("random-branch")); + } + + #[test] + fn test_strip_remote_prefix() { + assert_eq!(strip_remote_prefix("origin/main"), "main"); + assert_eq!(strip_remote_prefix("upstream/feature/x"), "feature/x"); + assert_eq!(strip_remote_prefix("main"), "main"); + } + + #[test] + fn test_main_worktree_never_removed_even_with_force() { + let mut main = worktree("repo", Some("main")); + main.is_main = true; + let protection = protection(&[]); + + assert_eq!( + is_safe_to_remove(&main, &clean_summary(), &protection, true), + SafetyResult::Unsafe("main worktree") + ); + assert_eq!( + is_safe_to_remove(&main, &clean_summary(), &protection, false), + SafetyResult::Unsafe("main worktree") + ); + } + + #[test] + fn test_current_worktree_never_removed() { + let mut current = worktree("ws", Some("feature")); + current.is_current = true; + assert_eq!( + is_safe_to_remove(¤t, &clean_summary(), &protection(&[]), true), + SafetyResult::Unsafe("current worktree") + ); + } + + #[test] + fn test_locked_worktree_is_skipped() { + let mut locked = worktree("ws", Some("feature")); + locked.is_locked = true; + // Even with --force a locked worktree is never removed. + assert_eq!( + is_safe_to_remove(&locked, &clean_summary(), &protection(&[]), true), + SafetyResult::Unsafe("locked") + ); + } + + #[test] + fn test_protected_branch_is_skipped() { + let ws = worktree("ws", Some("staging")); + assert_eq!( + is_safe_to_remove(&ws, &clean_summary(), &protection(&["staging"]), true), + SafetyResult::Unsafe("protected branch") + ); + } + + #[test] + fn test_dirty_workspace_skipped_without_force() { + let ws = worktree("ws", Some("feature")); + let dirty = WorktreeSummary { + tracked_changes: 2, + status_loaded: true, + ..Default::default() + }; + + assert_eq!( + is_safe_to_remove(&ws, &dirty, &protection(&[]), false), + SafetyResult::Unsafe("uncommitted changes") + ); + // --force bypasses the dirty check. + assert_eq!( + is_safe_to_remove(&ws, &dirty, &protection(&[]), true), + SafetyResult::Safe + ); + } + + #[test] + fn test_untracked_workspace_skipped_without_force() { + let ws = worktree("ws", Some("feature")); + let untracked = WorktreeSummary { + untracked_changes: 1, + status_loaded: true, + ..Default::default() + }; + assert_eq!( + is_safe_to_remove(&ws, &untracked, &protection(&[]), false), + SafetyResult::Unsafe("untracked files") + ); + assert_eq!( + is_safe_to_remove(&ws, &untracked, &protection(&[]), true), + SafetyResult::Safe + ); + } + + #[test] + fn test_unpushed_workspace_skipped_without_force() { + let ws = worktree("ws", Some("feature")); + let unpushed = WorktreeSummary { + ahead: Some(3), + status_loaded: true, + ..Default::default() + }; + assert_eq!( + is_safe_to_remove(&ws, &unpushed, &protection(&[]), false), + SafetyResult::Unsafe("unpushed commits") + ); + // --force bypasses the unpushed check too. + assert_eq!( + is_safe_to_remove(&ws, &unpushed, &protection(&[]), true), + SafetyResult::Safe + ); + } + + #[test] + fn test_clean_workspace_is_safe() { + let ws = worktree("ws", Some("feature")); + assert_eq!( + is_safe_to_remove(&ws, &clean_summary(), &protection(&[]), false), + SafetyResult::Safe + ); + } + + #[test] + fn test_workspace_with_no_upstream_is_unsafe_without_force() { + // `ahead: None` with a branch present means the upstream comparison + // could not be made (never pushed). Mirrors the orphan-branch rule: + // treat it as unsafe rather than assuming nothing is unpushed. + let ws = worktree("ws", Some("feature")); + let no_upstream = WorktreeSummary { + ahead: None, + status_loaded: true, + ..Default::default() + }; + assert_eq!( + is_safe_to_remove(&ws, &no_upstream, &protection(&[]), false), + SafetyResult::Unsafe("no upstream (never pushed)") + ); + // --force bypasses the never-pushed guard, like the other content checks. + assert_eq!( + is_safe_to_remove(&ws, &no_upstream, &protection(&[]), true), + SafetyResult::Safe + ); + } + + #[test] + fn test_detached_workspace_not_flagged_for_missing_upstream() { + // A detached worktree (no branch) has `ahead: None` but no branch to + // lose, so the never-pushed guard must not fire. + let detached = worktree("detached", None); + let summary = WorktreeSummary { + ahead: None, + status_loaded: true, + ..Default::default() + }; + assert_eq!( + is_safe_to_remove(&detached, &summary, &protection(&[]), false), + SafetyResult::Safe + ); + } + + fn orphan(name: &str, has_unpushed: bool, has_upstream: bool) -> OrphanBranch { + OrphanBranch { + name: name.to_string(), + has_unpushed, + has_upstream, + } + } + + #[test] + fn test_prunable_branches_filters_protected_unpushed_and_no_upstream() { + let orphans = vec![ + orphan("safe", false, true), // deletable + orphan("protected", false, true), // skipped: protected + orphan("ahead", true, true), // skipped: unpushed + orphan("no-upstream", false, false), // skipped: no upstream (unsafe) + ]; + let deletable = prunable_branches(&orphans, &protection(&["protected"])); + assert_eq!(deletable, vec!["safe".to_string()]); + } + + #[test] + fn test_prunable_branches_orphan_without_unpushed_is_deletable() { + let orphans = vec![orphan("feature-merged", false, true)]; + let deletable = prunable_branches(&orphans, &protection(&[])); + assert_eq!(deletable, vec!["feature-merged".to_string()]); + } +}