Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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...]
Expand Down Expand Up @@ -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

Expand Down
19 changes: 18 additions & 1 deletion man/workforest.1
Original file line number Diff line number Diff line change
Expand Up @@ -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 .\|.\|.
Expand Down Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions src/workforest/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"])
Expand Down
50 changes: 41 additions & 9 deletions src/workforest/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from pathlib import Path
from typing import Any

import yaml

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"))
Expand Down
10 changes: 10 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
32 changes: 32 additions & 0 deletions tests/test_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading