assertion-cli 0.5.8__tar.gz → 0.5.10__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.
- {assertion_cli-0.5.8 → assertion_cli-0.5.10}/PKG-INFO +1 -1
- {assertion_cli-0.5.8 → assertion_cli-0.5.10}/assertion_cli.egg-info/PKG-INFO +1 -1
- {assertion_cli-0.5.8 → assertion_cli-0.5.10}/bundle.py +3 -0
- assertion_cli-0.5.10/git.py +247 -0
- {assertion_cli-0.5.8 → assertion_cli-0.5.10}/main.py +46 -16
- {assertion_cli-0.5.8 → assertion_cli-0.5.10}/pyproject.toml +1 -1
- {assertion_cli-0.5.8 → assertion_cli-0.5.10}/session.py +5 -4
- {assertion_cli-0.5.8 → assertion_cli-0.5.10}/tests/test_bundle.py +8 -0
- assertion_cli-0.5.10/tests/test_git.py +659 -0
- {assertion_cli-0.5.8 → assertion_cli-0.5.10}/tests/test_new.py +29 -11
- {assertion_cli-0.5.8 → assertion_cli-0.5.10}/tests/test_session_start.py +50 -24
- assertion_cli-0.5.8/git.py +0 -138
- assertion_cli-0.5.8/tests/test_git.py +0 -311
- {assertion_cli-0.5.8 → assertion_cli-0.5.10}/README.md +0 -0
- {assertion_cli-0.5.8 → assertion_cli-0.5.10}/api.py +0 -0
- {assertion_cli-0.5.8 → assertion_cli-0.5.10}/assertion_cli.egg-info/SOURCES.txt +0 -0
- {assertion_cli-0.5.8 → assertion_cli-0.5.10}/assertion_cli.egg-info/dependency_links.txt +0 -0
- {assertion_cli-0.5.8 → assertion_cli-0.5.10}/assertion_cli.egg-info/entry_points.txt +0 -0
- {assertion_cli-0.5.8 → assertion_cli-0.5.10}/assertion_cli.egg-info/requires.txt +0 -0
- {assertion_cli-0.5.8 → assertion_cli-0.5.10}/assertion_cli.egg-info/top_level.txt +0 -0
- {assertion_cli-0.5.8 → assertion_cli-0.5.10}/link.py +0 -0
- {assertion_cli-0.5.8 → assertion_cli-0.5.10}/models.py +0 -0
- {assertion_cli-0.5.8 → assertion_cli-0.5.10}/setup.cfg +0 -0
- {assertion_cli-0.5.8 → assertion_cli-0.5.10}/templates/AGENTS.md +0 -0
- {assertion_cli-0.5.8 → assertion_cli-0.5.10}/templates/SKILL.md +0 -0
- {assertion_cli-0.5.8 → assertion_cli-0.5.10}/templates/__init__.py +0 -0
- {assertion_cli-0.5.8 → assertion_cli-0.5.10}/tests/test_api.py +0 -0
- {assertion_cli-0.5.8 → assertion_cli-0.5.10}/tests/test_decision.py +0 -0
- {assertion_cli-0.5.8 → assertion_cli-0.5.10}/tests/test_install.py +0 -0
- {assertion_cli-0.5.8 → assertion_cli-0.5.10}/tests/test_link.py +0 -0
- {assertion_cli-0.5.8 → assertion_cli-0.5.10}/tests/test_main.py +0 -0
- {assertion_cli-0.5.8 → assertion_cli-0.5.10}/tests/test_prompt.py +0 -0
- {assertion_cli-0.5.8 → assertion_cli-0.5.10}/tests/test_session.py +0 -0
|
@@ -6,6 +6,7 @@ from models import MetadataPayload
|
|
|
6
6
|
|
|
7
7
|
ASSERTION_DIR_NAME = ".assertion"
|
|
8
8
|
DIFF_ARCHIVE_PATH = "git.diff"
|
|
9
|
+
UNCOMMITTED_DIFF_ARCHIVE_PATH = "uncommitted.diff"
|
|
9
10
|
METADATA_ARCHIVE_PATH = f"{ASSERTION_DIR_NAME}/metadata.json"
|
|
10
11
|
PROMPTS_ARCHIVE_PATH = f"{ASSERTION_DIR_NAME}/prompts"
|
|
11
12
|
CHECKPOINT_ARCHIVE_PATH = f"{ASSERTION_DIR_NAME}/checkpoint"
|
|
@@ -30,6 +31,7 @@ def build_bundle(
|
|
|
30
31
|
*,
|
|
31
32
|
metadata: MetadataPayload,
|
|
32
33
|
diff_text: str,
|
|
34
|
+
uncommitted_diff_text: str,
|
|
33
35
|
prompts_text: str,
|
|
34
36
|
checkpoint_text: str,
|
|
35
37
|
) -> bytes:
|
|
@@ -39,4 +41,5 @@ def build_bundle(
|
|
|
39
41
|
zf.writestr(PROMPTS_ARCHIVE_PATH, prompts_text)
|
|
40
42
|
zf.writestr(CHECKPOINT_ARCHIVE_PATH, checkpoint_text)
|
|
41
43
|
zf.writestr(DIFF_ARCHIVE_PATH, diff_text)
|
|
44
|
+
zf.writestr(UNCOMMITTED_DIFF_ARCHIVE_PATH, uncommitted_diff_text)
|
|
42
45
|
return buf.getvalue()
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import NoReturn, Sequence
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def exit_with_error(message: str) -> NoReturn:
|
|
9
|
+
typer.echo(message, err=True)
|
|
10
|
+
raise typer.Exit(code=1)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def run_git_command(
|
|
14
|
+
repo_root: Path, args: Sequence[str], *, preserve_stdout: bool = False
|
|
15
|
+
) -> str:
|
|
16
|
+
completed = subprocess.run(
|
|
17
|
+
["git", *args],
|
|
18
|
+
cwd=repo_root,
|
|
19
|
+
capture_output=True,
|
|
20
|
+
text=True,
|
|
21
|
+
check=False,
|
|
22
|
+
)
|
|
23
|
+
if completed.returncode != 0:
|
|
24
|
+
message = completed.stderr.strip() or completed.stdout.strip() or "git failed"
|
|
25
|
+
raise RuntimeError(message)
|
|
26
|
+
return completed.stdout if preserve_stdout else completed.stdout.strip()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def find_git_root(start_path: Path) -> Path:
|
|
30
|
+
completed = subprocess.run(
|
|
31
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
32
|
+
cwd=start_path,
|
|
33
|
+
capture_output=True,
|
|
34
|
+
text=True,
|
|
35
|
+
check=False,
|
|
36
|
+
)
|
|
37
|
+
if completed.returncode != 0:
|
|
38
|
+
exit_with_error("Current directory is not inside a git repository.")
|
|
39
|
+
return Path(completed.stdout.strip())
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_head_sha(repo_root: Path) -> str:
|
|
43
|
+
try:
|
|
44
|
+
return run_git_command(repo_root, ["rev-parse", "HEAD"])
|
|
45
|
+
except RuntimeError as exc:
|
|
46
|
+
exit_with_error(f"Failed to get HEAD SHA: {exc}")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_upstream_ref(repo_root: Path) -> str | None:
|
|
50
|
+
"""Return the upstream tracking ref (e.g. ``origin/main``).
|
|
51
|
+
|
|
52
|
+
Tries HEAD's configured upstream first, then falls through increasingly
|
|
53
|
+
general heuristics to find a remote ref to compute a merge-base against:
|
|
54
|
+
same-named remote branch, closest remote ancestor (via
|
|
55
|
+
``git branch -r --merged HEAD`` + ``git merge-base``), then well-known
|
|
56
|
+
default branch names. Returns ``None`` when none are available.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
# 1. Configured upstream (git branch -u).
|
|
60
|
+
try:
|
|
61
|
+
return run_git_command(
|
|
62
|
+
repo_root, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"]
|
|
63
|
+
)
|
|
64
|
+
except RuntimeError:
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
# 2. Same branch name on remote (has been pushed at least once).
|
|
68
|
+
head_branch = get_head_branch(repo_root)
|
|
69
|
+
if head_branch is not None:
|
|
70
|
+
fallback = f"origin/{head_branch}"
|
|
71
|
+
try:
|
|
72
|
+
run_git_command(repo_root, ["rev-parse", "--verify", fallback])
|
|
73
|
+
except RuntimeError:
|
|
74
|
+
pass
|
|
75
|
+
else:
|
|
76
|
+
return fallback
|
|
77
|
+
|
|
78
|
+
# 3. Closest remote ancestor — handles branches based on other pushed
|
|
79
|
+
# branches (e.g. my-feature on my-feature-base). Uses --merged HEAD
|
|
80
|
+
# so only remote branches in HEAD's ancestry are considered; a branch
|
|
81
|
+
# whose tip moved forward after we branched off is correctly excluded.
|
|
82
|
+
try:
|
|
83
|
+
merged = run_git_command(
|
|
84
|
+
repo_root,
|
|
85
|
+
[
|
|
86
|
+
"branch", "-r", "--merged", "HEAD",
|
|
87
|
+
"--format=%(refname:short)",
|
|
88
|
+
],
|
|
89
|
+
)
|
|
90
|
+
ancestors = [r for r in merged.splitlines() if r]
|
|
91
|
+
if ancestors:
|
|
92
|
+
return run_git_command(
|
|
93
|
+
repo_root, ["merge-base", "HEAD", *ancestors]
|
|
94
|
+
)
|
|
95
|
+
except RuntimeError:
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
# 4. Remote's default branch (symbolic ref, usually origin/main).
|
|
99
|
+
try:
|
|
100
|
+
run_git_command(repo_root, ["rev-parse", "--verify", "origin/HEAD"])
|
|
101
|
+
except RuntimeError:
|
|
102
|
+
pass
|
|
103
|
+
else:
|
|
104
|
+
return "origin/HEAD"
|
|
105
|
+
|
|
106
|
+
# 5. Conventional default branch name.
|
|
107
|
+
try:
|
|
108
|
+
run_git_command(repo_root, ["rev-parse", "--verify", "origin/main"])
|
|
109
|
+
except RuntimeError:
|
|
110
|
+
pass
|
|
111
|
+
else:
|
|
112
|
+
return "origin/main"
|
|
113
|
+
|
|
114
|
+
# 6. Legacy default branch name.
|
|
115
|
+
try:
|
|
116
|
+
run_git_command(repo_root, ["rev-parse", "--verify", "origin/master"])
|
|
117
|
+
except RuntimeError:
|
|
118
|
+
pass
|
|
119
|
+
else:
|
|
120
|
+
return "origin/master"
|
|
121
|
+
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def get_merge_base(repo_root: Path, upstream: str) -> str:
|
|
126
|
+
"""Return the merge-base of ``HEAD`` and *upstream*."""
|
|
127
|
+
try:
|
|
128
|
+
return run_git_command(repo_root, ["merge-base", "HEAD", upstream])
|
|
129
|
+
except RuntimeError as exc:
|
|
130
|
+
exit_with_error(
|
|
131
|
+
f"Failed to compute merge-base between HEAD and {upstream}: {exc}"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def get_head_branch(repo_root: Path) -> str | None:
|
|
136
|
+
"""Return the current branch name, or None if HEAD is detached."""
|
|
137
|
+
try:
|
|
138
|
+
name = run_git_command(repo_root, ["rev-parse", "--abbrev-ref", "HEAD"])
|
|
139
|
+
except RuntimeError:
|
|
140
|
+
return None
|
|
141
|
+
return name if name and name != "HEAD" else None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _build_untracked_diff(repo_root: Path, rel_path: str) -> str:
|
|
145
|
+
completed = subprocess.run(
|
|
146
|
+
[
|
|
147
|
+
"git",
|
|
148
|
+
"diff",
|
|
149
|
+
"--no-index",
|
|
150
|
+
"--src-prefix=a/",
|
|
151
|
+
"--dst-prefix=b/",
|
|
152
|
+
"--",
|
|
153
|
+
"/dev/null",
|
|
154
|
+
rel_path,
|
|
155
|
+
],
|
|
156
|
+
cwd=repo_root,
|
|
157
|
+
capture_output=True,
|
|
158
|
+
text=True,
|
|
159
|
+
check=False,
|
|
160
|
+
)
|
|
161
|
+
if completed.returncode not in (0, 1):
|
|
162
|
+
message = completed.stderr.strip() or completed.stdout.strip() or "git failed"
|
|
163
|
+
raise RuntimeError(message)
|
|
164
|
+
return completed.stdout
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# Paths the assertion-cli owns end-to-end. Excluded from the diff bundle so
|
|
168
|
+
# reviewers don't flag our own bootstrap files as "unrelated changes" — they're
|
|
169
|
+
# generated by `asrt install` and have nothing to do with the user's feature
|
|
170
|
+
# work. CLAUDE.md / AGENTS.md are intentionally NOT in this list: `asrt install`
|
|
171
|
+
# writes a small pointer block into them once at onboarding (meant to be
|
|
172
|
+
# committed before work starts, so it never shows in a work-session diff), but
|
|
173
|
+
# the rest of those files is the user's own and must flow through to reviewers.
|
|
174
|
+
_ASSERTION_EXCLUDED_PATHSPECS = [
|
|
175
|
+
":(exclude).assertion",
|
|
176
|
+
":(exclude).claude/skills/assertion-cli",
|
|
177
|
+
":(exclude).agents/skills/assertion-cli",
|
|
178
|
+
]
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _build_tracked_diff(repo_root: Path, base: str) -> str:
|
|
182
|
+
"""``git diff <base>`` for tracked files, excluding assertion-owned paths."""
|
|
183
|
+
return run_git_command(
|
|
184
|
+
repo_root,
|
|
185
|
+
["diff", base, "--", *_ASSERTION_EXCLUDED_PATHSPECS],
|
|
186
|
+
preserve_stdout=True,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _build_untracked_diffs(repo_root: Path) -> list[str]:
|
|
191
|
+
"""Diff of every untracked (but non-ignored) file as a new-file diff."""
|
|
192
|
+
output = run_git_command(
|
|
193
|
+
repo_root,
|
|
194
|
+
[
|
|
195
|
+
"ls-files",
|
|
196
|
+
"--others",
|
|
197
|
+
"--exclude-standard",
|
|
198
|
+
"--",
|
|
199
|
+
*_ASSERTION_EXCLUDED_PATHSPECS,
|
|
200
|
+
],
|
|
201
|
+
)
|
|
202
|
+
return [
|
|
203
|
+
_build_untracked_diff(repo_root, rel_path)
|
|
204
|
+
for rel_path in output.splitlines()
|
|
205
|
+
if rel_path
|
|
206
|
+
]
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _join_diffs(parts: list[str]) -> str:
|
|
210
|
+
parts = [_ensure_trailing_newline(p) for p in parts if p]
|
|
211
|
+
return "".join(parts)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def get_uncommitted_diff(repo_root: Path) -> str:
|
|
215
|
+
"""Build a diff of **only uncommitted** changes (staged + unstaged +
|
|
216
|
+
untracked).
|
|
217
|
+
|
|
218
|
+
``git diff HEAD`` compares the working tree directly to ``HEAD``, so
|
|
219
|
+
local commits are excluded — only changes the agent hasn't committed yet
|
|
220
|
+
appear.
|
|
221
|
+
"""
|
|
222
|
+
try:
|
|
223
|
+
tracked = _build_tracked_diff(repo_root, "HEAD")
|
|
224
|
+
untracked = _build_untracked_diffs(repo_root)
|
|
225
|
+
return _join_diffs([tracked, *untracked])
|
|
226
|
+
except RuntimeError as exc:
|
|
227
|
+
exit_with_error(f"Failed to collect uncommitted diff: {exc}")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def get_full_diff(repo_root: Path, base_sha: str) -> str:
|
|
231
|
+
"""Build a unified diff from ``base_sha`` to the current working tree.
|
|
232
|
+
|
|
233
|
+
``git diff <base_sha>`` captures everything since the base commit —
|
|
234
|
+
local commits, staged changes, unstaged changes, and untracked files.
|
|
235
|
+
This is the diff the backend uses to reconstruct the full working state
|
|
236
|
+
(for terminal/browser sandboxes).
|
|
237
|
+
"""
|
|
238
|
+
try:
|
|
239
|
+
tracked = _build_tracked_diff(repo_root, base_sha)
|
|
240
|
+
untracked = _build_untracked_diffs(repo_root)
|
|
241
|
+
return _join_diffs([tracked, *untracked])
|
|
242
|
+
except RuntimeError as exc:
|
|
243
|
+
exit_with_error(f"Failed to collect full diff: {exc}")
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _ensure_trailing_newline(value: str) -> str:
|
|
247
|
+
return value if value.endswith("\n") else value + "\n"
|
|
@@ -14,9 +14,12 @@ from bundle import build_bundle
|
|
|
14
14
|
from git import (
|
|
15
15
|
exit_with_error,
|
|
16
16
|
find_git_root,
|
|
17
|
+
get_full_diff,
|
|
17
18
|
get_head_branch,
|
|
18
|
-
|
|
19
|
+
get_merge_base,
|
|
19
20
|
get_uncommitted_diff,
|
|
21
|
+
get_upstream_ref,
|
|
22
|
+
run_git_command,
|
|
20
23
|
)
|
|
21
24
|
from link import load_link, save_link
|
|
22
25
|
from models import CheckpointResponse, SessionStatus, render_stack_list
|
|
@@ -260,13 +263,42 @@ session_app = typer.Typer(help="Manage Assertion work sessions.")
|
|
|
260
263
|
app.add_typer(session_app, name="session")
|
|
261
264
|
|
|
262
265
|
|
|
266
|
+
def _resolve_diff_base(repo_root: Path) -> str:
|
|
267
|
+
"""Compute the merge-base with the upstream tracking branch.
|
|
268
|
+
|
|
269
|
+
Fetches from origin first so the merge-base SHA is guaranteed to exist
|
|
270
|
+
on the remote when the backend checks it out — stale remote tracking
|
|
271
|
+
refs (from a force-pushed or rebased branch) would otherwise produce a
|
|
272
|
+
merge-base that's no longer reachable from any remote ref.
|
|
273
|
+
|
|
274
|
+
Errors out when no upstream can be resolved (detached HEAD, no tracking
|
|
275
|
+
branch, branch not pushed to remote).
|
|
276
|
+
"""
|
|
277
|
+
try:
|
|
278
|
+
run_git_command(repo_root, ["fetch", "--no-tags", "origin"])
|
|
279
|
+
except RuntimeError:
|
|
280
|
+
pass # non-fatal: offline or unavailable; proceed with stale refs
|
|
281
|
+
|
|
282
|
+
upstream = get_upstream_ref(repo_root)
|
|
283
|
+
if upstream is None:
|
|
284
|
+
exit_with_error(
|
|
285
|
+
"Cannot determine the diff base: no upstream tracking branch found.\n"
|
|
286
|
+
"Make sure you are on a branch that has been pushed to a remote "
|
|
287
|
+
"with a tracking branch configured, e.g.:\n"
|
|
288
|
+
" git push -u origin <branch>\n"
|
|
289
|
+
"Or use an existing branch that tracks an upstream.",
|
|
290
|
+
)
|
|
291
|
+
return get_merge_base(repo_root, upstream)
|
|
292
|
+
|
|
293
|
+
|
|
263
294
|
@session_app.command("start")
|
|
264
295
|
def session_start() -> None:
|
|
265
|
-
"""Record the
|
|
296
|
+
"""Record the merge-base with the upstream tracking branch as the diff base.
|
|
266
297
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
298
|
+
Unlike the old HEAD-based approach, the merge-base always exists on the
|
|
299
|
+
remote so the backend can check it out. Branches without a remote tracking
|
|
300
|
+
ref (detached HEAD, no upstream, not pushed) are rejected with a clear
|
|
301
|
+
message — run ``asrt session start`` from a branch with an upstream.
|
|
270
302
|
|
|
271
303
|
`session start` does not write skill files — that is `asrt install`, the
|
|
272
304
|
one-time onboarding step. It also never touches user content in `CLAUDE.md` /
|
|
@@ -276,7 +308,7 @@ def session_start() -> None:
|
|
|
276
308
|
repo_root = find_git_root(Path.cwd())
|
|
277
309
|
_ensure_gitignore_limits_assertion_state(repo_root)
|
|
278
310
|
|
|
279
|
-
base_sha =
|
|
311
|
+
base_sha = _resolve_diff_base(repo_root)
|
|
280
312
|
assertion_dir = repo_root / ASSERTION_DIR_NAME
|
|
281
313
|
assertion_dir.mkdir(exist_ok=True)
|
|
282
314
|
base_sha_path = assertion_dir / BASE_SHA_FILE_NAME
|
|
@@ -294,9 +326,9 @@ def session_start() -> None:
|
|
|
294
326
|
|
|
295
327
|
@app.command("new")
|
|
296
328
|
def new() -> None:
|
|
297
|
-
"""Reset local Assertion run state and record
|
|
329
|
+
"""Reset local Assertion run state and record the merge-base as the diff base."""
|
|
298
330
|
repo_root = find_git_root(Path.cwd())
|
|
299
|
-
base_sha =
|
|
331
|
+
base_sha = _resolve_diff_base(repo_root)
|
|
300
332
|
assertion_dir = repo_root / ASSERTION_DIR_NAME
|
|
301
333
|
assertion_dir.mkdir(exist_ok=True)
|
|
302
334
|
|
|
@@ -401,13 +433,10 @@ def checkpoint(
|
|
|
401
433
|
assert stack_id is not None
|
|
402
434
|
|
|
403
435
|
append_checkpoint_entry(ctx.checkpoint_path, message)
|
|
404
|
-
# Base SHA is whatever `asrt session start` recorded for this repo. Backend
|
|
405
|
-
# uses it as the checkout target before applying the diff. Pinning it at
|
|
406
|
-
# session-start time (instead of rediscovering per command) means sandboxes
|
|
407
|
-
# without remote-tracking refs — e.g. Codex Cloud — still work.
|
|
408
436
|
base_sha = read_init_base_sha(ctx.assertion_dir)
|
|
409
437
|
head_branch = get_head_branch(ctx.repo_root)
|
|
410
|
-
diff_text =
|
|
438
|
+
diff_text = get_full_diff(ctx.repo_root, base_sha)
|
|
439
|
+
uncommitted_diff_text = get_uncommitted_diff(ctx.repo_root)
|
|
411
440
|
metadata = update_metadata_anchor(
|
|
412
441
|
ctx.metadata_path, metadata, base_sha, head_branch=head_branch
|
|
413
442
|
)
|
|
@@ -415,6 +444,7 @@ def checkpoint(
|
|
|
415
444
|
bundle_bytes = build_bundle(
|
|
416
445
|
metadata=metadata,
|
|
417
446
|
diff_text=diff_text,
|
|
447
|
+
uncommitted_diff_text=uncommitted_diff_text,
|
|
418
448
|
prompts_text=ctx.prompts_path.read_text(encoding="utf-8"),
|
|
419
449
|
checkpoint_text=message,
|
|
420
450
|
)
|
|
@@ -505,11 +535,10 @@ def verify(
|
|
|
505
535
|
ctx, metadata = load_existing_session(Path.cwd())
|
|
506
536
|
stack_id = metadata.stack_id
|
|
507
537
|
assert stack_id is not None
|
|
508
|
-
# See checkpoint() for why the base is the SHA `asrt session start`
|
|
509
|
-
# recorded, not local HEAD.
|
|
510
538
|
base_sha = read_init_base_sha(ctx.assertion_dir)
|
|
511
539
|
head_branch = get_head_branch(ctx.repo_root)
|
|
512
|
-
diff_text =
|
|
540
|
+
diff_text = get_full_diff(ctx.repo_root, base_sha)
|
|
541
|
+
uncommitted_diff_text = get_uncommitted_diff(ctx.repo_root)
|
|
513
542
|
metadata = update_metadata_anchor(
|
|
514
543
|
ctx.metadata_path, metadata, base_sha, head_branch=head_branch
|
|
515
544
|
)
|
|
@@ -517,6 +546,7 @@ def verify(
|
|
|
517
546
|
bundle_bytes = build_bundle(
|
|
518
547
|
metadata=metadata,
|
|
519
548
|
diff_text=diff_text,
|
|
549
|
+
uncommitted_diff_text=uncommitted_diff_text,
|
|
520
550
|
prompts_text=ctx.prompts_path.read_text(encoding="utf-8"),
|
|
521
551
|
checkpoint_text=ctx.checkpoint_path.read_text(encoding="utf-8"),
|
|
522
552
|
)
|
|
@@ -19,15 +19,16 @@ BASE_SHA_FILE_NAME = "base_sha"
|
|
|
19
19
|
def read_init_base_sha(assertion_dir: Path) -> str:
|
|
20
20
|
"""Return the diff base SHA recorded by `asrt session start`.
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
The base SHA is the merge-base with the upstream tracking branch — always
|
|
23
|
+
fetchable from the remote. Every checkpoint/verify reads this value; it
|
|
24
|
+
never changes within a session.
|
|
25
25
|
"""
|
|
26
26
|
path = assertion_dir / BASE_SHA_FILE_NAME
|
|
27
27
|
if not path.exists():
|
|
28
28
|
exit_with_error(
|
|
29
29
|
"No diff base recorded for this repo. Run `asrt session start` "
|
|
30
|
-
"first — it captures the
|
|
30
|
+
"first — it captures the merge-base SHA that checkpoint/verify "
|
|
31
|
+
"diff against."
|
|
31
32
|
)
|
|
32
33
|
content = path.read_text(encoding="utf-8").strip()
|
|
33
34
|
if not content:
|
|
@@ -15,6 +15,7 @@ def test_build_bundle_writes_session_metadata() -> None:
|
|
|
15
15
|
bundle_bytes = build_bundle(
|
|
16
16
|
metadata=metadata,
|
|
17
17
|
diff_text="diff --git a/file b/file",
|
|
18
|
+
uncommitted_diff_text="diff --git a/file b/file",
|
|
18
19
|
prompts_text='{"ts": "2026-05-28T14:32:01Z", "text": "Do the thing"}\n',
|
|
19
20
|
checkpoint_text="Checkpoint text",
|
|
20
21
|
)
|
|
@@ -31,3 +32,10 @@ def test_build_bundle_writes_session_metadata() -> None:
|
|
|
31
32
|
|
|
32
33
|
prompts_raw = zf.read(".assertion/prompts").decode("utf-8")
|
|
33
34
|
assert '"text": "Do the thing"' in prompts_raw
|
|
35
|
+
|
|
36
|
+
# Both diffs are included in the bundle.
|
|
37
|
+
git_diff = zf.read("git.diff").decode("utf-8")
|
|
38
|
+
assert "diff --git a/file b/file" in git_diff
|
|
39
|
+
|
|
40
|
+
uncommitted_diff = zf.read("uncommitted.diff").decode("utf-8")
|
|
41
|
+
assert "diff --git a/file b/file" in uncommitted_diff
|