mergetrain 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.
Files changed (33) hide show
  1. mergetrain/__init__.py +5 -0
  2. mergetrain/__main__.py +4 -0
  3. mergetrain/cli.py +628 -0
  4. mergetrain/config.py +438 -0
  5. mergetrain/daemon.py +80 -0
  6. mergetrain/dashboard.py +188 -0
  7. mergetrain/dashboard_dist/assets/index-BKsW0WDL.js +49 -0
  8. mergetrain/dashboard_dist/assets/index-dvMrfMz1.css +1 -0
  9. mergetrain/dashboard_dist/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  10. mergetrain/dashboard_dist/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  11. mergetrain/dashboard_dist/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  12. mergetrain/dashboard_dist/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  13. mergetrain/dashboard_dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  14. mergetrain/dashboard_dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  15. mergetrain/dashboard_dist/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  16. mergetrain/dashboard_dist/assets/jetbrains-mono-cyrillic-ext-wght-normal-EocZY2iu.woff2 +0 -0
  17. mergetrain/dashboard_dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  18. mergetrain/dashboard_dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  19. mergetrain/dashboard_dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  20. mergetrain/dashboard_dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  21. mergetrain/dashboard_dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  22. mergetrain/dashboard_dist/favicon.svg +1 -0
  23. mergetrain/dashboard_dist/index.html +16 -0
  24. mergetrain/errors.py +56 -0
  25. mergetrain/git_runner.py +1183 -0
  26. mergetrain/models.py +133 -0
  27. mergetrain/snapshot.py +241 -0
  28. mergetrain/store.py +893 -0
  29. mergetrain-0.1.0.dist-info/METADATA +207 -0
  30. mergetrain-0.1.0.dist-info/RECORD +33 -0
  31. mergetrain-0.1.0.dist-info/WHEEL +4 -0
  32. mergetrain-0.1.0.dist-info/entry_points.txt +2 -0
  33. mergetrain-0.1.0.dist-info/licenses/LICENSE +21 -0
