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/__init__.py +8 -0
- treebox/assets/container/Dockerfile +103 -0
- treebox/assets/container/allowed-domains.sh +25 -0
- treebox/assets/container/container.json +27 -0
- treebox/assets/container/firewall.json +4 -0
- treebox/assets/container/init-firewall.sh +142 -0
- treebox/assets/container/post-create.sh +48 -0
- treebox/assets/pre-push +27 -0
- treebox/assets.py +69 -0
- treebox/cli.py +1061 -0
- treebox/config.py +118 -0
- treebox/ecosystems.py +222 -0
- treebox/git.py +528 -0
- treebox/locking.py +49 -0
- treebox/models.py +88 -0
- treebox/names.py +166 -0
- treebox/output.py +531 -0
- treebox/provision.py +382 -0
- treebox/py.typed +0 -0
- treebox/resolve.py +65 -0
- treebox/runners/__init__.py +19 -0
- treebox/runners/base.py +73 -0
- treebox/runners/docker.py +688 -0
- treebox/runners/host.py +110 -0
- treebox/state.py +57 -0
- treebox/system.py +43 -0
- treebox-0.1.0.dist-info/METADATA +239 -0
- treebox-0.1.0.dist-info/RECORD +31 -0
- treebox-0.1.0.dist-info/WHEEL +4 -0
- treebox-0.1.0.dist-info/entry_points.txt +2 -0
- treebox-0.1.0.dist-info/licenses/LICENSE +73 -0
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()
|