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.
- arctx_cli/__init__.py +1 -0
- arctx_cli/alias.py +238 -0
- arctx_cli/append_batch.py +90 -0
- arctx_cli/commands/__init__.py +85 -0
- arctx_cli/commands/alias_cmd.py +174 -0
- arctx_cli/commands/anchor.py +82 -0
- arctx_cli/commands/current.py +69 -0
- arctx_cli/commands/cut.py +89 -0
- arctx_cli/commands/dump.py +72 -0
- arctx_cli/commands/ext.py +236 -0
- arctx_cli/commands/git.py +216 -0
- arctx_cli/commands/graph.py +73 -0
- arctx_cli/commands/guide.py +360 -0
- arctx_cli/commands/init.py +223 -0
- arctx_cli/commands/list.py +45 -0
- arctx_cli/commands/migrate.py +135 -0
- arctx_cli/commands/node.py +55 -0
- arctx_cli/commands/outcomes.py +58 -0
- arctx_cli/commands/payload.py +192 -0
- arctx_cli/commands/reachable.py +75 -0
- arctx_cli/commands/show.py +113 -0
- arctx_cli/commands/sync.py +244 -0
- arctx_cli/commands/trace.py +46 -0
- arctx_cli/commands/transition.py +212 -0
- arctx_cli/commands/use.py +67 -0
- arctx_cli/commands/view.py +82 -0
- arctx_cli/commands/work_session.py +330 -0
- arctx_cli/context.py +38 -0
- arctx_cli/ext/__init__.py +1 -0
- arctx_cli/ext/command/__init__.py +110 -0
- arctx_cli/ext/git/__init__.py +1 -0
- arctx_cli/ext/git/branch.py +140 -0
- arctx_cli/ext/git/cherry_pick.py +144 -0
- arctx_cli/ext/git/commit.py +205 -0
- arctx_cli/ext/git/hook.py +758 -0
- arctx_cli/ext/git/merge.py +204 -0
- arctx_cli/ext/git/reset.py +138 -0
- arctx_cli/ext/git/revert.py +157 -0
- arctx_cli/ext/git/verify.py +140 -0
- arctx_cli/ext/git/worktree.py +173 -0
- arctx_cli/ext_registry.py +34 -0
- arctx_cli/main.py +133 -0
- arctx_cli/paths.py +27 -0
- arctx_cli/payload_builder.py +23 -0
- arctx_cli/workspace.py +64 -0
- arctx_cli-0.2.0b2.dist-info/METADATA +48 -0
- arctx_cli-0.2.0b2.dist-info/RECORD +49 -0
- arctx_cli-0.2.0b2.dist-info/WHEEL +4 -0
- 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
|