diff --git a/README.md b/README.md index e0c14d7..8338359 100644 --- a/README.md +++ b/README.md @@ -337,7 +337,7 @@ scripts: ``` workforest create [BRANCH] [-o OPENER] [-w WRAPPER] [-p PATH] [--no-hooks] [--no-open] workforest open [NAME] [-o OPENER] [-w WRAPPER] [-p PATH] -workforest list [--porcelain] +workforest list [--porcelain | --json] workforest delete NAME... [--force] [--delete-branch | --keep-branch] workforest checkout NAME [--force] workforest run [-b] SCRIPT [ARGS...] @@ -365,7 +365,11 @@ which is not a stable interface, so any Claude Code update may break it. Exit codes: `0` ok · `1` error · `2` usage · `3` cancelled · `4` config error · `128+N` the `run` command was killed by signal N. Human messages go to stderr; stdout carries only machine output (`cd` -directives for the `wf` wrapper, `--porcelain` listings, dumps). +directives for the `wf` wrapper, `--porcelain`/`--json` listings, dumps). +`list --json` describes the whole forest for programs — `main` (the main +checkout, in the same `name`/`branch`/`path`/`dirty` shape as each entry of +`worktrees`) and the resolved `worktrees_dir` — for editor integrations +and other programs. ## Development diff --git a/man/workforest.1 b/man/workforest.1 index 15d2626..6048611 100644 --- a/man/workforest.1 +++ b/man/workforest.1 @@ -31,7 +31,7 @@ workforest, wf \- git worktree forest management .IR PATH ] .YS .SY "workforest list" -.RB [ \-\-porcelain ] +.RB [ \-\-porcelain " | " \-\-json ] .YS .SY "workforest delete" .IR NAME .\|.\|. @@ -188,6 +188,23 @@ and (dirty) or .B 0 (clean), separated by tabs. +.TP +.B \-\-json +The whole forest as a JSON object, for editor integrations and other +programs: +.B main +(the main checkout) and each entry of +.B worktrees +carry +.BR name , +.B branch +(null when detached), +.BR path , +and +.B dirty +(true/false); +.B worktrees_dir +is the resolved worktrees directory. .SS delete \fINAME\fR... Delete one or more worktrees. Every name is resolved before anything is removed, so a typo fails the diff --git a/src/workforest/cli.py b/src/workforest/cli.py index 53ff258..e508a21 100644 --- a/src/workforest/cli.py +++ b/src/workforest/cli.py @@ -83,7 +83,7 @@ def _handle_open(ns: argparse.Namespace) -> CommandResult: def _handle_list(ns: argparse.Namespace) -> CommandResult: ctx = commands.build_context() - return commands.cmd_list(ctx, porcelain=ns.porcelain) + return commands.cmd_list(ctx, porcelain=ns.porcelain, as_json=ns.json) def _handle_delete(ns: argparse.Namespace) -> CommandResult: @@ -177,7 +177,11 @@ def opener_args(p: argparse.ArgumentParser) -> None: p.set_defaults(func=_handle_open) p = sub.add_parser("list", help=SUBCOMMAND_HELP["list"]) - p.add_argument("--porcelain", action="store_true", help="stable tab-separated output") + group = p.add_mutually_exclusive_group() + group.add_argument("--porcelain", action="store_true", help="stable tab-separated output") + group.add_argument( + "--json", action="store_true", help="the whole forest (main checkout included) as JSON" + ) p.set_defaults(func=_handle_list) p = sub.add_parser("delete", help=SUBCOMMAND_HELP["delete"]) diff --git a/src/workforest/commands.py b/src/workforest/commands.py index b16a7f0..435e8bd 100644 --- a/src/workforest/commands.py +++ b/src/workforest/commands.py @@ -6,6 +6,7 @@ from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass from pathlib import Path +from typing import Any import yaml @@ -42,14 +43,14 @@ def build_context(cwd: Path | None = None) -> Context: return Context(cwd_root=cwd_root, main=main, config=config, worktrees_dir=worktrees_dir) +def _is_managed(ctx: Context, worktree: gitutil.Worktree) -> bool: + return not worktree.is_main and worktree.path.parent == ctx.worktrees_dir + + def managed_worktrees(ctx: Context) -> list[gitutil.Worktree]: """Worktrees located directly inside the resolved worktrees dir — the only ones we list, complete, or delete.""" - return [ - worktree - for worktree in gitutil.list_worktrees(ctx.main) - if not worktree.is_main and worktree.path.parent == ctx.worktrees_dir - ] + return [w for w in gitutil.list_worktrees(ctx.main) if _is_managed(ctx, w)] def find_managed(ctx: Context, name: str) -> gitutil.Worktree: @@ -198,16 +199,47 @@ def cmd_open( ) -def cmd_list(ctx: Context, *, porcelain: bool = False) -> CommandResult: +def _dirty_flags(worktrees: list[gitutil.Worktree]) -> list[bool]: + """One `git status` per worktree; subprocess-bound, so run them together.""" + if not worktrees: + return [] + with ThreadPoolExecutor(max_workers=min(8, len(worktrees))) as pool: + return list(pool.map(lambda w: bool(gitutil.status_porcelain(w.path)), worktrees)) + + +def _worktree_json(worktree: gitutil.Worktree, dirty: bool) -> dict[str, Any]: + return { + "name": worktree.name, + "branch": worktree.branch, # null when detached + "path": str(worktree.path), + "dirty": dirty, + } + + +def _list_json(ctx: Context) -> str: + """The whole forest for programs (editor integrations): the main + checkout in the same shape as the worktrees, plus where they live.""" + everything = gitutil.list_worktrees(ctx.main) + main, worktrees = everything[0], [w for w in everything if _is_managed(ctx, w)] + dirty = _dirty_flags([main, *worktrees]) + data = { + "main": _worktree_json(main, dirty[0]), + "worktrees_dir": str(ctx.worktrees_dir), + "worktrees": [_worktree_json(w, d) for w, d in zip(worktrees, dirty[1:], strict=True)], + } + return json.dumps(data, indent=2) + + +def cmd_list(ctx: Context, *, porcelain: bool = False, as_json: bool = False) -> CommandResult: + if as_json: + return _list_json(ctx) worktrees = managed_worktrees(ctx) if not worktrees: if porcelain: return "" output.info(f"no worktrees in {ctx.worktrees_dir} (create one with: wf create BRANCH)") return None - # One `git status` per worktree; subprocess-bound, so run them together. - with ThreadPoolExecutor(max_workers=min(8, len(worktrees))) as pool: - dirty = list(pool.map(lambda w: bool(gitutil.status_porcelain(w.path)), worktrees)) + dirty = _dirty_flags(worktrees) if porcelain: return "\n".join( "\t".join((w.name, w.branch or "", str(w.path), "1" if is_dirty else "0")) diff --git a/tests/test_cli.py b/tests/test_cli.py index 8396f1f..28cf239 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -63,6 +63,16 @@ def test_list_porcelain_on_stdout(self, run_cli: Run, repo: Repo) -> None: assert (name, branch, dirty) == ("feat", "feat", "0") assert path.endswith("worktrees/api/feat") + def test_list_json_on_stdout(self, run_cli: Run, repo: Repo) -> None: + import json + + result = run_cli("list", "--json", cwd=repo.path) + assert result.code == 0 + assert json.loads(result.out)["main"]["path"] == str(repo.path) + + def test_list_formats_are_exclusive(self, run_cli: Run, repo: Repo) -> None: + assert run_cli("list", "--json", "--porcelain", cwd=repo.path).code == 2 + def test_checkout_emits_cd_to_main(self, run_cli: Run, repo: Repo) -> None: run_cli("create", "feat", "--no-open", cwd=repo.path) result = run_cli("checkout", "feat", cwd=repo.path) diff --git a/tests/test_commands.py b/tests/test_commands.py index 138442e..06e69d8 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -245,6 +245,38 @@ def test_empty_forest(self, repo: Repo) -> None: assert commands.cmd_list(ctx_for(repo)) is None assert commands.cmd_list(ctx_for(repo), porcelain=True) == "" + def test_json_covers_the_whole_forest(self, repo: Repo) -> None: + import json + + ctx = ctx_for(repo) + commands.cmd_create(ctx, "feature/one", no_open=True) + repo.make_dirty() # the main checkout + out = commands.cmd_list(ctx, as_json=True) + assert isinstance(out, str) + data = json.loads(out) + assert data["main"] == { + "name": "api", + "branch": "main", + "path": str(repo.path), + "dirty": True, + } + assert data["worktrees_dir"] == str(ctx.worktrees_dir) + assert data["worktrees"] == [ + { + "name": "one", + "branch": "feature/one", + "path": str(ctx.worktrees_dir / "one"), + "dirty": False, + } + ] + + def test_json_of_empty_forest_still_describes_main(self, repo: Repo) -> None: + import json + + data = json.loads(commands.cmd_list(ctx_for(repo), as_json=True) or "") + assert data["worktrees"] == [] + assert data["main"]["path"] == str(repo.path) + class TestDelete: def test_clean_delete_keeps_branch_off_tty(self, repo: Repo) -> None: