treebox 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
treebox/cli.py ADDED
@@ -0,0 +1,1061 @@
1
+ """Typer CLI: create / enter / list / teardown / doctor.
2
+
3
+ Conventions: data to stdout, diagnostics to stderr, exit 0 on success. ``--json``
4
+ gives machine output; ``--print`` emits the copy-pasteable launch command; both
5
+ provision without launching the agent (handy over SSH / for scripting).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import shlex
12
+ import signal
13
+ import sys
14
+ import time
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ import typer
19
+
20
+ from . import __version__, git, locking, names, provision, resolve, state, system
21
+ from .config import VALID_RUNNERS, VALID_TOOLS, Config, load_config
22
+ from .models import (
23
+ Worktree,
24
+ derive_name,
25
+ is_placeholder,
26
+ is_slug,
27
+ placeholder_branch,
28
+ worktree_path,
29
+ worktree_root,
30
+ )
31
+ from .output import Reporter, StepError, format_elapsed
32
+ from .runners import PreflightError, Runner, get_runner
33
+
34
+ # Stable, documented exit codes (see the epilog on `--help`). Agents branch on these.
35
+ EXIT_OK = 0
36
+ EXIT_ERROR = 1 # generic / unexpected failure
37
+ EXIT_USAGE = 2 # bad invocation (invalid name/branch, ambiguous ref, bad option)
38
+ EXIT_NOTFOUND = 3 # the worktree/branch the command needs does not exist
39
+ EXIT_PERMISSION = 4 # auth / fetch / credential problem
40
+ EXIT_CONFLICT = 5 # already exists / uncommitted changes / lock held
41
+
42
+ # Current JSON schema version. Payloads only gain fields after this (porcelain
43
+ # discipline); bumps are reserved for breaking changes. v2: branchless create —
44
+ # create/enter/list gained "name"; teardown became variadic and its payload is
45
+ # now a "worktrees" array instead of one flat record.
46
+ SCHEMA_VERSION = 2
47
+
48
+ _EPILOG = (
49
+ "Exit codes: 0 ok · 2 usage · 3 not-found · 4 auth/permission "
50
+ "· 5 conflict (exists/locked/dirty)."
51
+ )
52
+
53
+ app = typer.Typer(
54
+ add_completion=True,
55
+ no_args_is_help=True,
56
+ help="Isolated, ready-to-run git worktrees for AI coding agents "
57
+ "— host-native or docker-sandboxed.",
58
+ epilog=_EPILOG,
59
+ context_settings={"help_option_names": ["-h", "--help"]},
60
+ )
61
+
62
+
63
+ # --- shared helpers ----------------------------------------------------------
64
+
65
+
66
+ def _emit_json(payload: dict, *, stream=None) -> None:
67
+ """The one place --json serialization is defined: pretty-printed, trailing
68
+ newline — identical for success payloads (stdout) and error payloads (stderr)."""
69
+ (stream or sys.stdout).write(json.dumps(payload, indent=2) + "\n")
70
+
71
+
72
+ def _die(
73
+ reporter: Reporter,
74
+ message: str,
75
+ *,
76
+ code: int = EXIT_ERROR,
77
+ error_code: str = "ERROR",
78
+ hint: str | None = None,
79
+ path: str | None = None,
80
+ json_out: bool = False,
81
+ ) -> typer.Exit:
82
+ """Report a failure and return a typer.Exit carrying the right exit code.
83
+
84
+ In ``--json`` mode the error is emitted as a structured object on stderr so
85
+ agents can branch on ``error.code``; otherwise it's a styled message (+ hint).
86
+ """
87
+ if json_out:
88
+ error: dict[str, str] = {"code": error_code, "message": message}
89
+ if hint:
90
+ error["hint"] = hint
91
+ if path:
92
+ error["path"] = path
93
+ payload = {"schemaVersion": SCHEMA_VERSION, "error": error}
94
+ _emit_json(payload, stream=sys.stderr)
95
+ else:
96
+ reporter.error(message)
97
+ if hint:
98
+ reporter.hint(hint)
99
+ return typer.Exit(code)
100
+
101
+
102
+ def _resolve_config(
103
+ reporter: Reporter,
104
+ *,
105
+ runner: str | None,
106
+ tool: str | None,
107
+ base: str | None,
108
+ root: str | None,
109
+ firewall: bool | None,
110
+ template: str | None = None,
111
+ json_out: bool = False,
112
+ ) -> Config:
113
+ try:
114
+ cfg = load_config()
115
+ except ValueError as exc:
116
+ # A malformed/invalid user config.toml is a usage problem, not a crash:
117
+ # exit 2 with a styled message — or a structured error in --json mode.
118
+ raise _die(
119
+ reporter,
120
+ str(exc),
121
+ code=EXIT_USAGE,
122
+ error_code="INVALID_CONFIG",
123
+ hint="Fix the config file (or unset $TREEBOX_CONFIG).",
124
+ json_out=json_out,
125
+ ) from exc
126
+ return cfg.with_overrides(
127
+ runner=runner,
128
+ tool=tool,
129
+ base=base,
130
+ root=root,
131
+ firewall=firewall,
132
+ template=template,
133
+ )
134
+
135
+
136
+ def _runner_from_state(
137
+ reporter: Reporter,
138
+ cfg: Config,
139
+ st: state.WorktreeState | None,
140
+ *,
141
+ explicit: str | None,
142
+ json_out: bool = False,
143
+ ) -> Config:
144
+ """Prefer the runner this worktree was provisioned with.
145
+
146
+ The sandbox/no-sandbox decision is recorded at create time; falling back to
147
+ the config default would silently enter a docker-sandboxed worktree on the
148
+ host (or leak its container on teardown). An explicit ``--runner`` that
149
+ disagrees with the recorded one is a conflict, not an override.
150
+ """
151
+ recorded = st.runner if st else ""
152
+ if not recorded:
153
+ return cfg
154
+ if recorded not in VALID_RUNNERS:
155
+ raise _die(
156
+ reporter,
157
+ f"Worktree was provisioned with unknown runner '{recorded}'.",
158
+ code=EXIT_CONFLICT,
159
+ error_code="UNKNOWN_RUNNER",
160
+ hint="This worktree predates the current runners. Remove it manually "
161
+ "(git worktree remove; docker rm any leftover container) and re-create it.",
162
+ json_out=json_out,
163
+ )
164
+ if explicit is None:
165
+ return cfg.with_overrides(runner=recorded)
166
+ if explicit != recorded:
167
+ raise _die(
168
+ reporter,
169
+ f"Worktree was provisioned with the '{recorded}' runner, "
170
+ f"but --runner {explicit} was given.",
171
+ code=EXIT_CONFLICT,
172
+ error_code="RUNNER_MISMATCH",
173
+ hint=f"Drop --runner to use the recorded '{recorded}' runner, or tear "
174
+ "down and re-create the worktree with the new one.",
175
+ json_out=json_out,
176
+ )
177
+ return cfg
178
+
179
+
180
+ def _repo_root(reporter: Reporter, repo: str, *, json_out: bool = False) -> str:
181
+ try:
182
+ return git.repo_root(repo)
183
+ except git.GitError as exc:
184
+ raise _die(
185
+ reporter,
186
+ str(exc),
187
+ code=EXIT_USAGE,
188
+ error_code="NOT_A_REPO",
189
+ hint="Run inside a git repo, or pass --repo <path>.",
190
+ json_out=json_out,
191
+ ) from exc
192
+
193
+
194
+ def _validate_branch(reporter: Reporter, branch: str, *, json_out: bool = False) -> None:
195
+ if not git.check_ref_format(branch):
196
+ raise _die(
197
+ reporter,
198
+ f"Invalid branch name '{branch}'.",
199
+ code=EXIT_USAGE,
200
+ error_code="INVALID_BRANCH",
201
+ json_out=json_out,
202
+ )
203
+
204
+
205
+ def _validate_name(reporter: Reporter, name: str, *, json_out: bool = False) -> None:
206
+ if not is_slug(name):
207
+ raise _die(
208
+ reporter,
209
+ f"Invalid worktree name '{name}'.",
210
+ code=EXIT_USAGE,
211
+ error_code="INVALID_NAME",
212
+ hint="One lowercase slug token (letters, digits, hyphens), e.g. fix-auth — "
213
+ "or omit it for a generated name.",
214
+ json_out=json_out,
215
+ )
216
+
217
+
218
+ def _name_taken(repo_path: str, root: str, name: str) -> bool:
219
+ """Whether a *generated* name is unusable: its directory or its placeholder
220
+ branch (local or on origin) already exists."""
221
+ if worktree_path(repo_path, root, name).exists():
222
+ return True
223
+ ph = placeholder_branch(name)
224
+ return git.local_branch_exists(repo_path, ph) or git.remote_branch_exists(repo_path, ph)
225
+
226
+
227
+ # Exceptions provisioning can raise, mapped to exit codes by _handle().
228
+ _PROVISION_ERRORS = (
229
+ provision.ProvisionError,
230
+ locking.LockError,
231
+ git.GitError,
232
+ StepError,
233
+ RuntimeError,
234
+ )
235
+
236
+
237
+ def _classify(exc: Exception) -> tuple[int, str, str | None]:
238
+ """Map a provisioning exception to (exit_code, error_code, hint)."""
239
+ if isinstance(exc, locking.LockError):
240
+ return (
241
+ EXIT_CONFLICT,
242
+ "LOCK_HELD",
243
+ "Another run holds this worktree — wait, then retry.",
244
+ )
245
+ if isinstance(exc, git.FetchError):
246
+ msg = str(exc).lower()
247
+ if "publickey" in msg or "permission denied" in msg:
248
+ hint = (
249
+ "Git auth failed. Authenticate once and re-run: `gh auth login` "
250
+ "(GitHub), `glab auth login` (GitLab), or a git credential helper / "
251
+ "HTTPS token (Bitbucket, Azure, others). Or load an SSH key "
252
+ '(eval "$(ssh-agent -s)"; ssh-add), or pass --no-fetch for stale refs.'
253
+ )
254
+ else:
255
+ hint = (
256
+ "Make origin reachable: authenticate once (`gh auth login`, "
257
+ "`glab auth login`, or a git credential helper), or pass --no-fetch."
258
+ )
259
+ return EXIT_PERMISSION, "FETCH_FAILED", hint
260
+ if isinstance(exc, provision.SlugConflictError):
261
+ return EXIT_CONFLICT, "SLUG_CONFLICT", exc.hint
262
+ if isinstance(exc, resolve.AmbiguousRefError):
263
+ return EXIT_USAGE, "AMBIGUOUS_REF", exc.hint
264
+ if isinstance(exc, provision.NotFoundError):
265
+ return EXIT_NOTFOUND, "NOT_FOUND", exc.hint
266
+ if isinstance(exc, PreflightError):
267
+ # Runner dependency problems keep exit 1 (codes are stable; agents
268
+ # branch on error.code instead: MISSING_DEPENDENCY, DOCKER_UNAVAILABLE).
269
+ return EXIT_ERROR, exc.error_code, exc.hint
270
+ return EXIT_ERROR, "ERROR", None
271
+
272
+
273
+ def _handle(reporter: Reporter, exc: Exception, *, json_out: bool) -> typer.Exit:
274
+ reporter.restore_terminal()
275
+ code, error_code, hint = _classify(exc)
276
+ return _die(
277
+ reporter,
278
+ str(exc),
279
+ code=code,
280
+ error_code=error_code,
281
+ hint=hint,
282
+ json_out=json_out,
283
+ )
284
+
285
+
286
+ def _short_path(path: Path | str, repo: str) -> str:
287
+ """A compact, readable path: relative to the repo when possible, else
288
+ home-collapsed — so status rows stay on one tidy line."""
289
+ p = Path(path)
290
+ try:
291
+ return str(p.relative_to(repo))
292
+ except ValueError:
293
+ pass
294
+ try:
295
+ return "~/" + str(p.relative_to(Path.home()))
296
+ except ValueError:
297
+ return str(p)
298
+
299
+
300
+ def _emit_result(outcome: provision.Outcome, *, json_out: bool, print_only: bool) -> None:
301
+ """Write the machine/script-facing result to stdout."""
302
+ if json_out:
303
+ payload = {
304
+ "schemaVersion": SCHEMA_VERSION,
305
+ "name": outcome.worktree.name,
306
+ "worktree_path": str(outcome.worktree.path),
307
+ "branch": outcome.worktree.branch,
308
+ "base": outcome.worktree.base,
309
+ "entry_command": outcome.entry_command,
310
+ "created": outcome.created,
311
+ }
312
+ _emit_json(payload)
313
+ elif print_only:
314
+ sys.stdout.write(" ".join(shlex.quote(p) for p in outcome.entry_command) + "\n")
315
+
316
+
317
+ def _dry_run(
318
+ reporter: Reporter,
319
+ cfg: Config,
320
+ run: Runner,
321
+ repo_path: str,
322
+ name: str,
323
+ branch: str,
324
+ *,
325
+ fetch: bool,
326
+ json_out: bool,
327
+ ) -> None:
328
+ """Render what ``create`` would do, executing nothing."""
329
+ try:
330
+ wt, cmds = provision.dry_run_plan(
331
+ cfg, run, repo=repo_path, name=name, branch=branch, base=cfg.base, fetch=fetch
332
+ )
333
+ except _PROVISION_ERRORS as exc:
334
+ raise _handle(reporter, exc, json_out=json_out) from exc
335
+
336
+ if json_out:
337
+ _emit_json(
338
+ {
339
+ "schemaVersion": SCHEMA_VERSION,
340
+ "dry_run": True,
341
+ "name": name,
342
+ "worktree_path": str(wt.path),
343
+ "branch": branch,
344
+ "commands": cmds,
345
+ }
346
+ )
347
+ return
348
+
349
+ reporter.heading("create", f"{name} · dry run")
350
+ reporter.summary("worktree", _short_path(wt.path, repo_path))
351
+ reporter.summary("branch", branch)
352
+ reporter.summary("runner", f"{cfg.runner} → {cfg.tool}")
353
+ reporter.blank()
354
+ for cmd in cmds:
355
+ reporter.command(cmd)
356
+ reporter.blank()
357
+
358
+
359
+ # --- create ------------------------------------------------------------------
360
+
361
+
362
+ @app.command()
363
+ def create(
364
+ name: str | None = typer.Argument(
365
+ None,
366
+ help=(
367
+ "Worktree name: one slug token (lowercase letters, digits, hyphens). "
368
+ "Omitted: a generated petname."
369
+ ),
370
+ ),
371
+ branch: str | None = typer.Option(
372
+ None,
373
+ "--branch",
374
+ "-b",
375
+ help="Check out this exact existing branch (local or origin) — the only "
376
+ "path that skips the treebox/ placeholder.",
377
+ ),
378
+ repo: str = typer.Option(".", "--repo", help="Git repo to create from. Default: current repo."),
379
+ root: str | None = typer.Option(None, "--root", help="Worktree root dir."),
380
+ base: str | None = typer.Option(
381
+ None, "--base", help="Base branch for the new branch (resolved as origin/<base>)."
382
+ ),
383
+ runner: str | None = typer.Option(
384
+ None, "--runner", help=f"Run seam: {'|'.join(VALID_RUNNERS)}."
385
+ ),
386
+ tool: str | None = typer.Option(
387
+ None, "--tool", help=f"Agent to launch: {'|'.join(VALID_TOOLS)}."
388
+ ),
389
+ cold: bool = typer.Option(
390
+ False, "--cold", help="Bypass shared caches for a from-source build."
391
+ ),
392
+ no_fetch: bool = typer.Option(
393
+ False,
394
+ "--no-fetch",
395
+ help="Opt out of the required origin fetch and accept (possibly stale) local refs.",
396
+ ),
397
+ firewall: bool = typer.Option(
398
+ False, "--firewall", help="Enable the container firewall (docker runner)."
399
+ ),
400
+ template: str | None = typer.Option(
401
+ None,
402
+ "--template",
403
+ help="Operator-owned sandbox template name (docker runner). "
404
+ "Resolved from $TREEBOX_TEMPLATE_DIR or ~/.config/treebox/templates/<name>.",
405
+ ),
406
+ dry_run: bool = typer.Option(
407
+ False,
408
+ "--dry-run",
409
+ help="Print the git/runner commands that would run; change nothing.",
410
+ ),
411
+ print_only: bool = typer.Option(
412
+ False, "--print", help="Provision, then print the launch command (no launch)."
413
+ ),
414
+ json_out: bool = typer.Option(
415
+ False, "--json", help="Provision, then print a JSON result (no launch)."
416
+ ),
417
+ quiet: bool = typer.Option(False, "--quiet", help="Suppress progress."),
418
+ verbose: bool = typer.Option(False, "--verbose", help="Stream raw command output."),
419
+ ) -> None:
420
+ """Provision a worktree and hand it to the runner.
421
+
422
+ Without -b the worktree starts on a treebox/<name> placeholder branch that a
423
+ pre-push guard keeps un-pushable: name the work when it takes shape
424
+ (git branch -m <type>/<short-name>, e.g. feature/user-auth, fix/login-race,
425
+ chore/bump-deps), then push.
426
+ """
427
+ reporter = Reporter(quiet=quiet, verbose=verbose, silent=json_out)
428
+ cfg = _resolve_config(
429
+ reporter,
430
+ runner=runner,
431
+ tool=tool,
432
+ base=base,
433
+ root=root,
434
+ firewall=firewall or None,
435
+ template=template,
436
+ json_out=json_out,
437
+ )
438
+ if name is not None:
439
+ _validate_name(reporter, name, json_out=json_out)
440
+ if branch is not None:
441
+ _validate_branch(reporter, branch, json_out=json_out)
442
+ repo_path = _repo_root(reporter, repo, json_out=json_out)
443
+
444
+ if branch is not None:
445
+ wt_name = name or derive_name(branch)
446
+ target_branch = branch
447
+ else:
448
+ wt_name = name or names.petname(lambda n: _name_taken(repo_path, cfg.root, n))
449
+ target_branch = placeholder_branch(wt_name)
450
+
451
+ run = get_runner(cfg)
452
+
453
+ if dry_run:
454
+ _dry_run(
455
+ reporter,
456
+ cfg,
457
+ run,
458
+ repo_path,
459
+ wt_name,
460
+ target_branch,
461
+ fetch=not no_fetch,
462
+ json_out=json_out,
463
+ )
464
+ return
465
+
466
+ reporter.heading("create", wt_name)
467
+ if branch is not None:
468
+ reporter.summary("branch", target_branch)
469
+ else:
470
+ reporter.summary("branch", f"{target_branch} · placeholder — rename before push")
471
+ reporter.summary("base", cfg.base)
472
+ reporter.summary("runner", f"{cfg.runner} → {cfg.tool}")
473
+ if cold:
474
+ reporter.summary("cache", "cold (from source)")
475
+ reporter.blank()
476
+
477
+ started = time.monotonic()
478
+ try:
479
+ # Runner-specific preconditions (a no-op for the host runner); fail
480
+ # before touching git state rather than mid-provision.
481
+ run.preflight(reporter)
482
+ with locking.worktree_lock(repo_path, cfg.root, wt_name):
483
+ outcome = provision.create(
484
+ cfg,
485
+ run,
486
+ repo=repo_path,
487
+ name=wt_name,
488
+ branch=target_branch,
489
+ base=cfg.base,
490
+ tool=cfg.tool,
491
+ cold=cold,
492
+ fetch=not no_fetch,
493
+ # Prompt for git credentials whenever a terminal is attached —
494
+ # like `git pull`. ssh's passphrase prompt uses the tty/stderr,
495
+ # so it stays out of the way of --json's stdout.
496
+ interactive=sys.stdin.isatty(),
497
+ reporter=reporter,
498
+ existing_branch=branch is not None,
499
+ )
500
+ except _PROVISION_ERRORS as exc:
501
+ raise _handle(reporter, exc, json_out=json_out) from exc
502
+
503
+ _finish(
504
+ reporter,
505
+ run,
506
+ cfg,
507
+ outcome,
508
+ json_out=json_out,
509
+ print_only=print_only,
510
+ args=[],
511
+ started=started,
512
+ )
513
+
514
+
515
+ # --- enter -------------------------------------------------------------------
516
+
517
+
518
+ @app.command()
519
+ def enter(
520
+ ref: str = typer.Argument(..., help="Worktree name, branch, or a unique substring of either."),
521
+ repo: str = typer.Option(".", "--repo", help="Git repo. Default: current repo."),
522
+ root: str | None = typer.Option(None, "--root", help="Worktree root dir."),
523
+ runner: str | None = typer.Option(
524
+ None, "--runner", help=f"Run seam: {'|'.join(VALID_RUNNERS)}."
525
+ ),
526
+ tool: str | None = typer.Option(
527
+ None, "--tool", help=f"Agent to launch: {'|'.join(VALID_TOOLS)}."
528
+ ),
529
+ template: str | None = typer.Option(
530
+ None, "--template", help="Operator-owned sandbox template name (docker runner)."
531
+ ),
532
+ cold: bool = typer.Option(False, "--cold", help="Bypass shared caches when re-syncing."),
533
+ print_only: bool = typer.Option(
534
+ False, "--print", help="Prepare, then print the launch command (no launch)."
535
+ ),
536
+ json_out: bool = typer.Option(
537
+ False, "--json", help="Prepare, then print a JSON result (no launch)."
538
+ ),
539
+ quiet: bool = typer.Option(False, "--quiet", help="Suppress progress."),
540
+ verbose: bool = typer.Option(False, "--verbose", help="Stream raw command output."),
541
+ args: list[str] | None = typer.Argument(
542
+ None, help="Extra args passed to the agent (after --)."
543
+ ),
544
+ ) -> None:
545
+ """Re-launch the agent in an existing worktree; refresh .env and re-sync deps if changed."""
546
+ reporter = Reporter(quiet=quiet, verbose=verbose, silent=json_out)
547
+ cfg = _resolve_config(
548
+ reporter,
549
+ runner=runner,
550
+ tool=tool,
551
+ base=None,
552
+ root=root,
553
+ firewall=None,
554
+ template=template,
555
+ json_out=json_out,
556
+ )
557
+ repo_path = _repo_root(reporter, repo, json_out=json_out)
558
+ try:
559
+ cand = resolve.resolve_ref(repo_path, cfg.root, ref)
560
+ except _PROVISION_ERRORS as exc:
561
+ raise _handle(reporter, exc, json_out=json_out) from exc
562
+ st = state.load(cand.path)
563
+ cfg = _runner_from_state(reporter, cfg, st, explicit=runner, json_out=json_out)
564
+ run = get_runner(cfg)
565
+
566
+ subtitle = cand.name if cand.branch in (None, cand.name) else f"{cand.name} · {cand.branch}"
567
+ reporter.heading("enter", subtitle)
568
+ reporter.summary("runner", f"{cfg.runner} → {cfg.tool}")
569
+ reporter.blank()
570
+
571
+ started = time.monotonic()
572
+ try:
573
+ with locking.worktree_lock(repo_path, cfg.root, cand.name):
574
+ outcome = provision.enter(
575
+ cfg,
576
+ run,
577
+ repo=repo_path,
578
+ name=cand.name,
579
+ tool=cfg.tool,
580
+ cold=cold,
581
+ args=args or [],
582
+ reporter=reporter,
583
+ )
584
+ except _PROVISION_ERRORS as exc:
585
+ raise _handle(reporter, exc, json_out=json_out) from exc
586
+
587
+ _finish(
588
+ reporter,
589
+ run,
590
+ cfg,
591
+ outcome,
592
+ json_out=json_out,
593
+ print_only=print_only,
594
+ args=args or [],
595
+ started=started,
596
+ )
597
+
598
+
599
+ def _finish(
600
+ reporter: Reporter,
601
+ run: Runner,
602
+ cfg: Config,
603
+ outcome: provision.Outcome,
604
+ *,
605
+ json_out: bool,
606
+ print_only: bool,
607
+ args: list[str],
608
+ started: float,
609
+ ) -> None:
610
+ if json_out or print_only:
611
+ _emit_result(outcome, json_out=json_out, print_only=print_only)
612
+ return
613
+ reporter.blank()
614
+ # Style-A closing line: green "Ready", dim "in <total> — launching <tool>".
615
+ reporter.ready(format_elapsed(time.monotonic() - started), cfg.tool)
616
+ reporter.blank()
617
+ reporter.restore_terminal()
618
+ try:
619
+ code = run.launch(outcome.worktree, tool=cfg.tool, args=args)
620
+ except RuntimeError as exc:
621
+ raise _die(reporter, str(exc)) from exc
622
+ raise typer.Exit(code)
623
+
624
+
625
+ # --- list --------------------------------------------------------------------
626
+
627
+
628
+ @app.command(name="list")
629
+ def list_cmd(
630
+ repo: str = typer.Option(".", "--repo", help="Git repo. Default: current repo."),
631
+ root: str | None = typer.Option(None, "--root", help="Worktree root dir."),
632
+ json_out: bool = typer.Option(False, "--json", help="Print machine-readable JSON."),
633
+ ) -> None:
634
+ """List worktrees by name with live branch, last commit, age, and dep/.env freshness."""
635
+ reporter = Reporter(silent=json_out)
636
+ cfg = _resolve_config(
637
+ reporter,
638
+ runner=None,
639
+ tool=None,
640
+ base=None,
641
+ root=root,
642
+ firewall=None,
643
+ json_out=json_out,
644
+ )
645
+ repo_path = _repo_root(reporter, repo, json_out=json_out)
646
+ base_dir = worktree_root(repo_path, cfg.root)
647
+
648
+ from . import ecosystems
649
+
650
+ rows: list[dict[str, Any]] = []
651
+ for rec in git.worktree_list(repo_path):
652
+ if not Path(rec.path).is_relative_to(base_dir):
653
+ continue
654
+ st = state.load(rec.path)
655
+ wt_path = Path(rec.path)
656
+ env_present = (wt_path / ".env").is_file()
657
+ if st and st.lockfile_hash:
658
+ deps = "fresh" if st.lockfile_hash == ecosystems.lockfile_hash(wt_path) else "stale"
659
+ else:
660
+ deps = "unknown"
661
+ subject, epoch = git.last_commit(wt_path)
662
+ rows.append(
663
+ {
664
+ "name": wt_path.name,
665
+ "branch": rec.branch or "detached",
666
+ "unnamed": is_placeholder(rec.branch),
667
+ "last_commit": subject,
668
+ "commit_epoch": epoch,
669
+ "path": rec.path,
670
+ "base": st.base if st else "",
671
+ "runner": st.runner if st else "",
672
+ "tool": st.tool if st else "",
673
+ "deps": deps,
674
+ "env": "present" if env_present else "absent",
675
+ }
676
+ )
677
+
678
+ # Most recently touched first: the worktree you're looking for is almost
679
+ # always the one you (or an agent) just committed in.
680
+ rows.sort(key=lambda r: r["commit_epoch"], reverse=True)
681
+
682
+ if json_out:
683
+ payload = {"schemaVersion": SCHEMA_VERSION, "worktrees": rows}
684
+ _emit_json(payload)
685
+ return
686
+
687
+ reporter.render_list(rows, repo_path)
688
+
689
+
690
+ # --- teardown ----------------------------------------------------------------
691
+
692
+
693
+ @app.command()
694
+ def teardown(
695
+ refs: list[str] = typer.Argument(
696
+ ..., help="Worktrees to remove: name, branch, or a unique substring of either."
697
+ ),
698
+ repo: str = typer.Option(".", "--repo", help="Git repo. Default: current repo."),
699
+ root: str | None = typer.Option(None, "--root", help="Worktree root dir."),
700
+ runner: str | None = typer.Option(
701
+ None, "--runner", help=f"Run seam: {'|'.join(VALID_RUNNERS)}."
702
+ ),
703
+ delete_branch: bool = typer.Option(
704
+ False, "--delete-branch", help="Also delete the local branch."
705
+ ),
706
+ remove_volumes: bool = typer.Option(
707
+ False, "--remove-volumes", help="Also remove treebox volumes."
708
+ ),
709
+ force: bool = typer.Option(
710
+ False, "--force", help="Remove even with uncommitted changes / no prompt."
711
+ ),
712
+ skip_container: bool = typer.Option(
713
+ False, "--skip-container", help="Do not touch containers/images."
714
+ ),
715
+ json_out: bool = typer.Option(False, "--json", help="Print a JSON record of what was removed."),
716
+ quiet: bool = typer.Option(False, "--quiet", help="Suppress progress."),
717
+ verbose: bool = typer.Option(False, "--verbose", help="Stream raw command output."),
718
+ ) -> None:
719
+ """Remove worktree directories (and optionally their branches); caches are left intact."""
720
+ reporter = Reporter(quiet=quiet, verbose=verbose, silent=json_out)
721
+ cfg = _resolve_config(
722
+ reporter,
723
+ runner=runner,
724
+ tool=None,
725
+ base=None,
726
+ root=root,
727
+ firewall=None,
728
+ json_out=json_out,
729
+ )
730
+ repo_path = _repo_root(reporter, repo, json_out=json_out)
731
+
732
+ # Resolve every ref before touching anything: a typo among three targets
733
+ # must not leave the first two half-removed.
734
+ targets: list[resolve.Candidate] = []
735
+ seen: set[str] = set()
736
+ for ref in refs:
737
+ try:
738
+ cand = resolve.resolve_ref(repo_path, cfg.root, ref)
739
+ except resolve.AmbiguousRefError as exc:
740
+ raise _handle(reporter, exc, json_out=json_out) from exc
741
+ except provision.NotFoundError as exc:
742
+ # The worktree may be gone while its branch lingers (manual rm):
743
+ # an exact local-branch match still gets pruned/cleaned up.
744
+ if git.local_branch_exists(repo_path, ref):
745
+ gone = worktree_path(repo_path, cfg.root, derive_name(ref))
746
+ cand = resolve.Candidate(name=derive_name(ref), branch=ref, path=str(gone))
747
+ else:
748
+ raise _handle(reporter, exc, json_out=json_out) from exc
749
+ if cand.path not in seen:
750
+ seen.add(cand.path)
751
+ targets.append(cand)
752
+
753
+ # Same all-or-nothing rule for safety gates: refuse up front.
754
+ for cand in targets:
755
+ if Path(cand.path).is_dir() and not force and git.is_dirty(cand.path):
756
+ raise _die(
757
+ reporter,
758
+ f"Worktree '{cand.name}' has uncommitted changes.",
759
+ code=EXIT_CONFLICT,
760
+ error_code="DIRTY_WORKTREE",
761
+ hint="Commit/stash the changes, or pass --force to remove anyway.",
762
+ path=cand.path,
763
+ json_out=json_out,
764
+ )
765
+
766
+ if not force and any(Path(c.path).is_dir() for c in targets):
767
+ # --json is a scripting contract: never block on a prompt (and never
768
+ # let one leak into stdout) — require --force instead.
769
+ if sys.stdin.isatty() and not json_out:
770
+ listed = ", ".join(c.name for c in targets)
771
+ plural = "s" if len(targets) != 1 else ""
772
+ typer.confirm(f"Remove worktree{plural} {listed}?", abort=True)
773
+ else:
774
+ raise _die(
775
+ reporter,
776
+ "Refusing to remove non-interactively without confirmation.",
777
+ code=EXIT_CONFLICT,
778
+ error_code="NEEDS_CONFIRMATION",
779
+ hint="Pass --force for non-interactive teardown.",
780
+ json_out=json_out,
781
+ )
782
+
783
+ reporter.heading("teardown", ", ".join(c.name for c in targets))
784
+
785
+ records = [
786
+ _teardown_one(
787
+ reporter,
788
+ cfg,
789
+ cand,
790
+ repo_path,
791
+ explicit_runner=runner,
792
+ delete_branch=delete_branch,
793
+ remove_volumes=remove_volumes,
794
+ force=force,
795
+ skip_container=skip_container,
796
+ json_out=json_out,
797
+ )
798
+ for cand in targets
799
+ ]
800
+
801
+ if json_out:
802
+ _emit_json({"schemaVersion": SCHEMA_VERSION, "worktrees": records})
803
+
804
+
805
+ def _teardown_one(
806
+ reporter: Reporter,
807
+ cfg: Config,
808
+ cand: resolve.Candidate,
809
+ repo_path: str,
810
+ *,
811
+ explicit_runner: str | None,
812
+ delete_branch: bool,
813
+ remove_volumes: bool,
814
+ force: bool,
815
+ skip_container: bool,
816
+ json_out: bool,
817
+ ) -> dict:
818
+ """Tear down one resolved worktree; returns its --json record."""
819
+ wt = Worktree.locate(repo_path, cfg.root, cand.name, cand.branch or "")
820
+ exists = wt.path.is_dir()
821
+ st = state.load(wt.path) if exists else None
822
+ run = get_runner(
823
+ _runner_from_state(reporter, cfg, st, explicit=explicit_runner, json_out=json_out)
824
+ )
825
+
826
+ branch_name = (
827
+ cand.branch
828
+ if cand.branch and (exists or git.local_branch_exists(repo_path, cand.branch))
829
+ else None
830
+ )
831
+
832
+ container = "skipped"
833
+ if not skip_container:
834
+ try:
835
+ run.teardown(wt, reporter=reporter, remove_volumes=remove_volumes)
836
+ container = "cleaned"
837
+ except Exception as exc: # teardown is best-effort
838
+ container = "failed"
839
+ reporter.warn(f"runner teardown: {exc}")
840
+ else:
841
+ reporter.note("container", "skipped")
842
+
843
+ if exists:
844
+ try:
845
+ git.worktree_remove(repo_path, wt.path, force=force)
846
+ except git.GitError:
847
+ import shutil as _sh
848
+
849
+ _sh.rmtree(wt.path, ignore_errors=True)
850
+ git.worktree_prune(repo_path)
851
+ reporter.ok("worktree", f"removed {_short_path(wt.path, repo_path)}")
852
+ else:
853
+ git.worktree_prune(repo_path)
854
+ reporter.note("worktree", f"already gone · {_short_path(wt.path, repo_path)}")
855
+
856
+ branch_deleted = False
857
+ if delete_branch and branch_name:
858
+ try:
859
+ git.delete_branch(repo_path, branch_name)
860
+ branch_deleted = True
861
+ reporter.ok("branch", f"deleted {branch_name}")
862
+ except git.GitError as exc:
863
+ reporter.warn(f"could not delete branch: {exc}")
864
+ elif branch_name:
865
+ reporter.note("branch", f"kept {branch_name}")
866
+
867
+ return {
868
+ "name": cand.name,
869
+ "branch": branch_name,
870
+ "worktree_path": str(wt.path),
871
+ "removed": exists, # False: it was already gone (still exit 0)
872
+ "branch_deleted": branch_deleted,
873
+ "container": container, # "cleaned" | "skipped" | "failed"
874
+ "volumes_removed": remove_volumes and container == "cleaned",
875
+ }
876
+
877
+
878
+ # --- doctor ------------------------------------------------------------------
879
+
880
+
881
+ @app.command()
882
+ def doctor(
883
+ repo: str = typer.Option(".", "--repo", help="Git repo. Default: current repo."),
884
+ runner: str | None = typer.Option(
885
+ None, "--runner", help=f"Runner to check: {'|'.join(VALID_RUNNERS)}."
886
+ ),
887
+ json_out: bool = typer.Option(False, "--json", help="Print machine-readable JSON."),
888
+ ) -> None:
889
+ """Check git, login credentials, UID/GID, and runner-specific dependencies."""
890
+ reporter = Reporter(silent=json_out)
891
+ cfg = _resolve_config(
892
+ reporter,
893
+ runner=runner,
894
+ tool=None,
895
+ base=None,
896
+ root=None,
897
+ firewall=None,
898
+ json_out=json_out,
899
+ )
900
+ run = get_runner(cfg)
901
+
902
+ # Instant checks: no I/O worth spinning on, so they print immediately.
903
+ checks: list[tuple[str, bool, str]] = []
904
+
905
+ git_ok = git.have_git()
906
+ git_ver = git.version_str() if git_ok else "missing"
907
+ checks.append(("git", git_ok, git_ver))
908
+
909
+ repo_path = ""
910
+ try:
911
+ repo_path = git.repo_root(repo)
912
+ checks.append(("repo", True, repo_path))
913
+ except git.GitError as exc:
914
+ checks.append(("repo", False, str(exc)))
915
+
916
+ ident = system.identity()
917
+ checks.append(("uid/gid", True, f"{ident.uid}:{ident.gid}"))
918
+
919
+ for toolname in ("claude", "codex"):
920
+ present = system.credential_present(toolname)
921
+ checks.append((f"login: {toolname}", present, str(system.credential_dir(toolname))))
922
+
923
+ # Same resolver copy_env uses, so doctor reports the exact secrets source
924
+ # provisioning will copy.
925
+ env_file = (
926
+ provision.resolve_env_file(repo_path, cfg.env_file) if repo_path else Path(cfg.env_file)
927
+ )
928
+ checks.append((".env", env_file.is_file(), str(env_file)))
929
+
930
+ # Slow checks hit the network / Docker daemon — the source of doctor's "dead
931
+ # pause". Deferred as thunks returning a row plus an optional advisory, so the
932
+ # human path can wrap each in a spinner while --json runs them inline.
933
+ def _check_git_auth() -> tuple[str, bool, str, str | None]:
934
+ # `create` REQUIRES a fresh fetch, so validate up front that git can
935
+ # authenticate to origin — exercising the same silent paths create uses
936
+ # (ssh-agent, then the HTTPS host-CLI/helper/token fallback).
937
+ reachable = git.origin_reachable(repo_path) if repo_path else None
938
+ if reachable is None:
939
+ return ("git auth", True, "no remote (local-only; freshness N/A)", None)
940
+ if reachable:
941
+ return ("git auth", True, "authenticated · fresh fetch will succeed", None)
942
+ return (
943
+ "git auth",
944
+ False,
945
+ "no working credential for origin",
946
+ "git can't authenticate to origin without a prompt — `create` requires a "
947
+ "fresh fetch. In a terminal it will prompt for your SSH key passphrase / "
948
+ "credentials and continue; headless (no TTY) it fails (exit 4) unless you "
949
+ "pass --no-fetch. Authenticate once to avoid prompts: `gh auth login` "
950
+ "(GitHub), `glab auth login` (GitLab), a git credential helper / HTTPS "
951
+ "token (others), or load an ssh-agent.",
952
+ )
953
+
954
+ def _check_runner() -> tuple[str, bool, str, str | None]:
955
+ name = f"runner: {run.name}"
956
+ try:
957
+ return (name, True, run.preflight(reporter), None)
958
+ except PreflightError as exc:
959
+ # Surface the remediation hint as a doctor advisory so the human
960
+ # checklist says how to fix it, not just what is broken.
961
+ return (name, False, str(exc), exc.hint)
962
+ except RuntimeError as exc:
963
+ return (name, False, str(exc), None)
964
+
965
+ slow = [("checking git auth", _check_git_auth), ("checking runner", _check_runner)]
966
+ advisories: list[str] = []
967
+
968
+ if json_out:
969
+ for _, check in slow:
970
+ name, ok, detail, advisory = check()
971
+ checks.append((name, ok, detail))
972
+ if advisory:
973
+ advisories.append(advisory)
974
+ # Credentials are the only hard gate for the host runner: at least one login.
975
+ has_login = any(ok for name, ok, _ in checks if name.startswith("login:"))
976
+ payload = {
977
+ "schemaVersion": SCHEMA_VERSION,
978
+ "ok": git_ok and bool(repo_path) and (has_login or not run.login_required),
979
+ "runner": cfg.runner,
980
+ "checks": [{"name": n, "ok": ok, "detail": d} for n, ok, d in checks],
981
+ "advisories": advisories,
982
+ }
983
+ _emit_json(payload)
984
+ # Same exit-code contract as the human path: hard checks (git, repo,
985
+ # runner) failing mean exit 1, so `doctor --json && create` in CI can
986
+ # branch on $? instead of parsing `ok`.
987
+ if _doctor_problems(checks):
988
+ raise typer.Exit(1)
989
+ return
990
+
991
+ # The label column is padded to the widest name; every name is known up front
992
+ # (the slow checks' names are fixed), so we can align without buffering rows.
993
+ names_ = [name for name, _, _ in checks] + ["git auth", f"runner: {run.name}"]
994
+ width = max(len(name) for name in names_)
995
+
996
+ checks, advisories = reporter.render_doctor(checks, slow, cfg.runner, width)
997
+ has_login = any(ok for name, ok, _ in checks if name.startswith("login:"))
998
+ problems = _doctor_problems(checks)
999
+ reporter.render_doctor_verdict(problems=problems, has_login=has_login, advisories=advisories)
1000
+ if problems:
1001
+ raise typer.Exit(1)
1002
+
1003
+
1004
+ def _doctor_problems(checks: list[tuple[str, bool, str]]) -> list[str]:
1005
+ """The hard-check failures (git, repo, runner) that make doctor exit 1 —
1006
+ one definition so the human and --json paths share an exit-code contract."""
1007
+ return [n for n, ok, _ in checks if not ok and (n.startswith("runner") or n in ("git", "repo"))]
1008
+
1009
+
1010
+ # --- version -----------------------------------------------------------------
1011
+
1012
+
1013
+ def _resolve_version() -> str:
1014
+ """The installed distribution version, falling back to the package constant."""
1015
+ try:
1016
+ from importlib.metadata import version as _dist_version
1017
+
1018
+ return _dist_version("treebox")
1019
+ except Exception:
1020
+ return __version__
1021
+
1022
+
1023
+ @app.command()
1024
+ def version() -> None:
1025
+ """Print the version."""
1026
+ sys.stdout.write(f"{_resolve_version()}\n")
1027
+
1028
+
1029
+ def _version_callback(value: bool) -> None:
1030
+ if value:
1031
+ sys.stdout.write(f"{_resolve_version()}\n")
1032
+ raise typer.Exit()
1033
+
1034
+
1035
+ @app.callback()
1036
+ def main(
1037
+ _version: bool = typer.Option(
1038
+ False,
1039
+ "--version",
1040
+ "-V",
1041
+ callback=_version_callback,
1042
+ is_eager=True,
1043
+ help="Show version and exit.",
1044
+ ),
1045
+ ) -> None:
1046
+ # Restore default SIGPIPE handling on POSIX so piping output into `head` /
1047
+ # `grep -m1` ends the process quietly (SIGPIPE, exit 141) instead of
1048
+ # surfacing "BrokenPipeError ignored" noise or a traceback at shutdown.
1049
+ # Python starts with SIGPIPE ignored, which is wrong for a piped CLI.
1050
+ if hasattr(signal, "SIGPIPE"):
1051
+ signal.signal(signal.SIGPIPE, signal.SIG_DFL)
1052
+
1053
+ # Pretty, secret-safe tracebacks for genuinely unexpected crashes (handled
1054
+ # errors already exit cleanly via _die). Framework frames are suppressed.
1055
+ from rich.traceback import install
1056
+
1057
+ install(show_locals=False, suppress=[typer], width=100)
1058
+
1059
+
1060
+ if __name__ == "__main__":
1061
+ app()