mergetrain/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """mergetrain: a local-first merge-and-deploy queue for coding-agent worktrees."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.1.0"
mergetrain/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
mergetrain/cli.py ADDED
@@ -0,0 +1,628 @@
1
+ """Command-line interface for mergetrain."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from collections import Counter
9
+ from pathlib import Path
10
+ from typing import Any, Sequence
11
+
12
+ from . import __version__
13
+ from .config import MergetrainConfig, load_config, render_default_config
14
+ from .daemon import daemon_loop
15
+ from .errors import CommandFailed, ConfigError, QueueError, MergetrainError
16
+ from .git_runner import (
17
+ GitRunner,
18
+ apply_gc,
19
+ branch_exists,
20
+ find_worktree_gc_candidates,
21
+ git_current_branch,
22
+ git_ref_exists,
23
+ git_remote_exists,
24
+ git_remote_url,
25
+ git_repo_root,
26
+ git_rev_parse,
27
+ git_worktree_clean,
28
+ )
29
+ from .models import Job
30
+ from .snapshot import next_action as _doctor_next_action
31
+ from .store import (
32
+ cancel_job,
33
+ claim_all_queued,
34
+ claim_deploy_batch,
35
+ claim_next_job,
36
+ connect,
37
+ counts,
38
+ default_owner,
39
+ enqueue_job,
40
+ get_lock,
41
+ list_jobs,
42
+ release_runner_lock,
43
+ terminal_branch_candidates,
44
+ validated_train_summaries,
45
+ )
46
+
47
+ GLOBAL_OPTIONS_WITH_VALUES = {"--config", "--repo", "--db"}
48
+
49
+
50
+ def normalize_global_options(argv: Sequence[str]) -> list[str]:
51
+ """Allow global options before or after the subcommand.
52
+
53
+ Many coding agents place ``--repo`` or ``--config`` after the subcommand.
54
+ argparse normally rejects that. This lightweight normalizer moves known
55
+ global options to the front while leaving command-specific arguments intact.
56
+ """
57
+
58
+ moved: list[str] = []
59
+ rest: list[str] = []
60
+ index = 0
61
+ while index < len(argv):
62
+ token = argv[index]
63
+ matched_equals = False
64
+ for option in GLOBAL_OPTIONS_WITH_VALUES:
65
+ if token.startswith(option + "="):
66
+ moved.append(token)
67
+ matched_equals = True
68
+ break
69
+ if matched_equals:
70
+ index += 1
71
+ continue
72
+ if token in GLOBAL_OPTIONS_WITH_VALUES and index + 1 < len(argv):
73
+ moved.extend([token, argv[index + 1]])
74
+ index += 2
75
+ continue
76
+ rest.append(token)
77
+ index += 1
78
+ return moved + rest
79
+
80
+
81
+ def dump_json(payload: Any) -> None:
82
+ print(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True))
83
+
84
+
85
+ def config_from_args(args: argparse.Namespace) -> MergetrainConfig:
86
+ return load_config(config_path=args.config, repo=args.repo, db_override=args.db)
87
+
88
+
89
+ def agent_contract_payload() -> dict[str, Any]:
90
+ return {
91
+ "name": "mergetrain agent contract",
92
+ "purpose": "Serialize committed local task branches through one merge/test/push/verify runner.",
93
+ "rules": [
94
+ "Work on a task-specific branch and worktree.",
95
+ "Commit all changes before enqueueing.",
96
+ "Do not push deploy refs directly; enqueue the branch instead.",
97
+ "Read doctor --json or status --json before deciding the next action.",
98
+ "Use --auto only after explicit unattended-deploy approval from the user/operator.",
99
+ "Let one runner or daemon own merge, test, push, and verify.",
100
+ "Fix blocked or failed work in the owning branch, commit a clean result, then enqueue a new job.",
101
+ ],
102
+ "boundary": {
103
+ "deploy_requires": "run-next --deploy or run-batch --deploy",
104
+ "validate_requires": "run-next --validate-only or run-batch --validate-only",
105
+ "validated_train_deploy": "run-batch --deploy claims one exact validated train",
106
+ "daemon_processes_only": "jobs enqueued with --auto",
107
+ "destructive_cleanup_requires": "gc --apply; branch deletion also requires --delete-branches",
108
+ },
109
+ }
110
+
111
+
112
+ def render_agent_contract() -> str:
113
+ payload = agent_contract_payload()
114
+ rules = "\n".join(f"{i}. {rule}" for i, rule in enumerate(payload["rules"], start=1))
115
+ return f"""# mergetrain agent contract
116
+
117
+ Purpose: {payload['purpose']}
118
+
119
+ ## Rules
120
+
121
+ {rules}
122
+
123
+ ## Safety boundary
124
+
125
+ - Deploy requires `run-next --deploy` or `run-batch --deploy`.
126
+ - Validation requires `run-next --validate-only` or `run-batch --validate-only`.
127
+ - A validated train is deployed as one exact identity by `run-batch --deploy`.
128
+ - The daemon processes only jobs enqueued with `--auto`.
129
+ - Destructive cleanup requires `gc --apply`; branch deletion also requires `--delete-branches`.
130
+ """
131
+
132
+
133
+ def cmd_init(args: argparse.Namespace) -> int:
134
+ repo = Path(args.repo or Path.cwd()).expanduser().resolve()
135
+ project = args.project or repo.name or "example-app"
136
+ config_text = render_default_config(project)
137
+ if not args.write:
138
+ print(config_text, end="")
139
+ return 0
140
+ files = {
141
+ repo / ".mergetrain.yaml": config_text,
142
+ repo / "AGENTS.mergetrain.md": render_agent_contract(),
143
+ repo / "CLAUDE.mergetrain.md": render_agent_contract(),
144
+ }
145
+ written: list[str] = []
146
+ for path, content in files.items():
147
+ if path.exists() and not args.force:
148
+ raise ConfigError(f"refusing to overwrite existing file without --force: {path}")
149
+ path.write_text(content, encoding="utf-8")
150
+ written.append(str(path))
151
+ dump_json({"ok": True, "written": written})
152
+ return 0
153
+
154
+
155
+ def cmd_agent_contract(args: argparse.Namespace) -> int:
156
+ if args.json:
157
+ dump_json(agent_contract_payload())
158
+ else:
159
+ print(render_agent_contract(), end="")
160
+ return 0
161
+
162
+
163
+ def _capture_sha_or_error(path: Path, ref: str, *, label: str) -> str:
164
+ try:
165
+ return git_rev_parse(path, ref)
166
+ except CommandFailed as exc:
167
+ raise QueueError(f"could not capture {label} SHA for {ref}: {exc}") from exc
168
+
169
+
170
+ def cmd_enqueue(args: argparse.Namespace) -> int:
171
+ config = config_from_args(args)
172
+ worktree = Path(args.worktree or Path.cwd()).expanduser().resolve()
173
+ if not args.no_ready_check:
174
+ if not worktree.exists():
175
+ raise QueueError(f"worktree does not exist: {worktree}")
176
+ if not git_repo_root(worktree):
177
+ raise QueueError(f"not a git worktree: {worktree}")
178
+ if not args.allow_dirty and not git_worktree_clean(worktree):
179
+ raise QueueError("worktree is dirty; commit/stash changes or pass --allow-dirty")
180
+ current = git_current_branch(worktree)
181
+ if not args.allow_branch_mismatch and current != args.branch:
182
+ raise QueueError(f"current branch {current!r} does not match --branch {args.branch!r}")
183
+ base_sha = args.base_sha or ""
184
+ head_sha = args.head_sha or ""
185
+ if args.capture_sha:
186
+ base_sha = base_sha or _capture_sha_or_error(config.repo, config.git.integration_ref, label="base")
187
+ head_sha = head_sha or _capture_sha_or_error(worktree, args.branch, label="head")
188
+ conn = connect(config.state.db)
189
+ try:
190
+ job = enqueue_job(
191
+ conn,
192
+ task=args.task,
193
+ branch=args.branch,
194
+ worktree_path=str(worktree),
195
+ base_sha=base_sha,
196
+ head_sha=head_sha,
197
+ note=args.note or "",
198
+ allow_duplicate=args.allow_duplicate,
199
+ auto_deploy=args.auto,
200
+ )
201
+ finally:
202
+ conn.close()
203
+ payload = {"ok": True, "job": job.to_dict()}
204
+ if args.json:
205
+ dump_json(payload)
206
+ else:
207
+ print(f"queued job {job.id}: {job.task} ({job.branch})")
208
+ return 0
209
+
210
+
211
+ def cmd_status(args: argparse.Namespace) -> int:
212
+ config = config_from_args(args)
213
+ conn = connect(config.state.db)
214
+ try:
215
+ lock = get_lock(conn)
216
+ validated_trains = validated_train_summaries(conn)
217
+ payload = {
218
+ "ok": True,
219
+ "db": str(config.state.db),
220
+ "lock": lock.to_dict() if lock else None,
221
+ "jobs": [job.to_dict() for job in list_jobs(conn, limit=args.limit)],
222
+ "validated_trains": validated_trains,
223
+ }
224
+ finally:
225
+ conn.close()
226
+ if args.json:
227
+ dump_json(payload)
228
+ else:
229
+ lock_text = payload["lock"]["owner"] if payload["lock"] else "none"
230
+ print(f"db: {payload['db']}")
231
+ print(f"lock: {lock_text}")
232
+ for job in payload["jobs"]:
233
+ print(f"#{job['id']} {job['status']} {job['branch']} - {job['task']}")
234
+ return 0
235
+
236
+
237
+ def cmd_doctor(args: argparse.Namespace) -> int:
238
+ config = config_from_args(args)
239
+ db_existed_before = config.state.db.exists()
240
+ conn = connect(config.state.db)
241
+ try:
242
+ lock = get_lock(conn)
243
+ count_data = counts(conn)
244
+ validated_trains = validated_train_summaries(conn)
245
+ finally:
246
+ conn.close()
247
+ remote_url = git_remote_url(config.repo, config.git.remote)
248
+ payload: dict[str, Any] = {
249
+ "ok": True,
250
+ "version": __version__,
251
+ "config": config.to_dict(),
252
+ "config_exists": config.config_exists,
253
+ "db": str(config.state.db),
254
+ "db_existed_before": db_existed_before,
255
+ "state": {
256
+ "logs": str(config.state.logs),
257
+ "worktree_root": str(config.state.worktree_root),
258
+ },
259
+ "git": {
260
+ "repo_root": git_repo_root(config.repo),
261
+ "current_branch": git_current_branch(config.repo),
262
+ "worktree_clean": git_worktree_clean(config.repo) if git_repo_root(config.repo) else False,
263
+ "remote_url": remote_url,
264
+ "remote_exists": bool(remote_url) or git_remote_exists(config.repo, config.git.remote),
265
+ "integration_ref": config.git.integration_ref,
266
+ "integration_ref_exists": git_ref_exists(config.repo, config.git.integration_ref) if git_repo_root(config.repo) else False,
267
+ },
268
+ "lock": lock.to_dict() if lock else None,
269
+ "counts": count_data,
270
+ "validated_trains": validated_trains,
271
+ "gc": {"worktree_candidates": find_worktree_gc_candidates(config)},
272
+ }
273
+ payload["ok"] = bool(payload["config_exists"] and payload["git"]["repo_root"])
274
+ payload["next_action"] = _doctor_next_action(payload)
275
+ if args.json:
276
+ dump_json(payload)
277
+ else:
278
+ print(f"ok: {payload['ok']}")
279
+ print(f"config: {payload['config']['config_path']} ({'found' if payload['config_exists'] else 'default'})")
280
+ print(f"db: {payload['db']}")
281
+ print(f"git repo: {payload['git']['repo_root'] or 'not found'}")
282
+ print(f"next action: {payload['next_action']}")
283
+ return 0
284
+
285
+
286
+ def _mode_from_args(args: argparse.Namespace) -> bool:
287
+ if args.deploy == args.validate_only:
288
+ raise QueueError("choose exactly one: --validate-only or --deploy")
289
+ return bool(args.deploy)
290
+
291
+
292
+ def _results_payload(results: list[Job]) -> dict[str, Any]:
293
+ status_counts = Counter(job.status for job in results)
294
+ successful = sum(status_counts[status] for status in ("validated", "deployed"))
295
+ ok = successful == len(results)
296
+ if ok:
297
+ result = "success"
298
+ elif successful:
299
+ result = "partial"
300
+ else:
301
+ result = "failed"
302
+ return {
303
+ "ok": ok,
304
+ "result": result,
305
+ "counts": dict(sorted(status_counts.items())),
306
+ "jobs": [job.to_dict() for job in results],
307
+ }
308
+
309
+
310
+ def cmd_run_next(args: argparse.Namespace) -> int:
311
+ deploy = _mode_from_args(args)
312
+ config = config_from_args(args)
313
+ owner = default_owner()
314
+ lease_token = ""
315
+ conn = connect(config.state.db)
316
+ try:
317
+ job = claim_next_job(conn, owner=owner, ttl_minutes=config.queue.lock_ttl_minutes)
318
+ if job is None:
319
+ payload = {**_results_payload([]), "note": "no queued jobs"}
320
+ else:
321
+ lease_token = job.claim_token
322
+ result = GitRunner(config).process_one(
323
+ conn,
324
+ job,
325
+ deploy=deploy,
326
+ keep_worktree=args.keep_worktree,
327
+ owner=owner,
328
+ ttl_minutes=config.queue.lock_ttl_minutes,
329
+ )
330
+ payload = _results_payload([result])
331
+ finally:
332
+ if lease_token:
333
+ release_runner_lock(conn, owner=owner, token=lease_token)
334
+ conn.close()
335
+ if args.json:
336
+ dump_json(payload)
337
+ else:
338
+ if payload.get("jobs"):
339
+ for job_data in payload["jobs"]:
340
+ print(f"#{job_data['id']} {job_data['status']}: {job_data['branch']}")
341
+ else:
342
+ print(payload.get("note", "done"))
343
+ return 0 if payload["ok"] else 1
344
+
345
+
346
+ def cmd_run_batch(args: argparse.Namespace) -> int:
347
+ deploy = _mode_from_args(args)
348
+ if args.train_id and not deploy:
349
+ raise QueueError("--train-id requires --deploy")
350
+ config = config_from_args(args)
351
+ owner = default_owner()
352
+ lease_token = ""
353
+ conn = connect(config.state.db)
354
+ try:
355
+ if deploy:
356
+ jobs = claim_deploy_batch(
357
+ conn,
358
+ owner=owner,
359
+ ttl_minutes=config.queue.lock_ttl_minutes,
360
+ train_id=args.train_id or "",
361
+ )
362
+ else:
363
+ jobs = claim_all_queued(conn, owner=owner, ttl_minutes=config.queue.lock_ttl_minutes)
364
+ if not jobs:
365
+ payload = {**_results_payload([]), "note": "no queued jobs"}
366
+ else:
367
+ lease_token = jobs[0].claim_token
368
+ results = GitRunner(config).process_batch(
369
+ conn,
370
+ jobs,
371
+ deploy=deploy,
372
+ keep_worktree=args.keep_worktree,
373
+ owner=owner,
374
+ ttl_minutes=config.queue.lock_ttl_minutes,
375
+ )
376
+ payload = _results_payload(results)
377
+ finally:
378
+ if lease_token:
379
+ release_runner_lock(conn, owner=owner, token=lease_token)
380
+ conn.close()
381
+ if args.json:
382
+ dump_json(payload)
383
+ else:
384
+ if payload.get("jobs"):
385
+ for job_data in payload["jobs"]:
386
+ print(f"#{job_data['id']} {job_data['status']}: {job_data['branch']}")
387
+ else:
388
+ print(payload.get("note", "done"))
389
+ return 0 if payload["ok"] else 1
390
+
391
+
392
+ def cmd_daemon(args: argparse.Namespace) -> int:
393
+ config = config_from_args(args)
394
+ runner = GitRunner(config)
395
+ owner = default_owner()
396
+
397
+ def process_batch(conn, jobs: list[Job]) -> object: # type: ignore[no-untyped-def]
398
+ return runner.process_batch(
399
+ conn,
400
+ jobs,
401
+ deploy=True,
402
+ keep_worktree=args.keep_worktree,
403
+ owner=owner,
404
+ ttl_minutes=config.queue.lock_ttl_minutes,
405
+ )
406
+
407
+ daemon_loop(
408
+ db_path=str(config.state.db),
409
+ process_batch=process_batch,
410
+ owner=owner,
411
+ interval_seconds=args.interval or config.queue.daemon_interval_seconds,
412
+ lock_ttl_minutes=config.queue.lock_ttl_minutes,
413
+ once=args.once,
414
+ say=print,
415
+ )
416
+ return 0
417
+
418
+
419
+ def cmd_gc(args: argparse.Namespace) -> int:
420
+ config = config_from_args(args)
421
+ conn = connect(config.state.db)
422
+ try:
423
+ branch_candidates_raw = terminal_branch_candidates(conn)
424
+ finally:
425
+ conn.close()
426
+ protected = set(config.git.push_refs) | {config.git.integration_branch, git_current_branch(config.repo)}
427
+ branch_candidates: list[dict[str, Any]] = []
428
+ delete_branch_names: list[str] = []
429
+ for candidate in branch_candidates_raw:
430
+ branch = candidate["branch"]
431
+ exists = branch_exists(config.repo, branch)
432
+ eligible = exists and branch not in protected
433
+ item = {**candidate, "exists": exists, "eligible": eligible}
434
+ branch_candidates.append(item)
435
+ if eligible:
436
+ delete_branch_names.append(branch)
437
+ payload: dict[str, Any] = {
438
+ "ok": True,
439
+ "apply": bool(args.apply),
440
+ "delete_branches": bool(args.delete_branches),
441
+ "worktree_candidates": find_worktree_gc_candidates(config),
442
+ "branch_candidates": branch_candidates,
443
+ "result": None,
444
+ }
445
+ if args.apply:
446
+ payload["result"] = apply_gc(
447
+ config,
448
+ delete_branches=delete_branch_names if args.delete_branches else (),
449
+ )
450
+ if args.json:
451
+ dump_json(payload)
452
+ else:
453
+ print(f"worktree candidates: {len(payload['worktree_candidates'])}")
454
+ print(f"branch candidates: {len(payload['branch_candidates'])}")
455
+ if args.apply:
456
+ print("applied")
457
+ else:
458
+ print("dry-run; pass --apply to remove candidates")
459
+ return 0
460
+
461
+
462
+ def cmd_cancel(args: argparse.Namespace) -> int:
463
+ config = config_from_args(args)
464
+ conn = connect(config.state.db)
465
+ try:
466
+ job = cancel_job(conn, args.job_id, note=args.note or "")
467
+ finally:
468
+ conn.close()
469
+ if args.json:
470
+ dump_json({"ok": True, "job": job.to_dict()})
471
+ else:
472
+ action = "cancellation requested for" if job.cancel_requested_at else "canceled"
473
+ print(f"{action} job {job.id}: {job.branch}")
474
+ return 0
475
+
476
+
477
+ def cmd_dashboard(args: argparse.Namespace) -> int:
478
+ from .dashboard import serve_dashboard
479
+
480
+ host = str(args.host).strip()
481
+ loopback_hosts = {"127.0.0.1", "localhost", "::1"}
482
+ if host not in loopback_hosts and not args.allow_remote:
483
+ raise QueueError(
484
+ "dashboard binds to loopback by default; pass --allow-remote to expose it"
485
+ )
486
+ if not 0 <= args.port <= 65535:
487
+ raise QueueError("dashboard port must be between 0 and 65535")
488
+ config = config_from_args(args)
489
+
490
+ def announce(url: str) -> None:
491
+ print(f"mergetrain dashboard: {url}", flush=True)
492
+ print("read-only · press Ctrl-C to stop", flush=True)
493
+
494
+ serve_dashboard(config, host=host, port=args.port, preview=args.preview, ready=announce)
495
+ return 0
496
+
497
+
498
+ def build_parser() -> argparse.ArgumentParser:
499
+ parser = argparse.ArgumentParser(prog="mergetrain")
500
+ parser.add_argument("--version", action="version", version=f"mergetrain {__version__}")
501
+ parser.add_argument("--config", help="Path to .mergetrain.yaml")
502
+ parser.add_argument("--repo", default=str(Path.cwd()), help="Repository root or worktree path")
503
+ parser.add_argument("--db", help="Override SQLite DB path")
504
+ subparsers = parser.add_subparsers(dest="command")
505
+
506
+ p_init = subparsers.add_parser("init", help="Print or write starter config and agent docs")
507
+ p_init.add_argument("--project", help="Project name for config and worktree prefixes")
508
+ p_init.add_argument("--write", action="store_true", help="Write .mergetrain.yaml and agent docs")
509
+ p_init.add_argument("--force", action="store_true", help="Overwrite generated files")
510
+ p_init.set_defaults(func=cmd_init)
511
+
512
+ p_contract = subparsers.add_parser("agent-contract", help="Print agent operating contract")
513
+ p_contract.add_argument("--json", action="store_true")
514
+ p_contract.set_defaults(func=cmd_agent_contract)
515
+
516
+ p_enqueue = subparsers.add_parser("enqueue", help="Add a task branch to the deploy queue")
517
+ p_enqueue.add_argument("--task", required=True)
518
+ p_enqueue.add_argument("--branch", required=True)
519
+ p_enqueue.add_argument("--worktree")
520
+ p_enqueue.add_argument("--base-sha", default="")
521
+ p_enqueue.add_argument("--head-sha", default="")
522
+ p_enqueue.add_argument("--note", default="")
523
+ p_enqueue.add_argument("--allow-duplicate", action="store_true")
524
+ p_enqueue.add_argument("--auto", action="store_true")
525
+ p_enqueue.add_argument("--capture-sha", action="store_true")
526
+ p_enqueue.add_argument("--allow-dirty", action="store_true")
527
+ p_enqueue.add_argument("--allow-branch-mismatch", action="store_true")
528
+ p_enqueue.add_argument("--no-ready-check", action="store_true")
529
+ p_enqueue.add_argument("--json", action="store_true")
530
+ p_enqueue.set_defaults(func=cmd_enqueue)
531
+
532
+ p_status = subparsers.add_parser("status", help="Show queue and lock status")
533
+ p_status.add_argument("--json", action="store_true")
534
+ p_status.add_argument("--limit", type=int, default=50)
535
+ p_status.set_defaults(func=cmd_status)
536
+
537
+ p_doctor = subparsers.add_parser("doctor", help="Diagnose config, queue, git, and next action")
538
+ p_doctor.add_argument("--json", action="store_true")
539
+ p_doctor.set_defaults(func=cmd_doctor)
540
+
541
+ for name, func, help_text in [
542
+ ("run-next", cmd_run_next, "Process one queued job"),
543
+ ("run-batch", cmd_run_batch, "Validate queued jobs or deploy an exact validated train"),
544
+ ]:
545
+ p_run = subparsers.add_parser(name, help=help_text)
546
+ mode = p_run.add_mutually_exclusive_group(required=True)
547
+ mode.add_argument("--validate-only", action="store_true")
548
+ mode.add_argument("--deploy", action="store_true")
549
+ p_run.add_argument("--keep-worktree", action="store_true")
550
+ p_run.add_argument("--json", action="store_true")
551
+ if name == "run-batch":
552
+ p_run.add_argument("--train-id", help="Deploy one exact validated train")
553
+ p_run.set_defaults(func=func)
554
+
555
+ p_daemon = subparsers.add_parser("daemon", help="Run foreground auto-only daemon")
556
+ p_daemon.add_argument("--interval", type=int)
557
+ p_daemon.add_argument("--once", action="store_true")
558
+ p_daemon.add_argument("--keep-worktree", action="store_true")
559
+ p_daemon.set_defaults(func=cmd_daemon)
560
+
561
+ p_gc = subparsers.add_parser("gc", help="Clean temporary worktrees and optionally terminal branches")
562
+ p_gc.add_argument("--json", action="store_true")
563
+ p_gc.add_argument("--apply", action="store_true")
564
+ p_gc.add_argument("--delete-branches", action="store_true")
565
+ p_gc.set_defaults(func=cmd_gc)
566
+
567
+ p_cancel = subparsers.add_parser("cancel", help="Cancel a non-terminal queue item")
568
+ p_cancel.add_argument("job_id", type=int)
569
+ p_cancel.add_argument("--note", default="")
570
+ p_cancel.add_argument("--json", action="store_true")
571
+ p_cancel.set_defaults(func=cmd_cancel)
572
+
573
+ p_dashboard = subparsers.add_parser(
574
+ "dashboard", help="Serve the local read-only live dashboard"
575
+ )
576
+ p_dashboard.add_argument("--host", default="127.0.0.1")
577
+ p_dashboard.add_argument("--port", type=int, default=8765)
578
+ p_dashboard.add_argument(
579
+ "--allow-remote",
580
+ action="store_true",
581
+ help="Explicitly allow binding outside the loopback interface",
582
+ )
583
+ p_dashboard.add_argument(
584
+ "--preview",
585
+ action="store_true",
586
+ help="Label the connected database as preview data",
587
+ )
588
+ p_dashboard.set_defaults(func=cmd_dashboard)
589
+ return parser
590
+
591
+
592
+ def main(argv: Sequence[str] | None = None) -> int:
593
+ raw = list(sys.argv[1:] if argv is None else argv)
594
+ parser = build_parser()
595
+ args = parser.parse_args(normalize_global_options(raw))
596
+ if not hasattr(args, "func"):
597
+ parser.print_help(sys.stderr)
598
+ return 2
599
+ try:
600
+ return int(args.func(args))
601
+ except KeyboardInterrupt:
602
+ if getattr(args, "json", False):
603
+ dump_json({"ok": False, "error": {"code": "interrupted", "message": "interrupted"}})
604
+ else:
605
+ print("mergetrain: interrupted", file=sys.stderr)
606
+ return 130
607
+ except (MergetrainError, CommandFailed, ConfigError, QueueError) as exc:
608
+ if getattr(args, "json", False):
609
+ code = "".join(
610
+ [f"_{char.lower()}" if char.isupper() else char for char in type(exc).__name__]
611
+ ).lstrip("_")
612
+ dump_json(
613
+ {
614
+ "ok": False,
615
+ "error": {
616
+ "code": code,
617
+ "message": str(exc),
618
+ "retryable": type(exc).__name__ in {"LockHeld", "LostLease"},
619
+ },
620
+ }
621
+ )
622
+ else:
623
+ print(f"mergetrain: error: {exc}", file=sys.stderr)
624
+ return 1
625
+
626
+
627
+ if __name__ == "__main__": # pragma: no cover
628
+ raise SystemExit(main())