From c152b4a89923e05d5fbf4c75f2e8d0328bfc33ff Mon Sep 17 00:00:00 2001 From: "Waylon S. Walker" Date: Tue, 25 Nov 2025 20:38:16 -0600 Subject: [PATCH 1/4] 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() + + -- 2.47.3 From ca47d5da893abd77a172b7ab8253c917832dbce4 Mon Sep 17 00:00:00 2001 From: "Waylon S. Walker" Date: Tue, 25 Nov 2025 20:43:59 -0600 Subject: [PATCH 2/4] remove unused unicode --- workspaces.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/workspaces.py b/workspaces.py index c7b22f2..99b88ab 100755 --- a/workspaces.py +++ b/workspaces.py @@ -800,7 +800,7 @@ def remove_workspace( if status.dirty: problems.append(f"{wt.name}: dirty working tree on '{branch}'") - # Only care about "unpushe­d commits" if the branch is NOT integrated into main. + # Only care about "unpushed 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}'") -- 2.47.3 From 4de2448aa95885af3203d7a7380b8adeaffb817a Mon Sep 17 00:00:00 2001 From: "Waylon S. Walker" Date: Tue, 25 Nov 2025 20:54:39 -0600 Subject: [PATCH 3/4] better unpushed_commits detection --- workspaces.py | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/workspaces.py b/workspaces.py index 99b88ab..2937104 100755 --- a/workspaces.py +++ b/workspaces.py @@ -644,30 +644,39 @@ def find_repo_for_worktree( def has_unpushed_commits(repo_path: Path, branch: str) -> bool: """ - Detect if branch has commits not on its upstream. - Uses 'git rev-list @{u}..HEAD'; if non-empty, there are unpushed commits. + Detect if `branch` has commits not on its upstream. + + If no upstream is configured, we *do not* treat that as "unpushed" anymore, + just "can't check", and return False (so rm won't block on it). """ - # Check if upstream exists - code, _, _ = run_cmd( + # Find upstream ref, e.g. origin/branch + code, out, _ = run_cmd( ["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], cwd=repo_path, ) if code != 0: - # No upstream configured; treat as unpushed work. - return True + # No upstream configured; assume "not able to check", *not* a blocker. + return False + upstream = out.strip() + if not upstream or upstream == "@{u}": + return False + + # Number of commits on HEAD that are not in upstream code, out, _ = run_cmd( - ["git", "rev-list", "@{u}..HEAD", "--count"], + ["git", "rev-list", "--count", f"{upstream}..{branch}"], cwd=repo_path, ) if code != 0: - return True + # If we can't check, don't block + return False + try: count = int(out.strip() or "0") except ValueError: - return True - return count > 0 + return False + return count > 0 def is_branch_integrated_into_main( repo_path: Path, -- 2.47.3 From e18a8a8db493b9e9f4f1b4f987399fbf2a976d55 Mon Sep 17 00:00:00 2001 From: "Waylon S. Walker" Date: Tue, 25 Nov 2025 20:55:23 -0600 Subject: [PATCH 4/4] roll back unpushed commits change --- workspaces.py | 29 ++++++++++------------------- 1 file changed, 10 insertions(+), 19 deletions(-) diff --git a/workspaces.py b/workspaces.py index 2937104..99b88ab 100755 --- a/workspaces.py +++ b/workspaces.py @@ -644,40 +644,31 @@ def find_repo_for_worktree( def has_unpushed_commits(repo_path: Path, branch: str) -> bool: """ - Detect if `branch` has commits not on its upstream. - - If no upstream is configured, we *do not* treat that as "unpushed" anymore, - just "can't check", and return False (so rm won't block on it). + Detect if branch has commits not on its upstream. + Uses 'git rev-list @{u}..HEAD'; if non-empty, there are unpushed commits. """ - # Find upstream ref, e.g. origin/branch - code, out, _ = run_cmd( + # Check if upstream exists + code, _, _ = run_cmd( ["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], cwd=repo_path, ) if code != 0: - # No upstream configured; assume "not able to check", *not* a blocker. - return False + # No upstream configured; treat as unpushed work. + return True - upstream = out.strip() - if not upstream or upstream == "@{u}": - return False - - # Number of commits on HEAD that are not in upstream code, out, _ = run_cmd( - ["git", "rev-list", "--count", f"{upstream}..{branch}"], + ["git", "rev-list", "@{u}..HEAD", "--count"], cwd=repo_path, ) if code != 0: - # If we can't check, don't block - return False - + return True try: count = int(out.strip() or "0") except ValueError: - return False - + return True return count > 0 + def is_branch_integrated_into_main( repo_path: Path, branch: str, -- 2.47.3