chsum 3.1.0__tar.gz → 3.2.0__tar.gz
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.
- {chsum-3.1.0 → chsum-3.2.0}/.gitignore +1 -0
- {chsum-3.1.0 → chsum-3.2.0}/PKG-INFO +13 -3
- {chsum-3.1.0 → chsum-3.2.0}/README.md +12 -2
- {chsum-3.1.0 → chsum-3.2.0}/chsum/checkpoints.py +207 -64
- {chsum-3.1.0 → chsum-3.2.0}/chsum/core.py +2544 -150
- chsum-3.2.0/chsum/observe.py +2064 -0
- chsum-3.2.0/chsum/sessions.py +52 -0
- {chsum-3.1.0 → chsum-3.2.0}/pyproject.toml +1 -1
- {chsum-3.1.0 → chsum-3.2.0}/skills/chsum/SKILL.md +68 -2
- chsum-3.2.0/skills/chsum/references/observe.md +103 -0
- {chsum-3.1.0 → chsum-3.2.0}/tests/test_branches.py +2 -2
- chsum-3.2.0/tests/test_cache.py +213 -0
- chsum-3.2.0/tests/test_rewind.py +356 -0
- {chsum-3.1.0 → chsum-3.2.0}/CONTRIBUTING.md +0 -0
- {chsum-3.1.0 → chsum-3.2.0}/LICENSE +0 -0
- {chsum-3.1.0 → chsum-3.2.0}/chsum/__init__.py +0 -0
- {chsum-3.1.0 → chsum-3.2.0}/chsum/__main__.py +0 -0
- {chsum-3.1.0 → chsum-3.2.0}/chsum/sources/__init__.py +0 -0
- {chsum-3.1.0 → chsum-3.2.0}/chsum/sources/claude.py +0 -0
- {chsum-3.1.0 → chsum-3.2.0}/chsum/sources/codex.py +0 -0
- {chsum-3.1.0 → chsum-3.2.0}/chsum.py +0 -0
- {chsum-3.1.0 → chsum-3.2.0}/skills/chsum/references/checkpoints.md +0 -0
- {chsum-3.1.0 → chsum-3.2.0}/skills/chsum/references/debugging.md +0 -0
- {chsum-3.1.0 → chsum-3.2.0}/skills/chsum/references/naming.md +0 -0
- {chsum-3.1.0 → chsum-3.2.0}/skills/chsum/references/noting.md +0 -0
- {chsum-3.1.0 → chsum-3.2.0}/skills/chsum/references/recap.md +0 -0
- {chsum-3.1.0 → chsum-3.2.0}/tests/harness.py +0 -0
- {chsum-3.1.0 → chsum-3.2.0}/tests/test_checkpoints.py +0 -0
- {chsum-3.1.0 → chsum-3.2.0}/tests/test_codex_source.py +0 -0
- {chsum-3.1.0 → chsum-3.2.0}/tests/test_handbacks.py +0 -0
- {chsum-3.1.0 → chsum-3.2.0}/tests/test_undo.py +0 -0
- {chsum-3.1.0 → chsum-3.2.0}/tests/test_views.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: chsum
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.2.0
|
|
4
4
|
Summary: Work logs and reload-ready context from coding-agent conversations. Deterministic: no model, nothing invented.
|
|
5
5
|
Author: InDate
|
|
6
6
|
License-Expression: MIT
|
|
@@ -501,7 +501,11 @@ chsum warm [ref] # ping an idle session before expiry, until Ctrl-C; -
|
|
|
501
501
|
agent with a one-hour cache (`cacheTtl: 1h`); long work you return to suits
|
|
502
502
|
its own session, which keeps the hour, takes messages and takes `chsum warm`.
|
|
503
503
|
- **`chsum warm`** resumes the session without saving a turn, so the transcript
|
|
504
|
-
stays as it was.
|
|
504
|
+
stays as it was. It pings after a random wait of 30 to 58 minutes on
|
|
505
|
+
one-hour entries (half to 58/60 of the lifetime), counted from the last
|
|
506
|
+
request or ping. A ping that fails, a hook block included, stops it with the
|
|
507
|
+
reply's own error text. Each ping draws on your usage limits. Agents fall
|
|
508
|
+
outside it.
|
|
505
509
|
|
|
506
510
|
The plugin's hooks put the same figures where the choice is made: a message
|
|
507
511
|
that would resume a cold agent, or wake an idle session with a cold cache,
|
|
@@ -679,7 +683,13 @@ command, and nothing reaches its context between runs.
|
|
|
679
683
|
|
|
680
684
|
Every response in a transcript records the cache it read and wrote, split into
|
|
681
685
|
one-hour and five-minute writes, so a session's or an agent's cache expiry is
|
|
682
|
-
computed from the transcript with no request sent.
|
|
686
|
+
computed from the transcript with no request sent. The clock runs from the
|
|
687
|
+
time the request went out, the record the reply hangs off, since an entry's
|
|
688
|
+
lifetime counts from the request's start and the reply's own records are stamped
|
|
689
|
+
as it streams. A `chsum warm` ping leaves no record in the transcript, so each
|
|
690
|
+
ping that hits is logged in `pings/<session>.jsonl` in chsum's data directory;
|
|
691
|
+
`chsum cache` and both hooks count the later of the last request and the last
|
|
692
|
+
logged ping. Measured on a subscription:
|
|
683
693
|
the main session's cache outlived a five-minute gap; an agent's did not, and
|
|
684
694
|
`subagentPromptCacheTtl: "1h"` held it; a resumed agent read its cache only if
|
|
685
695
|
it had made a tool call; a message to another idle session read that
|
|
@@ -490,7 +490,11 @@ chsum warm [ref] # ping an idle session before expiry, until Ctrl-C; -
|
|
|
490
490
|
agent with a one-hour cache (`cacheTtl: 1h`); long work you return to suits
|
|
491
491
|
its own session, which keeps the hour, takes messages and takes `chsum warm`.
|
|
492
492
|
- **`chsum warm`** resumes the session without saving a turn, so the transcript
|
|
493
|
-
stays as it was.
|
|
493
|
+
stays as it was. It pings after a random wait of 30 to 58 minutes on
|
|
494
|
+
one-hour entries (half to 58/60 of the lifetime), counted from the last
|
|
495
|
+
request or ping. A ping that fails, a hook block included, stops it with the
|
|
496
|
+
reply's own error text. Each ping draws on your usage limits. Agents fall
|
|
497
|
+
outside it.
|
|
494
498
|
|
|
495
499
|
The plugin's hooks put the same figures where the choice is made: a message
|
|
496
500
|
that would resume a cold agent, or wake an idle session with a cold cache,
|
|
@@ -668,7 +672,13 @@ command, and nothing reaches its context between runs.
|
|
|
668
672
|
|
|
669
673
|
Every response in a transcript records the cache it read and wrote, split into
|
|
670
674
|
one-hour and five-minute writes, so a session's or an agent's cache expiry is
|
|
671
|
-
computed from the transcript with no request sent.
|
|
675
|
+
computed from the transcript with no request sent. The clock runs from the
|
|
676
|
+
time the request went out, the record the reply hangs off, since an entry's
|
|
677
|
+
lifetime counts from the request's start and the reply's own records are stamped
|
|
678
|
+
as it streams. A `chsum warm` ping leaves no record in the transcript, so each
|
|
679
|
+
ping that hits is logged in `pings/<session>.jsonl` in chsum's data directory;
|
|
680
|
+
`chsum cache` and both hooks count the later of the last request and the last
|
|
681
|
+
logged ping. Measured on a subscription:
|
|
672
682
|
the main session's cache outlived a five-minute gap; an agent's did not, and
|
|
673
683
|
`subagentPromptCacheTtl: "1h"` held it; a resumed agent read its cache only if
|
|
674
684
|
it had made a tool call; a message to another idle session read that
|
|
@@ -40,6 +40,13 @@ LOCK_NAME = "chsum-checkpoint.lock"
|
|
|
40
40
|
_LOCK_WAIT = 20.0
|
|
41
41
|
_LOCK_POLL = 0.05
|
|
42
42
|
_CHECKPOINT_PREFIX = "chsum-checkpoint: "
|
|
43
|
+
# The action of a checkpoint holding what changed between two of a session's
|
|
44
|
+
# calls by any other writer. It carries no call and no step.
|
|
45
|
+
OUTSIDE = "outside"
|
|
46
|
+
# The trees `record_pre` writes, one file per call, under the worktree's git
|
|
47
|
+
# directory, and how long one outlives a call that never reached PostToolUse.
|
|
48
|
+
_PRE_DIR = "chsum-pre"
|
|
49
|
+
_PRE_LIFE = 86400
|
|
43
50
|
_HOOK_GIT_TIMEOUT = 30 # seconds per git call — this must never be what hangs a turn
|
|
44
51
|
# The subject's tail is the call that wrote the change, or, for a checkpoint
|
|
45
52
|
# `chsum undo` or `chsum redo` wrote, that action and the stamp of the step it
|
|
@@ -47,7 +54,8 @@ _HOOK_GIT_TIMEOUT = 30 # seconds per git call — this must never be what hangs
|
|
|
47
54
|
# step. `_checkpoint_message` builds what this parses.
|
|
48
55
|
_CHECKPOINT_RE = re.compile(
|
|
49
56
|
"^" + re.escape(_CHECKPOINT_PREFIX) + r"(?P<session>\S+) @ (?P<when>\S+)"
|
|
50
|
-
r"(?: (?P<action>undo|redo) (?P<step>[^\s/]+)(?:/(?P<part>[a-z]+))?| (?P<
|
|
57
|
+
r"(?: (?P<action>undo|redo) (?P<step>[^\s/]+)(?:/(?P<part>[a-z]+))?| (?P<outside>outside)"
|
|
58
|
+
r"| (?P<call>\S+))?$")
|
|
51
59
|
_HUNK_RE = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@")
|
|
52
60
|
|
|
53
61
|
# `core` installs its tracer here at import. Left None, every call below runs
|
|
@@ -57,7 +65,7 @@ TRACER = None
|
|
|
57
65
|
|
|
58
66
|
def _checkpoint_message(session_id: str, when: str, call: str = "",
|
|
59
67
|
action: str = "", step: str = "", part: str = "") -> str:
|
|
60
|
-
tail = (f" {action} {step}" + (f"/{part}" if part else "") if action
|
|
68
|
+
tail = (f" {action}" + (f" {step}" if step else "") + (f"/{part}" if part else "") if action
|
|
61
69
|
else f" {call}" if call else "")
|
|
62
70
|
return f"{_CHECKPOINT_PREFIX}{session_id} @ {when}{tail}"
|
|
63
71
|
|
|
@@ -66,8 +74,10 @@ def _checkpoint_message(session_id: str, when: str, call: str = "",
|
|
|
66
74
|
class ChainEntry:
|
|
67
75
|
"""One checkpoint on a session's chain. `action` is "undo" or "redo" on a
|
|
68
76
|
checkpoint those commands wrote, with `step` the stamp of the checkpoint
|
|
69
|
-
they reversed or re-applied; both are "" on one the hook wrote. `
|
|
70
|
-
|
|
77
|
+
they reversed or re-applied; both are "" on one the hook wrote. `action` is
|
|
78
|
+
`OUTSIDE` on a checkpoint holding other writers' changes, with no step and
|
|
79
|
+
no call. `part` is the letter of the one file of that step it acted on, ""
|
|
80
|
+
for the whole step."""
|
|
71
81
|
when: str
|
|
72
82
|
sha: str
|
|
73
83
|
call: str = ""
|
|
@@ -203,13 +213,16 @@ def _tree_of(cwd: pathlib.Path, commit: str) -> str:
|
|
|
203
213
|
|
|
204
214
|
def _write_checkpoint(cwd: pathlib.Path, git_dir: pathlib.Path, session_id: str,
|
|
205
215
|
ref: str = "", action: str = "", step: str = "",
|
|
206
|
-
part: str = "") -> str:
|
|
216
|
+
part: str = "", pre_tree: str = "") -> str:
|
|
207
217
|
"""One checkpoint per tool call that changed the tree, chained under this
|
|
208
218
|
session's ref. `ref` is the `tool_use` id of the call that caused it, "" where
|
|
209
219
|
the payload carries none. `action` and `step` are set by `chsum undo` and
|
|
210
220
|
`chsum redo`: the action, and the stamp of the checkpoint it reversed or
|
|
211
|
-
re-applied, with `part` the letter of the one file it acted on.
|
|
212
|
-
|
|
221
|
+
re-applied, with `part` the letter of the one file it acted on. `pre_tree`
|
|
222
|
+
is the tree `record_pre` wrote before the call: with it, changes made since
|
|
223
|
+
the last checkpoint by anything other than the call go into an `outside`
|
|
224
|
+
checkpoint beneath the call's, so the call's diff holds its own paths only
|
|
225
|
+
(`_outside_commit`). Returns the checkpoint written, "" where none was.
|
|
213
226
|
|
|
214
227
|
`HEAD` is never written. The commit object is built by `commit-tree` from a
|
|
215
228
|
tree and a parent, and only `update-ref` publishes it, so no step of this
|
|
@@ -235,43 +248,14 @@ def _write_checkpoint(cwd: pathlib.Path, git_dir: pathlib.Path, session_id: str,
|
|
|
235
248
|
if not status.stdout.strip() and not action:
|
|
236
249
|
return ""
|
|
237
250
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
except (OSError, subprocess.SubprocessError):
|
|
251
|
+
tree_sha = _worktree_tree(cwd)
|
|
252
|
+
if not tree_sha:
|
|
241
253
|
return ""
|
|
242
|
-
|
|
254
|
+
# A tree equal to the one before the call means the call wrote nothing,
|
|
255
|
+
# whatever else changed since the last checkpoint: those changes wait for
|
|
256
|
+
# the next call that writes, and go into its `outside` checkpoint.
|
|
257
|
+
if pre_tree == tree_sha and not action:
|
|
243
258
|
return ""
|
|
244
|
-
# Absolute, so the environment holds one path whatever `cwd` git resolves to.
|
|
245
|
-
index_path = pathlib.Path(index.stdout.strip())
|
|
246
|
-
if not index_path.is_absolute():
|
|
247
|
-
index_path = (cwd / index_path).resolve()
|
|
248
|
-
env = {**os.environ, "GIT_INDEX_FILE": str(index_path)}
|
|
249
|
-
|
|
250
|
-
try:
|
|
251
|
-
staged = _git(["git", "add", "-A"], cwd, env=env)
|
|
252
|
-
if staged.returncode != 0:
|
|
253
|
-
return ""
|
|
254
|
-
tree = _git(["git", "write-tree"], cwd, env=env)
|
|
255
|
-
except (OSError, subprocess.SubprocessError):
|
|
256
|
-
return ""
|
|
257
|
-
finally:
|
|
258
|
-
# The throwaway index is rebuilt from scratch on the next call, so a
|
|
259
|
-
# stale one costs nothing; leaving it does not touch the user's.
|
|
260
|
-
pass
|
|
261
|
-
if tree.returncode != 0:
|
|
262
|
-
return ""
|
|
263
|
-
tree_sha = tree.stdout.strip()
|
|
264
|
-
|
|
265
|
-
session_ref = checkpoint_ref(session_id)
|
|
266
|
-
tip = _ref_tip(cwd, session_ref)
|
|
267
|
-
# A call that wrote nothing leaves the tree equal to the last checkpoint's:
|
|
268
|
-
# the working tree differs from `HEAD` for the whole session, so the status
|
|
269
|
-
# check above passes on every call and a `Read` would otherwise chain a copy.
|
|
270
|
-
if tip and _tree_of(cwd, tip) == tree_sha:
|
|
271
|
-
return ""
|
|
272
|
-
parent = tip or _head_sha(cwd)
|
|
273
|
-
if not parent:
|
|
274
|
-
return "" # no commits yet — nothing to parent the first checkpoint on
|
|
275
259
|
|
|
276
260
|
now = datetime.now(timezone.utc)
|
|
277
261
|
# Millisecond precision, matching Claude Code's transcript timestamps: every
|
|
@@ -283,37 +267,185 @@ def _write_checkpoint(cwd: pathlib.Path, git_dir: pathlib.Path, session_id: str,
|
|
|
283
267
|
# that address at read time — the transcript record holding the call is
|
|
284
268
|
# flushed after the hook fires, so the row does not exist yet here.
|
|
285
269
|
message = _checkpoint_message(session_id, ts, ref, action, step, part)
|
|
286
|
-
|
|
287
|
-
try:
|
|
288
|
-
made = _git(["git", "commit-tree", tree_sha, "-p", parent, "-m", message], cwd)
|
|
289
|
-
except (OSError, subprocess.SubprocessError):
|
|
290
|
-
return ""
|
|
291
|
-
if made.returncode != 0:
|
|
292
|
-
return ""
|
|
293
|
-
commit = made.stdout.strip()
|
|
270
|
+
session_ref = checkpoint_ref(session_id)
|
|
294
271
|
|
|
295
272
|
# Compare-and-swap on the tip: parallel subagents share one worktree, and two
|
|
296
273
|
# hooks reading the same tip would otherwise fork the chain. The loser sees
|
|
297
274
|
# its expected old value fail and takes the retry, which re-reads the tip and
|
|
298
275
|
# chains onto the winner.
|
|
299
276
|
for _ in range(2):
|
|
277
|
+
tip = _ref_tip(cwd, session_ref)
|
|
278
|
+
# A call that wrote nothing leaves the tree equal to the last checkpoint's:
|
|
279
|
+
# the working tree differs from `HEAD` for the whole session, so the status
|
|
280
|
+
# check above passes on every call and a `Read` would otherwise chain a copy.
|
|
281
|
+
if tip and _tree_of(cwd, tip) == tree_sha:
|
|
282
|
+
return ""
|
|
283
|
+
parent = tip or _head_sha(cwd)
|
|
284
|
+
if not parent:
|
|
285
|
+
return "" # no commits yet — nothing to parent the first checkpoint on
|
|
286
|
+
if pre_tree:
|
|
287
|
+
parent = _outside_commit(cwd, session_id, ts, parent, pre_tree, tree_sha)
|
|
288
|
+
if parent and _tree_of(cwd, parent) == tree_sha:
|
|
289
|
+
# Every change since the last checkpoint came from outside the call:
|
|
290
|
+
# the `outside` checkpoint alone records them.
|
|
291
|
+
commit, made_one = parent, False
|
|
292
|
+
else:
|
|
293
|
+
try:
|
|
294
|
+
made = _git(["git", "commit-tree", tree_sha, "-p", parent, "-m", message], cwd)
|
|
295
|
+
except (OSError, subprocess.SubprocessError):
|
|
296
|
+
return ""
|
|
297
|
+
if made.returncode != 0:
|
|
298
|
+
return ""
|
|
299
|
+
commit, made_one = made.stdout.strip(), True
|
|
300
|
+
if commit == tip:
|
|
301
|
+
return ""
|
|
300
302
|
try:
|
|
301
|
-
done = _git(["git", "update-ref", session_ref, commit,
|
|
302
|
-
tip or _EMPTY_SHA], cwd)
|
|
303
|
+
done = _git(["git", "update-ref", session_ref, commit, tip or _EMPTY_SHA], cwd)
|
|
303
304
|
except (OSError, subprocess.SubprocessError):
|
|
304
305
|
return ""
|
|
305
306
|
if done.returncode == 0:
|
|
306
|
-
return commit
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
307
|
+
return commit if made_one else ""
|
|
308
|
+
return ""
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _git_path(cwd: pathlib.Path, name: str) -> pathlib.Path | None:
|
|
312
|
+
"""`name` under this worktree's git directory, absolute, or None where git
|
|
313
|
+
returns none. Per worktree, so two worktrees never share a staging file."""
|
|
314
|
+
try:
|
|
315
|
+
proc = _git(["git", "rev-parse", "--git-path", name], cwd)
|
|
316
|
+
except (OSError, subprocess.SubprocessError):
|
|
317
|
+
return None
|
|
318
|
+
if proc.returncode != 0:
|
|
319
|
+
return None
|
|
320
|
+
path = pathlib.Path(proc.stdout.strip())
|
|
321
|
+
return path if path.is_absolute() else (cwd / path).resolve()
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _index_env(cwd: pathlib.Path) -> dict | None:
|
|
325
|
+
"""The environment that points git at the throwaway index, so the user's
|
|
326
|
+
staging area is never in the sequence."""
|
|
327
|
+
index_path = _git_path(cwd, _INDEX_NAME)
|
|
328
|
+
return {**os.environ, "GIT_INDEX_FILE": str(index_path)} if index_path else None
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _worktree_tree(cwd: pathlib.Path) -> str:
|
|
332
|
+
"""The tree of the working tree as it stands, every file `git add -A`
|
|
333
|
+
stages, written through the throwaway index; "" where git fails."""
|
|
334
|
+
env = _index_env(cwd)
|
|
335
|
+
if env is None:
|
|
336
|
+
return ""
|
|
337
|
+
try:
|
|
338
|
+
staged = _git(["git", "add", "-A"], cwd, env=env)
|
|
339
|
+
if staged.returncode != 0:
|
|
340
|
+
return ""
|
|
341
|
+
tree = _git(["git", "write-tree"], cwd, env=env)
|
|
342
|
+
except (OSError, subprocess.SubprocessError):
|
|
343
|
+
return ""
|
|
344
|
+
return tree.stdout.strip() if tree.returncode == 0 else ""
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _changed_paths(cwd: pathlib.Path, old: str, new: str) -> set[str]:
|
|
348
|
+
try:
|
|
349
|
+
proc = _git(["git", "diff-tree", "-r", "--name-only", "-z", old, new], cwd)
|
|
350
|
+
except (OSError, subprocess.SubprocessError):
|
|
351
|
+
return set()
|
|
352
|
+
return {p for p in proc.stdout.split("\0") if p} if proc.returncode == 0 else set()
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _tree_with(cwd: pathlib.Path, tree: str, source: str, paths: set[str]) -> str:
|
|
356
|
+
"""`tree` with each of `paths` set to its entry in `source`, and removed
|
|
357
|
+
where `source` holds none; "" where git fails."""
|
|
358
|
+
env = _index_env(cwd)
|
|
359
|
+
if env is None:
|
|
360
|
+
return ""
|
|
361
|
+
try:
|
|
362
|
+
if _git(["git", "read-tree", tree], cwd, env=env).returncode != 0:
|
|
363
|
+
return ""
|
|
364
|
+
listed = _git(["git", "ls-tree", "-r", "-z", source, "--", *sorted(paths)], cwd)
|
|
365
|
+
if listed.returncode != 0:
|
|
313
366
|
return ""
|
|
314
|
-
|
|
367
|
+
entries = {}
|
|
368
|
+
for row in listed.stdout.split("\0"):
|
|
369
|
+
meta, _, path = row.partition("\t")
|
|
370
|
+
if path:
|
|
371
|
+
mode, _, sha = meta.split(" ")
|
|
372
|
+
entries[path] = f"{mode} {sha}"
|
|
373
|
+
info = "".join(f"{entries.get(p, '0 ' + _EMPTY_SHA)}\t{p}\0" for p in sorted(paths))
|
|
374
|
+
if subprocess.run(["git", "update-index", "-z", "--index-info"], cwd=cwd, input=info,
|
|
375
|
+
capture_output=True, text=True, timeout=_HOOK_GIT_TIMEOUT,
|
|
376
|
+
env=env).returncode != 0:
|
|
315
377
|
return ""
|
|
316
|
-
|
|
378
|
+
made = _git(["git", "write-tree"], cwd, env=env)
|
|
379
|
+
except (OSError, subprocess.SubprocessError, ValueError):
|
|
380
|
+
return ""
|
|
381
|
+
return made.stdout.strip() if made.returncode == 0 else ""
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _outside_commit(cwd: pathlib.Path, session_id: str, ts: str, parent: str,
|
|
385
|
+
pre_tree: str, post_tree: str) -> str:
|
|
386
|
+
"""The commit a call's checkpoint is parented on: `parent` where the tree
|
|
387
|
+
before the call matches it, and otherwise an `outside` checkpoint on
|
|
388
|
+
`parent` holding what changed between them, written by another session,
|
|
389
|
+
the person, or a call of this session running in parallel.
|
|
390
|
+
|
|
391
|
+
The call's own paths are those that differ both from before the call to
|
|
392
|
+
after it and from `parent` to after it. The second condition drops a path a
|
|
393
|
+
parallel call of this session wrote and checkpointed first: `parent`
|
|
394
|
+
already holds it as it stands. The `outside` tree is the tree after the
|
|
395
|
+
call with the call's own paths set back to `parent`'s, so the call's
|
|
396
|
+
checkpoint diffs against it with its own paths only. Returns `parent`
|
|
397
|
+
where git fails, which leaves the outside changes in the call's diff, as
|
|
398
|
+
every checkpoint carried them before."""
|
|
399
|
+
base = _tree_of(cwd, parent)
|
|
400
|
+
if not base or base == pre_tree:
|
|
401
|
+
return parent
|
|
402
|
+
own = _changed_paths(cwd, pre_tree, post_tree) & _changed_paths(cwd, base, post_tree)
|
|
403
|
+
tree = _tree_with(cwd, post_tree, base, own) if own else post_tree
|
|
404
|
+
if not tree or tree == base:
|
|
405
|
+
return parent
|
|
406
|
+
try:
|
|
407
|
+
made = _git(["git", "commit-tree", tree, "-p", parent, "-m",
|
|
408
|
+
_checkpoint_message(session_id, ts, action=OUTSIDE)], cwd)
|
|
409
|
+
except (OSError, subprocess.SubprocessError):
|
|
410
|
+
return parent
|
|
411
|
+
return made.stdout.strip() if made.returncode == 0 else parent
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def record_pre(cwd: pathlib.Path, git_dir: pathlib.Path, call: str) -> None:
|
|
415
|
+
"""PreToolUse: the tree before call `call`, which `_write_checkpoint`
|
|
416
|
+
compares against the tree after it. One small file per call under the
|
|
417
|
+
worktree's git directory; `take_pre` removes it."""
|
|
418
|
+
if not call or not _enabled(git_dir):
|
|
419
|
+
return
|
|
420
|
+
tree = _worktree_tree(cwd)
|
|
421
|
+
folder = _git_path(cwd, _PRE_DIR)
|
|
422
|
+
if not tree or folder is None:
|
|
423
|
+
return
|
|
424
|
+
folder.mkdir(exist_ok=True)
|
|
425
|
+
(folder / call).write_text(tree)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def take_pre(cwd: pathlib.Path, call: str) -> str:
|
|
429
|
+
"""The tree `record_pre` wrote for `call`, removed as it is read; "" for
|
|
430
|
+
none. A call that never reaches PostToolUse, one a hook denied, leaves its
|
|
431
|
+
file; any file older than `_PRE_LIFE` goes on the next read."""
|
|
432
|
+
folder = _git_path(cwd, _PRE_DIR)
|
|
433
|
+
if not call or folder is None or not folder.is_dir():
|
|
434
|
+
return ""
|
|
435
|
+
now = time.time()
|
|
436
|
+
for f in folder.iterdir():
|
|
437
|
+
try:
|
|
438
|
+
if now - f.stat().st_mtime > _PRE_LIFE:
|
|
439
|
+
f.unlink()
|
|
440
|
+
except OSError:
|
|
441
|
+
pass
|
|
442
|
+
f = folder / call
|
|
443
|
+
try:
|
|
444
|
+
tree = f.read_text().strip()
|
|
445
|
+
f.unlink()
|
|
446
|
+
except OSError:
|
|
447
|
+
return ""
|
|
448
|
+
return tree
|
|
317
449
|
|
|
318
450
|
|
|
319
451
|
def _head_sha(cwd: pathlib.Path) -> str:
|
|
@@ -465,7 +597,8 @@ def chain_entries(project_dir: pathlib.Path,
|
|
|
465
597
|
m = _CHECKPOINT_RE.match(subject)
|
|
466
598
|
if m and m["session"] == session_uuid:
|
|
467
599
|
out.append(ChainEntry(m["when"], sha, m["call"] or "",
|
|
468
|
-
m["action"] or
|
|
600
|
+
m["action"] or m["outside"] or "", m["step"] or "",
|
|
601
|
+
m["part"] or ""))
|
|
469
602
|
out.reverse()
|
|
470
603
|
TRACER and TRACER.step("chain_entries", repo=str(project_dir), session=session_uuid[:8],
|
|
471
604
|
checkpoints=len(out))
|
|
@@ -475,8 +608,18 @@ def chain_entries(project_dir: pathlib.Path,
|
|
|
475
608
|
def _chain_shas(project_dir: pathlib.Path,
|
|
476
609
|
session_uuid: str) -> list[tuple[str, str, str]]:
|
|
477
610
|
"""The session's checkpoints off its own ref, oldest first, in the shape
|
|
478
|
-
`_checkpoint_shas` returns.
|
|
479
|
-
|
|
611
|
+
`_checkpoint_shas` returns. An `outside` checkpoint holds other writers'
|
|
612
|
+
changes, not the session's, and is left out."""
|
|
613
|
+
return [(e.when, e.sha, e.call) for e in chain_entries(project_dir, session_uuid)
|
|
614
|
+
if e.action != OUTSIDE]
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def outside_parented(project_dir: pathlib.Path, session_uuid: str) -> set[str]:
|
|
618
|
+
"""The checkpoints parented on an `outside` one. Each diffs against its own
|
|
619
|
+
parent; measured against the session's previous checkpoint, its diff would
|
|
620
|
+
take in the outside changes as well."""
|
|
621
|
+
entries = chain_entries(project_dir, session_uuid)
|
|
622
|
+
return {e.sha for prev, e in zip(entries, entries[1:]) if prev.action == OUTSIDE}
|
|
480
623
|
|
|
481
624
|
|
|
482
625
|
def _checkpoint_shas(project_dir: pathlib.Path | None,
|