From d9b4ad2af4111bf565460b4a68f7811b72f9afdb Mon Sep 17 00:00:00 2001 From: Vitalii Mishchenko Date: Thu, 21 May 2026 21:57:17 -0700 Subject: [PATCH] Align prune defaults to delete all non-running runs Remove the --all flag and change prune's default behavior to delete every non-running run in the selected project scope, not just those that finished successfully. Add run status classification (done, stopped, missing, corrupt) to make the selection more transparent. Update delete to include the resolved project name in its output. Provide a helpful hint when non-running runs exist in other projects. --- README.md | 8 +-- src/draft/command_delete.py | 2 +- src/draft/command_prune.py | 68 ++++++++++-------- src/draft/runs.py | 18 +++++ tests/draft/test_commands.py | 130 ++++++++++++++++++++++++++++++++--- 5 files changed, 182 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index a2ef004..02205a6 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ Steps in order: - [draft fix-pr](#draft-fix-pr) — fix the current CI failures on a PR locally without pushing - [draft continue](#draft-continue) — resume a stopped or failed run - [draft delete](#draft-delete) — remove a single run -- [draft prune](#draft-prune) — bulk-delete finished runs +- [draft prune](#draft-prune) — bulk-delete non-running runs in the current project ### draft init @@ -170,7 +170,7 @@ Continue reuses the config file recorded on creation. There is no way to switch ### draft delete -Remove a single run's state directory and its linked git worktree. +Remove a single run's state directory and its linked git worktree. On success, the output line names the resolved project, for example `deleted run 260521-212506 (project: draft)`. ```shell draft delete @@ -183,13 +183,12 @@ draft delete ### draft prune -Bulk-delete finished runs. By default operates on the current project. +Bulk-delete every run in scope except those actively running. By default operates on the current project. ```shell draft prune draft prune --yes draft prune --dry-run -draft prune --all draft prune --project NAME draft prune --all-projects draft prune --delete-branch @@ -199,7 +198,6 @@ draft prune --delete-branch - `--yes`, `-y` — skip the confirmation prompt - `--dry-run` — print the selection and exit without deleting -- `--all` — include every non-active run regardless of finished status (not only successful ones) - `--project NAME` — operate on the named project instead of the current one - `--all-projects` — operate across every project under `~/.draft/runs/`; mutually exclusive with `--project` - `--delete-branch` — also delete the local git branch for each pruned run diff --git a/src/draft/command_delete.py b/src/draft/command_delete.py index 8d8df54..f915ced 100644 --- a/src/draft/command_delete.py +++ b/src/draft/command_delete.py @@ -43,5 +43,5 @@ def run(args) -> int: if result["branch_deleted"]: print(f"deleted branch {result['branch']}") - print(f"deleted run {args.run_id}") + print(f"deleted run {args.run_id} (project: {run_dir.parent.name})") return 0 diff --git a/src/draft/command_prune.py b/src/draft/command_prune.py index 90c6240..ebe890d 100644 --- a/src/draft/command_prune.py +++ b/src/draft/command_prune.py @@ -4,12 +4,15 @@ from draft import runs +_STATUS_ORDER = {"done": 0, "stopped": 1, "missing": 2, "corrupt": 3} + def register(subparsers): p = subparsers.add_parser( "prune", - help="Bulk-delete finished runs.", - description="Bulk-delete successfully finished runs. By default operates on the current project. See also: draft delete.", + help="Bulk-delete non-running runs in the current project.", + description="Bulk-delete every run in scope except those actively running. By default operates on the current project. See also: draft delete.", + allow_abbrev=False, ) p.add_argument( "--yes", "-y", action="store_true", help="Skip the confirmation prompt." @@ -19,12 +22,6 @@ def register(subparsers): action="store_true", help="Print the selection and exit without deleting.", ) - p.add_argument( - "--all", - dest="include_all", - action="store_true", - help="Include every non-active run regardless of finished status.", - ) p.add_argument( "--project", metavar="NAME", @@ -76,37 +73,43 @@ def _resolve_project_scope(args) -> list[Path] | int: return runs.project_runs(name) -def _build_selection(candidates, *, include_all): - from draft.pipelines import CorruptStateError - +def _build_selection(candidates): selection = [] active = [] for run_dir in candidates: - if runs.is_run_active(run_dir): + status = runs.classify_run(run_dir) + if status == "running": active.append(run_dir) continue state = runs.load_state(run_dir) - try: - finished = state is not None and runs.is_run_finished(state) - except CorruptStateError: - finished = True - if include_all or finished: - selection.append((run_dir, state)) - - selection.sort(key=lambda x: x[0].name, reverse=True) + selection.append((run_dir, state, status)) + + selection.sort(key=lambda t: t[0].name, reverse=True) + selection.sort(key=lambda t: _STATUS_ORDER[t[2]]) active.sort(key=lambda d: d.name, reverse=True) return selection, active def _print_selection(selection): print("runs to delete:") - for run_dir, state in selection: + for run_dir, state, status in selection: branch = "" if state is not None: b = state.get("data", {}).get("branch") if b: branch = b - print(f" {run_dir.name} {branch}") + print(f" {run_dir.name} {status:<7} {branch}") + + +def _count_non_running_in_other_projects(current: str | None) -> int: + count = 0 + for name in runs.all_project_names(): + if name == current: + continue + for run_dir in runs.project_runs(name): + if runs.classify_run(run_dir) != "running": + count += 1 + return count def _confirm() -> bool: @@ -121,12 +124,19 @@ def run(args) -> int: if isinstance(scope, int): return scope - selection, active = _build_selection(scope, include_all=args.include_all) + selection, active = _build_selection(scope) if not selection: - print("no runs to prune") - if active: - print(f"skipped {len(active)} active") + if not getattr(args, "project", None) and not getattr( + args, "all_projects", False + ): + current = runs.current_project_name() + other = _count_non_running_in_other_projects(current) + if other > 0: + print( + f"{other} non-running run(s) in other projects; pass --all-projects to include them" + ) + print(f"deleted 0; skipped {len(active)} active") return 0 _print_selection(selection) @@ -147,7 +157,7 @@ def run(args) -> int: return 0 n_deleted = 0 - for run_dir, _ in selection: + for run_dir, _, _ in selection: project = run_dir.parent.name result = runs.delete_run(run_dir, delete_branch=args.delete_branch) for warning in result["warnings"]: @@ -164,7 +174,5 @@ def run(args) -> int: pid = int(pid_file.read_text().strip()) print(f"skipped active run {run_dir.name} (pid {pid})") - print( - f"deleted {n_deleted}; skipped {len(active)} active; selected {len(selection)}" - ) + print(f"deleted {n_deleted}; skipped {len(active)} active") return 0 diff --git a/src/draft/runs.py b/src/draft/runs.py index b4bab56..703fcfd 100644 --- a/src/draft/runs.py +++ b/src/draft/runs.py @@ -126,6 +126,24 @@ def is_run_finished(state: dict) -> bool: return all(s in state.get("completed", []) for s in expected_steps(state)) +def classify_run(run_dir: Path) -> str: + if is_run_active(run_dir): + return "running" + state_path = run_dir / "state.json" + if not state_path.exists(): + return "missing" + try: + state = json.loads(state_path.read_text()) + except Exception: + return "corrupt" + from draft.pipelines import CorruptStateError + + try: + return "done" if is_run_finished(state) else "stopped" + except CorruptStateError: + return "corrupt" + + def find_original_run_on_branch(project: str, branch: str) -> Path | None: candidates = [] for run_dir in project_runs(project): diff --git a/tests/draft/test_commands.py b/tests/draft/test_commands.py index 756358e..afe90a4 100644 --- a/tests/draft/test_commands.py +++ b/tests/draft/test_commands.py @@ -347,6 +347,7 @@ class FakeArgs: assert branch_calls[0].kwargs.get("cwd") == str(repo_dir) captured = capsys.readouterr() assert "deleted branch draft/feature-x" in captured.out + assert "(project: myproject)" in captured.out def test_command_delete_without_flag_skips_branch_deletion(tmp_path, capsys): @@ -385,6 +386,8 @@ class FakeArgs: if call.args and call.args[0][:3] == ["git", "branch", "-D"] ] assert branch_calls == [] + captured = capsys.readouterr() + assert "(project: myproject)" in captured.out # --- command_continue --- @@ -628,7 +631,6 @@ def _make_prune_args(**kwargs): class FakeArgs: yes = False dry_run = False - include_all = False project = None all_projects = False delete_branch = False @@ -664,11 +666,12 @@ def test_prune_no_runs(tmp_path, capsys): with ( patch("draft.runs.current_project_name", return_value="myproject"), patch("draft.runs.project_runs", return_value=[]), + patch("draft.runs.all_project_names", return_value=[]), ): result = cp.run(_make_prune_args()) assert result == 0 - assert "no runs to prune" in capsys.readouterr().out + assert "deleted 0; skipped 0 active" in capsys.readouterr().out def test_prune_one_finished_one_in_progress_default_selection( @@ -691,7 +694,9 @@ def test_prune_one_finished_one_in_progress_default_selection( captured = capsys.readouterr() assert "260505-130000" in captured.out - assert "260505-120000" not in captured.out + assert "260505-120000" in captured.out + assert "done" in captured.out + assert "stopped" in captured.out def test_prune_confirmation_declined(tmp_path, capsys, monkeypatch): @@ -728,7 +733,7 @@ def test_prune_yes_deletes_run(tmp_path, capsys): assert result == 0 assert not run_done.exists() captured = capsys.readouterr() - assert "deleted 1" in captured.out + assert "deleted 1; skipped 0 active" in captured.out def test_prune_dry_run_no_deletion(tmp_path, capsys): @@ -748,7 +753,7 @@ def test_prune_dry_run_no_deletion(tmp_path, capsys): assert "would" in captured.out -def test_prune_all_includes_incomplete(tmp_path, capsys): +def test_prune_default_includes_incomplete(tmp_path, capsys): import draft.command_prune as cp run_partial = _make_run_dir( @@ -759,14 +764,14 @@ def test_prune_all_includes_incomplete(tmp_path, capsys): patch("draft.runs.current_project_name", return_value="myproject"), patch("draft.runs.project_runs", return_value=[run_partial]), ): - result = cp.run(_make_prune_args(include_all=True, dry_run=True)) + result = cp.run(_make_prune_args(dry_run=True)) assert result == 0 captured = capsys.readouterr() assert "260505-120000" in captured.out -def test_prune_all_skips_active(tmp_path, capsys): +def test_prune_default_skips_active(tmp_path, capsys): import draft.command_prune as cp run_active = _make_run_dir(tmp_path, run_id="260505-120000", state=_partial_state()) @@ -775,8 +780,9 @@ def test_prune_all_skips_active(tmp_path, capsys): with ( patch("draft.runs.current_project_name", return_value="myproject"), patch("draft.runs.project_runs", return_value=[run_active]), + patch("draft.runs.all_project_names", return_value=[]), ): - result = cp.run(_make_prune_args(include_all=True, yes=True)) + result = cp.run(_make_prune_args(yes=True)) assert result == 0 assert run_active.exists() @@ -909,6 +915,114 @@ def test_prune_skip_pr_finished_included(tmp_path, capsys): assert "260505-130000" in captured.out +def test_prune_status_grouping_order(tmp_path, capsys): + import draft.command_prune as cp + + run_done = _make_run_dir(tmp_path, run_id="260505-140000", state=_full_state()) + run_stopped = _make_run_dir( + tmp_path, run_id="260505-130000", state=_partial_state() + ) + run_missing = _make_run_dir(tmp_path, run_id="260505-120000") + run_corrupt = _make_run_dir(tmp_path, run_id="260505-110000", state=None) + (run_corrupt / "state.json").write_text("not valid json {{{") + run_active = _make_run_dir(tmp_path, run_id="260505-100000", state=_partial_state()) + (run_active / "draft.pid").write_text(str(os.getpid())) + + with ( + patch("draft.runs.current_project_name", return_value="myproject"), + patch( + "draft.runs.project_runs", + return_value=[run_done, run_stopped, run_missing, run_corrupt, run_active], + ), + ): + result = cp.run(_make_prune_args(dry_run=True)) + + assert result == 0 + out = capsys.readouterr().out + pos_done = out.index("260505-140000") + pos_stopped = out.index("260505-130000") + pos_missing = out.index("260505-120000") + pos_corrupt = out.index("260505-110000") + assert pos_done < pos_stopped < pos_missing < pos_corrupt + assert "260505-100000" not in out + assert "would skip 1 active" in out + + +def test_prune_all_projects_hint_fires(tmp_path, capsys): + import draft.command_prune as cp + + other_run = _make_run_dir( + tmp_path, run_id="260505-120000", project="other", state=_partial_state() + ) + + with ( + patch("draft.runs.current_project_name", return_value="myproject"), + patch( + "draft.runs.project_runs", + side_effect=lambda name: [] if name == "myproject" else [other_run], + ), + patch("draft.runs.all_project_names", return_value=["myproject", "other"]), + ): + result = cp.run(_make_prune_args()) + + assert result == 0 + out = capsys.readouterr().out + assert "non-running run(s) in other projects" in out + assert "--all-projects" in out + assert "deleted 0; skipped 0 active" in out + + +def test_prune_all_projects_hint_suppressed_under_project_flag(tmp_path, capsys): + import draft.command_prune as cp + + (tmp_path / "myproject").mkdir() + + with ( + patch("draft.runs.runs_base", return_value=tmp_path), + patch("draft.runs.project_runs", return_value=[]), + patch("draft.runs.all_project_names", return_value=["myproject", "other"]), + ): + result = cp.run(_make_prune_args(project="myproject")) + + assert result == 0 + out = capsys.readouterr().out + assert "non-running run(s) in other projects" not in out + + +def test_prune_status_column_in_selection_line(tmp_path, capsys): + import re + + import draft.command_prune as cp + + run_stopped = _make_run_dir( + tmp_path, run_id="260505-120000", state=_partial_state() + ) + + with ( + patch("draft.runs.current_project_name", return_value="myproject"), + patch("draft.runs.project_runs", return_value=[run_stopped]), + ): + cp.run(_make_prune_args(dry_run=True)) + + out = capsys.readouterr().out + assert re.search(r"\b260505-120000\s+stopped\s+draft/feat\b", out) + + +def test_prune_all_flag_rejected(capsys): + import argparse + + import draft.command_prune as cp + + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers() + cp.register(subparsers) + + with pytest.raises(SystemExit) as exc_info: + parser.parse_args(["prune", "--all"]) + + assert exc_info.value.code != 0 + + # --- create-modes: flag conflicts ---