From c152b4a89923e05d5fbf4c75f2e8d0328bfc33ff Mon Sep 17 00:00:00 2001 From: "Waylon S. Walker" Date: Tue, 25 Nov 2025 20:38:16 -0600 Subject: [PATCH] fix: workspaces rm --- workspaces.py | 87 +++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 78 insertions(+), 9 deletions(-) diff --git a/workspaces.py b/workspaces.py index f027dd7..c7b22f2 100755 --- a/workspaces.py +++ b/workspaces.py @@ -669,6 +669,60 @@ def has_unpushed_commits(repo_path: Path, branch: str) -> bool: return count > 0 +def is_branch_integrated_into_main( + repo_path: Path, + branch: str, + main_ref: str = "origin/main", +) -> bool: + """ + Heuristic: is `branch`'s content already in `main_ref`? + + Returns True if: + - branch tip is an ancestor of main_ref (normal merge / FF / rebase-before-merge), OR + - the branch tip's tree hash appears somewhere in main_ref's history + (common for squash merges and cherry-picks). + + This does not know about PRs, only Git history. + """ + # Make sure we have latest main (best-effort; ignore fetch errors) + _code, _out, _err = run_cmd(["git", "fetch", "--quiet", "origin"], cwd=repo_path) + + # 1) Simple case: branch is ancestor of main_ref + code, _out, _err = run_cmd( + ["git", "merge-base", "--is-ancestor", branch, main_ref], + cwd=repo_path, + ) + if code == 0: + return True + + # 2) Squash / cherry-pick heuristic: same tree exists on main_ref + code, out, err = run_cmd( + ["git", "show", "-s", "--format=%T", branch], + cwd=repo_path, + ) + if code != 0: + console.print( + f"[red]Failed to get tree for branch {branch} in {repo_path.name}:[/red]\n{err}" + ) + return False + + branch_tree = out.strip() + if not branch_tree: + return False + + code, out, err = run_cmd( + ["git", "log", "--format=%T", main_ref], + cwd=repo_path, + ) + if code != 0: + console.print( + f"[red]Failed to walk {main_ref} in {repo_path.name}:[/red]\n{err}" + ) + return False + + main_trees = {line.strip() for line in out.splitlines() if line.strip()} + return branch_tree in main_trees + @app.command("rm") def remove_workspace( ctx: typer.Context, @@ -687,6 +741,11 @@ def remove_workspace( "-f", help="Force removal even if there is dirty or unpushed work.", ), + main_ref: str = typer.Option( + "origin/main", + "--main-ref", + help="Ref to consider as the integration target (default: origin/main).", + ), ): """ Remove a workspace: @@ -732,13 +791,19 @@ def remove_workspace( for wt in worktrees: status = get_git_status(wt) branch = get_current_branch(wt) or "?" + repo = find_repo_for_worktree(wt, repos_dir) + + integrated = False + if repo is not None and branch != "?": + integrated = is_branch_integrated_into_main(wt, branch, main_ref=main_ref) + if status.dirty: problems.append(f"{wt.name}: dirty working tree on '{branch}'") - repo = find_repo_for_worktree(wt, repos_dir) - if repo is not None and branch != "?": + + # Only care about "unpushe­d commits" if the branch is NOT integrated into main. + if repo is not None and branch != "?" and not integrated: if has_unpushed_commits(wt, branch): problems.append(f"{wt.name}: unpushed commits on '{branch}'") - if problems and not force: console.print( "[red]Refusing to remove workspace; found dirty or unpushed work:[/red]" @@ -770,10 +835,13 @@ def remove_workspace( continue console.print(f"Removing worktree for [bold]{display_name}[/bold]...") - code, _out, err = run_cmd( - ["git", "worktree", "remove", "--force" if force else "--detach", str(wt)], - cwd=repo, - ) + + args = ["git", "worktree", "remove"] + if force: + args.append("--force") + args.append(str(wt)) + + code, _out, err = run_cmd(args, cwd=repo) if code != 0: console.print( f"[red]Failed to remove worktree {display_name} via git:[/red]\n{err}" @@ -785,9 +853,8 @@ def remove_workspace( ) shutil.rmtree(wt) elif wt.exists(): - # git worktree remove should delete the directory; if not, clean up. + # git worktree remove *should* delete the directory; if not, clean up. shutil.rmtree(wt) - # Finally, remove the workspace directory itself shutil.rmtree(ws_dir) console.print( @@ -1435,3 +1502,5 @@ app.add_typer(tmux_app) if __name__ == "__main__": app() + +