arctx-cli 0.2.0b2__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 (49) hide show
  1. arctx_cli/__init__.py +1 -0
  2. arctx_cli/alias.py +238 -0
  3. arctx_cli/append_batch.py +90 -0
  4. arctx_cli/commands/__init__.py +85 -0
  5. arctx_cli/commands/alias_cmd.py +174 -0
  6. arctx_cli/commands/anchor.py +82 -0
  7. arctx_cli/commands/current.py +69 -0
  8. arctx_cli/commands/cut.py +89 -0
  9. arctx_cli/commands/dump.py +72 -0
  10. arctx_cli/commands/ext.py +236 -0
  11. arctx_cli/commands/git.py +216 -0
  12. arctx_cli/commands/graph.py +73 -0
  13. arctx_cli/commands/guide.py +360 -0
  14. arctx_cli/commands/init.py +223 -0
  15. arctx_cli/commands/list.py +45 -0
  16. arctx_cli/commands/migrate.py +135 -0
  17. arctx_cli/commands/node.py +55 -0
  18. arctx_cli/commands/outcomes.py +58 -0
  19. arctx_cli/commands/payload.py +192 -0
  20. arctx_cli/commands/reachable.py +75 -0
  21. arctx_cli/commands/show.py +113 -0
  22. arctx_cli/commands/sync.py +244 -0
  23. arctx_cli/commands/trace.py +46 -0
  24. arctx_cli/commands/transition.py +212 -0
  25. arctx_cli/commands/use.py +67 -0
  26. arctx_cli/commands/view.py +82 -0
  27. arctx_cli/commands/work_session.py +330 -0
  28. arctx_cli/context.py +38 -0
  29. arctx_cli/ext/__init__.py +1 -0
  30. arctx_cli/ext/command/__init__.py +110 -0
  31. arctx_cli/ext/git/__init__.py +1 -0
  32. arctx_cli/ext/git/branch.py +140 -0
  33. arctx_cli/ext/git/cherry_pick.py +144 -0
  34. arctx_cli/ext/git/commit.py +205 -0
  35. arctx_cli/ext/git/hook.py +758 -0
  36. arctx_cli/ext/git/merge.py +204 -0
  37. arctx_cli/ext/git/reset.py +138 -0
  38. arctx_cli/ext/git/revert.py +157 -0
  39. arctx_cli/ext/git/verify.py +140 -0
  40. arctx_cli/ext/git/worktree.py +173 -0
  41. arctx_cli/ext_registry.py +34 -0
  42. arctx_cli/main.py +133 -0
  43. arctx_cli/paths.py +27 -0
  44. arctx_cli/payload_builder.py +23 -0
  45. arctx_cli/workspace.py +64 -0
  46. arctx_cli-0.2.0b2.dist-info/METADATA +48 -0
  47. arctx_cli-0.2.0b2.dist-info/RECORD +49 -0
  48. arctx_cli-0.2.0b2.dist-info/WHEEL +4 -0
  49. arctx_cli-0.2.0b2.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,758 @@
