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 ---