From c0f0a172c7eb55d39b86a9c8ce101495441c82c9 Mon Sep 17 00:00:00 2001 From: "Waylon S. Walker" Date: Fri, 28 Nov 2025 11:04:02 -0600 Subject: [PATCH] feat: workspace clean (#7) Reviewed-on: https://git.wayl.one/waylon/workspaces/pulls/7 Co-authored-by: Waylon S. Walker Co-committed-by: Waylon S. Walker --- workspaces.py | 112 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/workspaces.py b/workspaces.py index f7158bc..e3a53d2 100755 --- a/workspaces.py +++ b/workspaces.py @@ -1315,6 +1315,118 @@ def diff_workspace( if not any_diffs: console.print("[green]No changes to diff in any repo.[/green]") +def get_branches_merged(repo_dir: Path, remote: bool = False, main_branch: str="main") -> list[str]: + cmd = ["git", "branch", "--merged", main_branch] + if remote: + cmd.append("--remotes") + branches = run_cmd(cmd, cwd=repo_dir) + current_branch = get_current_branch(repo_dir) + branches = [b.strip() for b in branches[1].splitlines() if not b.startswith("*") and not b.startswith("+")] + + branches = [ + b for b in branches + if b not in {main_branch, f"origin/{main_branch}", "origin/HEAD -> origin/main", current_branch} + and "->" not in b # filters symbolic refs + ] + return branches + + +@app.command("clean") +def clean_workspace( + ctx: typer.Context, + workspace: str | None = typer.Option( + None, + "--workspace", + "-w", + help=( + "Workspace directory name to diff. " + "If omitted, uses the workspace containing the current directory." + ), + ), + dry_run: bool = typer.Option( + False, + "--dry-run", + "-n", + help="Print what would be deleted, but don't actually delete.", + ), +): + """Show detailed status for the current (or specified) workspace. + + For each repo in the workspace: + - look up all local branches that are merged into main + - ask user to delete local branches that are merged into main + - look up all remote branches that are merged into main + - ask user to delete remote branches that are merged into main + """ + _settings, _repos_dir, workspaces_dir = get_ctx_paths(ctx) + ws_dir = find_workspace_dir(workspaces_dir, workspace) + workspace = ws_dir.name + + if not ws_dir: + console.print(f"[red]Workspace '{workspace}' does not exist at {ws_dir}[/red]") + raise typer.Exit(1) + repos = [directory for directory in ws_dir.iterdir() if directory.is_dir()] + repo = pick_repo_with_iterfzf(repos) + + title, _desc = read_workspace_readme(ws_dir) + console.print( + f"Cleaning workspace [bold]{title or ws_dir.name}[/bold] (dir: {repo.name})" + ) + + run_cmd(["git", "fetch", "--all", "--prune"], cwd=repo) + local_branches_merged = get_branches_merged(repo, remote=False) + remote_branches_merged = get_branches_merged(repo, remote=True) + + if len(local_branches_merged) == 0 and len(remote_branches_merged) == 0: + console.print("[green]No branches to delete.[/green]") + raise typer.Exit(0) + + console.print( + f"[green]Would delete {len(local_branches_merged)} local branches[/green]" + ) + for b in local_branches_merged: + console.print(f"[yellow]{b}[/yellow]") + console.print( + f"[green]Would delete {len(remote_branches_merged)} remote branches[/green]" + ) + for b in remote_branches_merged: + console.print(f"[yellow]{b}[/yellow]") + + if dry_run: + console.print("[grey]Dry run. Exiting.[/grey]") + raise typer.Exit(0) + + if not Confirm.ask("Delete these branches?"): + console.print("[red]Aborting.[/red]") + raise typer.Exit(1) + + for b in local_branches_merged: + cmd = ["git", "branch", "-D", b] + + run_cmd(cmd, cwd=repo) + + for b in remote_branches_merged: + # b looks like "origin/feat/workspaces-tmux-support" + if "->" in b: + # safety: skip symbolic refs like "origin/HEAD -> origin/main" + continue + + try: + remote, branch = b.split("/", 1) + except ValueError: + # fallback: no slash? assume origin + raw name + remote, branch = "origin", b + + cmd = ["git", "push", remote, "--delete", branch] + + status, out, err = run_cmd(cmd, cwd=repo) + + if status != 0: + console.print(f"[red]Failed to delete {remote}/{branch}:[/red]\n{err}") + continue + + console.print("[green]Done.[/green]") + def in_tmux() -> bool: """Return True if inside tmux"""