1
+ """arctx CLI hook commands.
2
+
3
+ Subcommands:
4
+ arctx git hook install [--force] — Install .git/hooks/post-rewrite
5
+ arctx git hook post-rewrite <mode> — Process stdin sha_map and call adopt_rewrite
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ from arctx.ext.git.helpers.repo import resolve_worktree_path
15
+ from arctx.ext.git.queries import transition_by_sha
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # Hook script content
19
+ # ---------------------------------------------------------------------------
20
+
21
+ _POST_REWRITE_HOOK = """\
22
+ #!/usr/bin/env bash
23
+ # .git/hooks/post-rewrite — arctx amend/rebase tracking
24
+ # argv: $1 = "amend" | "rebase"
25
+ # stdin: one line per rewrite: "<old_sha> <new_sha>"
26
+ exec arctx git hook post-rewrite "$1"
27
+ """
28
+
29
+ _POST_COMMIT_HOOK = """\
30
+ #!/usr/bin/env bash
31
+ # .git/hooks/post-commit — arctx revert/cherry-pick fallback tracking
32
+ # Detects bare git revert / cherry-pick and records a arctx transition.
33
+ exec arctx git hook post-commit
34
+ """
35
+
36
+ _POST_MERGE_HOOK = """\
37
+ #!/usr/bin/env bash
38
+ # .git/hooks/post-merge — arctx merge tracking
39
+ # Detects a bare `git merge` (not driven by arctx merge) and attempts to
40
+ # adopt the merge commit into the arctx graph.
41
+ # argv: $1 = 1 if squash merge, 0 otherwise
42
+ exec arctx git hook post-merge "$1"
43
+ """
44
+
45
+
46
+ def add_parser(subparsers) -> argparse.ArgumentParser:
47
+ """Register the ``hook`` subcommand parser."""
48
+ parser = subparsers.add_parser("hook", help="Manage git hooks for arctx integration")
49
+ hook_sub = parser.add_subparsers(dest="hook_command", required=True)
50
+
51
+ # install subcommand
52
+ install_parser = hook_sub.add_parser(
53
+ "install", help="Install .git/hooks/post-rewrite"
54
+ )
55
+ install_parser.add_argument(
56
+ "--force",
57
+ action="store_true",
58
+ help="Overwrite existing hook without prompting",
59
+ )
60
+ install_parser.add_argument(
61
+ "--repo-path",
62
+ default=None,
63
+ help="Path to git repo root (default: cwd)",
64
+ )
65
+
66
+ # post-rewrite subcommand (called by the hook script)
67
+ post_rewrite_parser = hook_sub.add_parser(
68
+ "post-rewrite",
69
+ help="Process a post-rewrite hook invocation (reads stdin)",
70
+ )
71
+ post_rewrite_parser.add_argument(
72
+ "mode",
73
+ choices=["amend", "rebase"],
74
+ help="The rewrite mode passed by git",
75
+ )
76
+ post_rewrite_parser.add_argument("--run", default=None)
77
+ post_rewrite_parser.add_argument("--store-dir", default=None)
78
+
79
+ # post-commit subcommand (called by the hook script)
80
+ post_commit_parser = hook_sub.add_parser(
81
+ "post-commit",
82
+ help="Process a post-commit hook invocation (revert/cherry-pick fallback)",
83
+ )
84
+ post_commit_parser.add_argument("--run", default=None)
85
+ post_commit_parser.add_argument("--store-dir", default=None)
86
+ post_commit_parser.add_argument(
87
+ "--repo-path",
88
+ default=None,
89
+ help="Path to git repo root (default: cwd)",
90
+ )
91
+
92
+ # post-merge subcommand (called by the hook script)
93
+ post_merge_parser = hook_sub.add_parser(
94
+ "post-merge",
95
+ help="Process a post-merge hook invocation (adopt bare git merge)",
96
+ )
97
+ post_merge_parser.add_argument(
98
+ "squash",
99
+ nargs="?",
100
+ default="0",
101
+ help="1 if squash merge, 0 otherwise (passed by git)",
102
+ )
103
+ post_merge_parser.add_argument("--run", default=None)
104
+ post_merge_parser.add_argument("--store-dir", default=None)
105
+ post_merge_parser.add_argument(
106
+ "--repo-path",
107
+ default=None,
108
+ help="Path to git repo root (default: cwd)",
109
+ )
110
+
111
+ return parser
112
+
113
+
114
+ # ---------------------------------------------------------------------------
115
+ # install
116
+ # ---------------------------------------------------------------------------
117
+
118
+
119
+ def run_hook_install(
120
+ *,
121
+ repo_path: Path | None = None,
122
+ force: bool = False,
123
+ ) -> dict:
124
+ """Install .git/hooks/post-rewrite and .git/hooks/post-commit in the git repo.
125
+
126
+ Parameters
127
+ ----------
128
+ repo_path:
129
+ Path to the git repository root. Defaults to cwd.
130
+ force:
131
+ If True, overwrite existing hooks. If False and hooks already exist,
132
+ skip them and return status="skipped".
133
+
134
+ Returns
135
+ -------
136
+ dict with keys:
137
+ - status: "installed", "skipped", or "error"
138
+ - hook_path: absolute path to the post-rewrite hook file
139
+ - message: human-readable description
140
+ """
141
+ from arctx_cli.paths import find_repo_root # noqa: PLC0415
142
+
143
+ resolved_root: Path
144
+ if repo_path is not None:
145
+ resolved_root = Path(repo_path)
146
+ else:
147
+ try:
148
+ resolved_root = find_repo_root()
149
+ except RuntimeError as exc:
150
+ return {
151
+ "status": "error",
152
+ "hook_path": None,
153
+ "message": str(exc),
154
+ }
155
+
156
+ hooks_dir = resolved_root / ".git" / "hooks"
157
+ if not hooks_dir.exists():
158
+ return {
159
+ "status": "error",
160
+ "hook_path": None,
161
+ "message": f".git/hooks directory not found at {hooks_dir}",
162
+ }
163
+
164
+ post_rewrite_path = hooks_dir / "post-rewrite"
165
+ post_commit_path = hooks_dir / "post-commit"
166
+ post_merge_path = hooks_dir / "post-merge"
167
+
168
+ # Backward-compatible: if post-rewrite already exists and force=False, skip all.
169
+ if post_rewrite_path.exists() and not force:
170
+ return {
171
+ "status": "skipped",
172
+ "hook_path": str(post_rewrite_path),
173
+ "message": (
174
+ f"hook already exists at {post_rewrite_path}; "
175
+ "use --force to overwrite"
176
+ ),
177
+ }
178
+
179
+ # Install post-rewrite.
180
+ post_rewrite_path.write_text(_POST_REWRITE_HOOK, encoding="utf-8")
181
+ post_rewrite_path.chmod(0o755)
182
+
183
+ # Install post-commit (best-effort; skip silently if it already exists and not force).
184
+ if not post_commit_path.exists() or force:
185
+ post_commit_path.write_text(_POST_COMMIT_HOOK, encoding="utf-8")
186
+ post_commit_path.chmod(0o755)
187
+
188
+ # Install post-merge (best-effort; skip silently if it already exists and not force).
189
+ if not post_merge_path.exists() or force:
190
+ post_merge_path.write_text(_POST_MERGE_HOOK, encoding="utf-8")
191
+ post_merge_path.chmod(0o755)
192
+
193
+ return {
194
+ "status": "installed",
195
+ "hook_path": str(post_rewrite_path),
196
+ "message": f"installed post-rewrite, post-commit, and post-merge hooks under {hooks_dir}",
197
+ }
198
+
199
+
200
+ # ---------------------------------------------------------------------------
201
+ # post-rewrite
202
+ # ---------------------------------------------------------------------------
203
+
204
+
205
+ def run_hook_post_rewrite(
206
+ *,
207
+ mode: str,
208
+ run_id: str,
209
+ store_dir: str | None,
210
+ stdin_lines: list[str] | None = None,
211
+ user_id: str | None = None,
212
+ work_session_id: str | None = None,
213
+ ) -> dict:
214
+ """Process a post-rewrite hook invocation.
215
+
216
+ Reads sha_map from stdin (or ``stdin_lines`` for testing), calls
217
+ ``RunHandle.git.adopt_rewrite``, and persists the run.
218
+
219
+ Parameters
220
+ ----------
221
+ mode:
222
+ "amend" or "rebase" (the first argument git passes to post-rewrite).
223
+ run_id:
224
+ The arctx run to update.
225
+ store_dir:
226
+ Run store directory. If None, uses default.
227
+ stdin_lines:
228
+ Override stdin lines (for testing). Each line: "<old_sha> <new_sha>".
229
+ user_id:
230
+ User ID for work events.
231
+ work_session_id:
232
+ Work session ID for work events.
233
+
234
+ Returns
235
+ -------
236
+ dict with keys from ``adopt_rewrite``:
237
+ - affected_transitions, skipped_shas, event_id
238
+ """
239
+ import os # noqa: PLC0415
240
+
241
+ from arctx_cli.context import resolve_store # noqa: PLC0415
242
+
243
+ # Parse sha_map from stdin.
244
+ if stdin_lines is None:
245
+ stdin_lines = sys.stdin.read().splitlines()
246
+
247
+ sha_map: dict[str, str] = {}
248
+ for line in stdin_lines:
249
+ line = line.strip()
250
+ if not line:
251
+ continue
252
+ parts = line.split()
253
+ if len(parts) >= 2:
254
+ sha_map[parts[0]] = parts[1]
255
+
256
+ if not sha_map:
257
+ return {
258
+ "affected_transitions": [],
259
+ "skipped_shas": [],
260
+ "event_id": None,
261
+ }
262
+
263
+ # Resolve onto = last new_sha.
264
+ onto = list(sha_map.values())[-1]
265
+
266
+ # Resolve user / session from env if not provided.
267
+ if user_id is None:
268
+ user_id = os.environ.get("ARCTX_USER_ID", "user")
269
+ if work_session_id is None:
270
+ work_session_id = os.environ.get("ARCTX_WORK_SESSION_ID", "session_hook")
271
+
272
+ store = resolve_store(store_dir)
273
+ handle = store.load_run(run_id)
274
+
275
+ result = handle.git.adopt_rewrite(
276
+ sha_map=sha_map,
277
+ onto=onto,
278
+ mode=mode,
279
+ user_id=user_id,
280
+ work_session_id=work_session_id,
281
+ )
282
+
283
+ store.save_run(handle)
284
+ return result
285
+
286
+
287
+ # ---------------------------------------------------------------------------
288
+ # post-commit
289
+ # ---------------------------------------------------------------------------
290
+
291
+ # Regex patterns for detecting revert / cherry-pick from commit subject/body.
292
+ import re as _re
293
+
294
+ _REVERT_SUBJECT_RE = _re.compile(r'^Revert "(.+)"$')
295
+ _REVERT_SHA_RE = _re.compile(r"This reverts commit ([0-9a-f]{7,40})")
296
+ _CHERRY_PICK_RE = _re.compile(r"cherry picked from commit ([0-9a-f]{7,40})", _re.IGNORECASE)
297
+
298
+
299
+ def run_hook_post_commit(
300
+ *,
301
+ run_id: str,
302
+ store_dir: str | None,
303
+ repo_path: Path | None = None,
304
+ user_id: str | None = None,
305
+ work_session_id: str | None = None,
306
+ # Test injection: override HEAD info instead of running git.
307
+ head_sha: str | None = None,
308
+ head_subject: str | None = None,
309
+ head_body: str | None = None,
310
+ ) -> dict:
311
+ """Process a post-commit hook invocation.
312
+
313
+ Detects whether the newest commit is a bare ``git revert`` or
314
+ ``git cherry-pick`` (i.e. not driven by ``arctx revert`` / ``arctx
315
+ cherry-pick``) and records the appropriate arctx transition if so.
316
+
317
+ Detection logic
318
+ ---------------
319
+ 1. Read HEAD: sha, subject, full body via ``git log -1``.
320
+ 2. Check whether HEAD sha is already known to arctx
321
+ (``transition_by_sha``). If it is, arctx already recorded it —
322
+ return early.
323
+ 3. Match subject/body against revert / cherry-pick patterns.
324
+ 4. If matched, call ``handle.revert`` / ``handle.cherry_pick`` with
325
+ ``head_commit`` injected so git is not called a second time.
326
+
327
+ For revert, the reverted sha is extracted from the body line
328
+ ``This reverts commit <sha>.``
329
+
330
+ For cherry-pick, the source sha is extracted from the trailer
331
+ ``cherry picked from commit <sha>`` (added by ``git cherry-pick -x``).
332
+ If the trailer is absent, detection is skipped (best-effort only).
333
+
334
+ Returns
335
+ -------
336
+ dict with keys:
337
+ - action: "revert", "cherry_pick", "skip", or "warn"
338
+ - transition_id: the new arctx transition ID (or None)
339
+ - message: human-readable description
340
+ """
341
+ import os # noqa: PLC0415
342
+ import subprocess as _sp # noqa: PLC0415
343
+
344
+ from arctx_cli.context import resolve_store # noqa: PLC0415
345
+
346
+ resolved_repo_path: Path = resolve_worktree_path(repo_path)
347
+
348
+ # Resolve user / session from env if not provided.
349
+ if user_id is None:
350
+ user_id = os.environ.get("ARCTX_USER_ID", "user")
351
+ if work_session_id is None:
352
+ work_session_id = os.environ.get("ARCTX_WORK_SESSION_ID", "session_hook")
353
+
354
+ # ------------------------------------------------------------------
355
+ # 1. Read HEAD info (or use injected values for testing).
356
+ # ------------------------------------------------------------------
357
+ if head_sha is None or head_subject is None or head_body is None:
358
+ try:
359
+ log_result = _sp.run(
360
+ ["git", "log", "-1", "--format=%H%n%s%n%B"],
361
+ cwd=str(resolved_repo_path),
362
+ capture_output=True,
363
+ text=True,
364
+ check=True,
365
+ )
366
+ lines = log_result.stdout.splitlines()
367
+ head_sha = lines[0].strip() if lines else ""
368
+ head_subject = lines[1].strip() if len(lines) > 1 else ""
369
+ head_body = "\n".join(lines[2:]) if len(lines) > 2 else ""
370
+ except Exception as exc: # noqa: BLE001
371
+ return {
372
+ "action": "warn",
373
+ "transition_id": None,
374
+ "message": f"could not read git HEAD: {exc}",
375
+ }
376
+
377
+ if not head_sha:
378
+ return {
379
+ "action": "skip",
380
+ "transition_id": None,
381
+ "message": "no HEAD sha found",
382
+ }
383
+
384
+ # ------------------------------------------------------------------
385
+ # 2. Check if arctx already knows this sha.
386
+ # ------------------------------------------------------------------
387
+ store = resolve_store(store_dir)
388
+ try:
389
+ handle = store.load_run(run_id)
390
+ except Exception as exc: # noqa: BLE001
391
+ return {
392
+ "action": "warn",
393
+ "transition_id": None,
394
+ "message": f"could not load run: {exc}",
395
+ }
396
+
397
+ if transition_by_sha(handle.run_graph, head_sha) is not None:
398
+ # Already recorded by arctx revert/cherry-pick/commit — skip.
399
+ return {
400
+ "action": "skip",
401
+ "transition_id": None,
402
+ "message": "HEAD sha already recorded by arctx",
403
+ }
404
+
405
+ # ------------------------------------------------------------------
406
+ # 3. Detect revert.
407
+ # ------------------------------------------------------------------
408
+ revert_match = _REVERT_SUBJECT_RE.match(head_subject)
409
+ if revert_match:
410
+ sha_match = _REVERT_SHA_RE.search(head_body)
411
+ if sha_match:
412
+ reverted_sha = sha_match.group(1)
413
+ # Look up the reverted transition.
414
+ reverted_t = transition_by_sha(handle.run_graph, reverted_sha)
415
+ if reverted_t is None:
416
+ # We don't know the original commit — skip; can't link properly.
417
+ return {
418
+ "action": "warn",
419
+ "transition_id": None,
420
+ "message": (
421
+ f"post-commit: revert of {reverted_sha[:12]} detected but "
422
+ "original commit not in arctx graph; skipping"
423
+ ),
424
+ }
425
+ try:
426
+ transition = handle.git.revert(
427
+ target_transition=reverted_t,
428
+ user_id=user_id,
429
+ work_session_id=work_session_id,
430
+ head_commit=head_sha,
431
+ dry_run=True, # git already ran; just record
432
+ )
433
+ store.save_run(handle)
434
+ return {
435
+ "action": "revert",
436
+ "transition_id": transition.transition_id,
437
+ "message": (
438
+ f"post-commit: recorded revert of {reverted_sha[:12]} "
439
+ f"as {transition.transition_id}"
440
+ ),
441
+ }
442
+ except Exception as exc: # noqa: BLE001
443
+ return {
444
+ "action": "warn",
445
+ "transition_id": None,
446
+ "message": f"post-commit: failed to record revert: {exc}",
447
+ }
448
+
449
+ # ------------------------------------------------------------------
450
+ # 4. Detect cherry-pick (best-effort; requires -x trailer).
451
+ # ------------------------------------------------------------------
452
+ cp_match = _CHERRY_PICK_RE.search(head_body)
453
+ if cp_match:
454
+ source_sha = cp_match.group(1)
455
+ try:
456
+ transition = handle.git.cherry_pick(
457
+ source_sha=source_sha,
458
+ user_id=user_id,
459
+ work_session_id=work_session_id,
460
+ head_commit=head_sha,
461
+ dry_run=True, # git already ran; just record
462
+ )
463
+ store.save_run(handle)
464
+ return {
465
+ "action": "cherry_pick",
466
+ "transition_id": transition.transition_id,
467
+ "message": (
468
+ f"post-commit: recorded cherry-pick of {source_sha[:12]} "
469
+ f"as {transition.transition_id}"
470
+ ),
471
+ }
472
+ except Exception as exc: # noqa: BLE001
473
+ return {
474
+ "action": "warn",
475
+ "transition_id": None,
476
+ "message": f"post-commit: failed to record cherry-pick: {exc}",
477
+ }
478
+
479
+ # Not a revert or detectable cherry-pick — nothing to do.
480
+ return {
481
+ "action": "skip",
482
+ "transition_id": None,
483
+ "message": "post-commit: not a revert or cherry-pick (no pattern matched)",
484
+ }
485
+
486
+
487
+ # ---------------------------------------------------------------------------
488
+ # post-merge
489
+ # ---------------------------------------------------------------------------
490
+
491
+
492
+ def run_hook_post_merge(
493
+ *,
494
+ run_id: str,
495
+ store_dir: str | None,
496
+ repo_path: Path | None = None,
497
+ squash: bool = False,
498
+ user_id: str | None = None,
499
+ work_session_id: str | None = None,
500
+ # Test injection: override HEAD info.
501
+ head_sha: str | None = None,
502
+ ) -> dict:
503
+ """Process a post-merge hook invocation.
504
+
505
+ Detects whether the newest commit is a merge commit that arctx has not
506
+ yet recorded, and adopts it by calling ``handle.git.merge(dry_run=True)``.
507
+
508
+ Detection logic
509
+ ---------------
510
+ 1. Read HEAD sha via ``git rev-parse HEAD``.
511
+ 2. Check if HEAD sha is already known to arctx. If yes, skip (arctx merge
512
+ already ran via ``arctx merge`` or ``arctx commit --merge``).
513
+ 3. Check if HEAD has two parents (``git rev-parse HEAD^2`` succeeds).
514
+ If squash merge (squash=True), HEAD has only one parent but this hook
515
+ still fires — log a warning and skip.
516
+ 4. If a real merge commit: call ``handle.git.merge(dry_run=True, ...)`` to
517
+ adopt it into the arctx graph.
518
+
519
+ Returns
520
+ -------
521
+ dict with keys:
522
+ - action: "adopted", "skip", or "warn"
523
+ - transition_id: new arctx transition ID (or None)
524
+ - message: human-readable description
525
+ """
526
+ import os # noqa: PLC0415
527
+ import subprocess as _sp # noqa: PLC0415
528
+
529
+ from arctx_cli.context import resolve_store # noqa: PLC0415
530
+
531
+ resolved_repo_path: Path = resolve_worktree_path(repo_path)
532
+
533
+ if user_id is None:
534
+ user_id = os.environ.get("ARCTX_USER_ID", "user")
535
+ if work_session_id is None:
536
+ work_session_id = os.environ.get("ARCTX_WORK_SESSION_ID", "session_hook")
537
+
538
+ # ------------------------------------------------------------------
539
+ # 1. Squash merge: cannot adopt automatically (no merge commit).
540
+ # ------------------------------------------------------------------
541
+ if squash:
542
+ return {
543
+ "action": "skip",
544
+ "transition_id": None,
545
+ "message": "post-merge: squash merge — skipping automatic adoption",
546
+ }
547
+
548
+ # ------------------------------------------------------------------
549
+ # 2. Read HEAD sha.
550
+ # ------------------------------------------------------------------
551
+ if head_sha is None:
552
+ try:
553
+ result = _sp.run(
554
+ ["git", "rev-parse", "HEAD"],
555
+ cwd=str(resolved_repo_path),
556
+ capture_output=True,
557
+ text=True,
558
+ check=True,
559
+ )
560
+ head_sha = result.stdout.strip()
561
+ except Exception as exc: # noqa: BLE001
562
+ return {
563
+ "action": "warn",
564
+ "transition_id": None,
565
+ "message": f"post-merge: could not read HEAD sha: {exc}",
566
+ }
567
+
568
+ if not head_sha:
569
+ return {
570
+ "action": "skip",
571
+ "transition_id": None,
572
+ "message": "post-merge: no HEAD sha found",
573
+ }
574
+
575
+ # ------------------------------------------------------------------
576
+ # 3. Load run and check if already known.
577
+ # ------------------------------------------------------------------
578
+ store = resolve_store(store_dir)
579
+ try:
580
+ handle = store.load_run(run_id)
581
+ except Exception as exc: # noqa: BLE001
582
+ return {
583
+ "action": "warn",
584
+ "transition_id": None,
585
+ "message": f"post-merge: could not load run: {exc}",
586
+ }
587
+
588
+ if transition_by_sha(handle.run_graph, head_sha) is not None:
589
+ return {
590
+ "action": "skip",
591
+ "transition_id": None,
592
+ "message": "post-merge: HEAD sha already recorded by arctx",
593
+ }
594
+
595
+ # ------------------------------------------------------------------
596
+ # 4. Verify HEAD is actually a merge commit (has ^2 parent).
597
+ # ------------------------------------------------------------------
598
+ try:
599
+ p2_result = _sp.run(
600
+ ["git", "rev-parse", "--verify", "HEAD^2"],
601
+ cwd=str(resolved_repo_path),
602
+ capture_output=True,
603
+ text=True,
604
+ )
605
+ if p2_result.returncode != 0:
606
+ return {
607
+ "action": "skip",
608
+ "transition_id": None,
609
+ "message": "post-merge: HEAD is not a merge commit (no second parent)",
610
+ }
611
+ other_sha = p2_result.stdout.strip()
612
+ except Exception as exc: # noqa: BLE001
613
+ return {
614
+ "action": "warn",
615
+ "transition_id": None,
616
+ "message": f"post-merge: could not verify merge parents: {exc}",
617
+ }
618
+
619
+ # ------------------------------------------------------------------
620
+ # 5. Adopt: call merge with dry_run=True (git already ran).
621
+ # ------------------------------------------------------------------
622
+ # Look up the other node by its sha, if known.
623
+ other_node_id: str | None = None
624
+ other_transition_id = transition_by_sha(handle.run_graph, other_sha)
625
+ if other_transition_id is not None:
626
+ other_t = handle.run_graph.transitions.get(other_transition_id)
627
+ if other_t is not None:
628
+ other_node_id = other_t.output_node_id
629
+
630
+ try:
631
+ transition = handle.git.merge(
632
+ other_node_id=other_node_id,
633
+ other_branch=None, # branch unknown from hook context
634
+ head_commit=head_sha,
635
+ user_id=user_id,
636
+ work_session_id=work_session_id,
637
+ dry_run=True, # git already merged
638
+ )
639
+ store.save_run(handle)
640
+ return {
641
+ "action": "adopted",
642
+ "transition_id": transition.transition_id,
643
+ "message": (
644
+ f"post-merge: adopted merge commit {head_sha[:12]} "
645
+ f"as {transition.transition_id}"
646
+ ),
647
+ }
648
+ except Exception as exc: # noqa: BLE001
649
+ return {
650
+ "action": "warn",
651
+ "transition_id": None,
652
+ "message": f"post-merge: could not adopt merge: {exc}",
653
+ }
654
+
655
+
656
+ # ---------------------------------------------------------------------------
657
+ # CLI dispatcher
658
+ # ---------------------------------------------------------------------------
659
+
660
+
661
+ def cli_hook(args) -> int:
662
+ """Entry point for ``arctx hook`` subcommands."""
663
+ if args.hook_command == "install":
664
+ repo_path = Path(args.repo_path) if args.repo_path else None
665
+ result = run_hook_install(repo_path=repo_path, force=args.force)
666
+ if result["status"] == "error":
667
+ print(f"error: {result['message']}", file=sys.stderr)
668
+ return 1
669
+ if result["status"] == "skipped":
670
+ print(f"warning: {result['message']}", file=sys.stderr)
671
+ return 0
672
+ print(result["message"])
673
+ return 0
674
+
675
+ if args.hook_command == "post-rewrite":
676
+ import os # noqa: PLC0415
677
+
678
+ from arctx_cli.context import resolve_run_id_from_args # noqa: PLC0415
679
+
680
+ try:
681
+ run_id = resolve_run_id_from_args(args)
682
+ except Exception as exc:
683
+ print(f"arctx hook post-rewrite: could not resolve run: {exc}", file=sys.stderr)
684
+ # Exit 0 so git continues even if arctx can't find the run.
685
+ return 0
686
+
687
+ result = run_hook_post_rewrite(
688
+ mode=args.mode,
689
+ run_id=run_id,
690
+ store_dir=args.store_dir,
691
+ user_id=os.environ.get("ARCTX_USER_ID"),
692
+ work_session_id=os.environ.get("ARCTX_WORK_SESSION_ID"),
693
+ )
694
+ n_affected = len(result.get("affected_transitions", []))
695
+ n_skipped = len(result.get("skipped_shas", []))
696
+ print(
697
+ f"arctx: post-rewrite ({args.mode}): "
698
+ f"{n_affected} transition(s) updated, {n_skipped} sha(s) skipped",
699
+ file=sys.stderr,
700
+ )
701
+ return 0
702
+
703
+ if args.hook_command == "post-commit":
704
+ import os # noqa: PLC0415
705
+
706
+ from arctx_cli.context import resolve_run_id_from_args # noqa: PLC0415
707
+
708
+ try:
709
+ run_id = resolve_run_id_from_args(args)
710
+ except Exception as exc:
711
+ print(f"arctx hook post-commit: could not resolve run: {exc}", file=sys.stderr)
712
+ return 0
713
+
714
+ repo_path = Path(args.repo_path) if getattr(args, "repo_path", None) else None
715
+ result = run_hook_post_commit(
716
+ run_id=run_id,
717
+ store_dir=args.store_dir,
718
+ repo_path=repo_path,
719
+ user_id=os.environ.get("ARCTX_USER_ID"),
720
+ work_session_id=os.environ.get("ARCTX_WORK_SESSION_ID"),
721
+ )
722
+ print(
723
+ f"arctx: post-commit: {result['action']}: {result['message']}",
724
+ file=sys.stderr,
725
+ )
726
+ return 0
727
+
728
+ if args.hook_command == "post-merge":
729
+ import os # noqa: PLC0415
730
+
731
+ from arctx_cli.context import resolve_run_id_from_args # noqa: PLC0415
732
+
733
+ try:
734
+ run_id = resolve_run_id_from_args(args)
735
+ except Exception as exc:
736
+ print(f"arctx hook post-merge: could not resolve run: {exc}", file=sys.stderr)
737
+ # Exit 0 so git continues even if arctx can't find the run.
738
+ return 0
739
+
740
+ repo_path = Path(args.repo_path) if getattr(args, "repo_path", None) else None
741
+ squash_arg = getattr(args, "squash", "0")
742
+ squash = squash_arg == "1"
743
+
744
+ result = run_hook_post_merge(
745
+ run_id=run_id,
746
+ store_dir=args.store_dir,
747
+ repo_path=repo_path,
748
+ squash=squash,
749
+ user_id=os.environ.get("ARCTX_USER_ID"),
750
+ work_session_id=os.environ.get("ARCTX_WORK_SESSION_ID"),
751
+ )
752
+ print(
753
+ f"arctx: post-merge: {result['action']}: {result['message']}",
754
+ file=sys.stderr,
755
+ )
756
+ return 0
757
+
758
+ return 1