assertion-cli 0.5.8__tar.gz → 0.5.9__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.9}/PKG-INFO +1 -1
- {assertion_cli-0.5.8 → assertion_cli-0.5.9}/assertion_cli.egg-info/PKG-INFO +1 -1
- {assertion_cli-0.5.8 → assertion_cli-0.5.9}/bundle.py +3 -0
- assertion_cli-0.5.9/git.py +199 -0
- {assertion_cli-0.5.8 → assertion_cli-0.5.9}/main.py +35 -16
- {assertion_cli-0.5.8 → assertion_cli-0.5.9}/pyproject.toml +1 -1
- {assertion_cli-0.5.8 → assertion_cli-0.5.9}/session.py +5 -4
- {assertion_cli-0.5.8 → assertion_cli-0.5.9}/tests/test_bundle.py +8 -0
- {assertion_cli-0.5.8 → assertion_cli-0.5.9}/tests/test_git.py +197 -30
- {assertion_cli-0.5.8 → assertion_cli-0.5.9}/tests/test_new.py +29 -11
- {assertion_cli-0.5.8 → assertion_cli-0.5.9}/tests/test_session_start.py +50 -24
- assertion_cli-0.5.8/git.py +0 -138
- {assertion_cli-0.5.8 → assertion_cli-0.5.9}/README.md +0 -0
- {assertion_cli-0.5.8 → assertion_cli-0.5.9}/api.py +0 -0
- {assertion_cli-0.5.8 → assertion_cli-0.5.9}/assertion_cli.egg-info/SOURCES.txt +0 -0
- {assertion_cli-0.5.8 → assertion_cli-0.5.9}/assertion_cli.egg-info/dependency_links.txt +0 -0
- {assertion_cli-0.5.8 → assertion_cli-0.5.9}/assertion_cli.egg-info/entry_points.txt +0 -0
- {assertion_cli-0.5.8 → assertion_cli-0.5.9}/assertion_cli.egg-info/requires.txt +0 -0
- {assertion_cli-0.5.8 → assertion_cli-0.5.9}/assertion_cli.egg-info/top_level.txt +0 -0
- {assertion_cli-0.5.8 → assertion_cli-0.5.9}/link.py +0 -0
- {assertion_cli-0.5.8 → assertion_cli-0.5.9}/models.py +0 -0
- {assertion_cli-0.5.8 → assertion_cli-0.5.9}/setup.cfg +0 -0
- {assertion_cli-0.5.8 → assertion_cli-0.5.9}/templates/AGENTS.md +0 -0
- {assertion_cli-0.5.8 → assertion_cli-0.5.9}/templates/SKILL.md +0 -0
- {assertion_cli-0.5.8 → assertion_cli-0.5.9}/templates/__init__.py +0 -0
- {assertion_cli-0.5.8 → assertion_cli-0.5.9}/tests/test_api.py +0 -0
- {assertion_cli-0.5.8 → assertion_cli-0.5.9}/tests/test_decision.py +0 -0
- {assertion_cli-0.5.8 → assertion_cli-0.5.9}/tests/test_install.py +0 -0
- {assertion_cli-0.5.8 → assertion_cli-0.5.9}/tests/test_link.py +0 -0
- {assertion_cli-0.5.8 → assertion_cli-0.5.9}/tests/test_main.py +0 -0
- {assertion_cli-0.5.8 → assertion_cli-0.5.9}/tests/test_prompt.py +0 -0
- {assertion_cli-0.5.8 → assertion_cli-0.5.9}/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,199 @@
|
|
|
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 (``HEAD@{upstream}``). When the
|
|
53
|
+
branch has no upstream but the branch name itself resolves on the default
|
|
54
|
+
remote, falls back to ``origin/<branch>``. Returns ``None`` when neither
|
|
55
|
+
is available (detached HEAD, no remote, branch not pushed).
|
|
56
|
+
"""
|
|
57
|
+
try:
|
|
58
|
+
return run_git_command(
|
|
59
|
+
repo_root, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"]
|
|
60
|
+
)
|
|
61
|
+
except RuntimeError:
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
head_branch = get_head_branch(repo_root)
|
|
65
|
+
if head_branch is not None:
|
|
66
|
+
fallback = f"origin/{head_branch}"
|
|
67
|
+
try:
|
|
68
|
+
run_git_command(repo_root, ["rev-parse", "--verify", fallback])
|
|
69
|
+
except RuntimeError:
|
|
70
|
+
pass
|
|
71
|
+
else:
|
|
72
|
+
return fallback
|
|
73
|
+
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def get_merge_base(repo_root: Path, upstream: str) -> str:
|
|
78
|
+
"""Return the merge-base of ``HEAD`` and *upstream*."""
|
|
79
|
+
try:
|
|
80
|
+
return run_git_command(repo_root, ["merge-base", "HEAD", upstream])
|
|
81
|
+
except RuntimeError as exc:
|
|
82
|
+
exit_with_error(
|
|
83
|
+
f"Failed to compute merge-base between HEAD and {upstream}: {exc}"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def get_head_branch(repo_root: Path) -> str | None:
|
|
88
|
+
"""Return the current branch name, or None if HEAD is detached."""
|
|
89
|
+
try:
|
|
90
|
+
name = run_git_command(repo_root, ["rev-parse", "--abbrev-ref", "HEAD"])
|
|
91
|
+
except RuntimeError:
|
|
92
|
+
return None
|
|
93
|
+
return name if name and name != "HEAD" else None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _build_untracked_diff(repo_root: Path, rel_path: str) -> str:
|
|
97
|
+
completed = subprocess.run(
|
|
98
|
+
[
|
|
99
|
+
"git",
|
|
100
|
+
"diff",
|
|
101
|
+
"--no-index",
|
|
102
|
+
"--src-prefix=a/",
|
|
103
|
+
"--dst-prefix=b/",
|
|
104
|
+
"--",
|
|
105
|
+
"/dev/null",
|
|
106
|
+
rel_path,
|
|
107
|
+
],
|
|
108
|
+
cwd=repo_root,
|
|
109
|
+
capture_output=True,
|
|
110
|
+
text=True,
|
|
111
|
+
check=False,
|
|
112
|
+
)
|
|
113
|
+
if completed.returncode not in (0, 1):
|
|
114
|
+
message = completed.stderr.strip() or completed.stdout.strip() or "git failed"
|
|
115
|
+
raise RuntimeError(message)
|
|
116
|
+
return completed.stdout
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# Paths the assertion-cli owns end-to-end. Excluded from the diff bundle so
|
|
120
|
+
# reviewers don't flag our own bootstrap files as "unrelated changes" — they're
|
|
121
|
+
# generated by `asrt install` and have nothing to do with the user's feature
|
|
122
|
+
# work. CLAUDE.md / AGENTS.md are intentionally NOT in this list: `asrt install`
|
|
123
|
+
# writes a small pointer block into them once at onboarding (meant to be
|
|
124
|
+
# committed before work starts, so it never shows in a work-session diff), but
|
|
125
|
+
# the rest of those files is the user's own and must flow through to reviewers.
|
|
126
|
+
_ASSERTION_EXCLUDED_PATHSPECS = [
|
|
127
|
+
":(exclude).assertion",
|
|
128
|
+
":(exclude).claude/skills/assertion-cli",
|
|
129
|
+
":(exclude).agents/skills/assertion-cli",
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _build_tracked_diff(repo_root: Path, base: str) -> str:
|
|
134
|
+
"""``git diff <base>`` for tracked files, excluding assertion-owned paths."""
|
|
135
|
+
return run_git_command(
|
|
136
|
+
repo_root,
|
|
137
|
+
["diff", base, "--", *_ASSERTION_EXCLUDED_PATHSPECS],
|
|
138
|
+
preserve_stdout=True,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _build_untracked_diffs(repo_root: Path) -> list[str]:
|
|
143
|
+
"""Diff of every untracked (but non-ignored) file as a new-file diff."""
|
|
144
|
+
output = run_git_command(
|
|
145
|
+
repo_root,
|
|
146
|
+
[
|
|
147
|
+
"ls-files",
|
|
148
|
+
"--others",
|
|
149
|
+
"--exclude-standard",
|
|
150
|
+
"--",
|
|
151
|
+
*_ASSERTION_EXCLUDED_PATHSPECS,
|
|
152
|
+
],
|
|
153
|
+
)
|
|
154
|
+
return [
|
|
155
|
+
_build_untracked_diff(repo_root, rel_path)
|
|
156
|
+
for rel_path in output.splitlines()
|
|
157
|
+
if rel_path
|
|
158
|
+
]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _join_diffs(parts: list[str]) -> str:
|
|
162
|
+
parts = [_ensure_trailing_newline(p) for p in parts if p]
|
|
163
|
+
return "".join(parts)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def get_uncommitted_diff(repo_root: Path) -> str:
|
|
167
|
+
"""Build a diff of **only uncommitted** changes (staged + unstaged +
|
|
168
|
+
untracked).
|
|
169
|
+
|
|
170
|
+
``git diff HEAD`` compares the working tree directly to ``HEAD``, so
|
|
171
|
+
local commits are excluded — only changes the agent hasn't committed yet
|
|
172
|
+
appear.
|
|
173
|
+
"""
|
|
174
|
+
try:
|
|
175
|
+
tracked = _build_tracked_diff(repo_root, "HEAD")
|
|
176
|
+
untracked = _build_untracked_diffs(repo_root)
|
|
177
|
+
return _join_diffs([tracked, *untracked])
|
|
178
|
+
except RuntimeError as exc:
|
|
179
|
+
exit_with_error(f"Failed to collect uncommitted diff: {exc}")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def get_full_diff(repo_root: Path, base_sha: str) -> str:
|
|
183
|
+
"""Build a unified diff from ``base_sha`` to the current working tree.
|
|
184
|
+
|
|
185
|
+
``git diff <base_sha>`` captures everything since the base commit —
|
|
186
|
+
local commits, staged changes, unstaged changes, and untracked files.
|
|
187
|
+
This is the diff the backend uses to reconstruct the full working state
|
|
188
|
+
(for terminal/browser sandboxes).
|
|
189
|
+
"""
|
|
190
|
+
try:
|
|
191
|
+
tracked = _build_tracked_diff(repo_root, base_sha)
|
|
192
|
+
untracked = _build_untracked_diffs(repo_root)
|
|
193
|
+
return _join_diffs([tracked, *untracked])
|
|
194
|
+
except RuntimeError as exc:
|
|
195
|
+
exit_with_error(f"Failed to collect full diff: {exc}")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _ensure_trailing_newline(value: str) -> str:
|
|
199
|
+
return value if value.endswith("\n") else value + "\n"
|
|
@@ -14,9 +14,11 @@ 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,
|
|
20
22
|
)
|
|
21
23
|
from link import load_link, save_link
|
|
22
24
|
from models import CheckpointResponse, SessionStatus, render_stack_list
|
|
@@ -260,13 +262,32 @@ session_app = typer.Typer(help="Manage Assertion work sessions.")
|
|
|
260
262
|
app.add_typer(session_app, name="session")
|
|
261
263
|
|
|
262
264
|
|
|
265
|
+
def _resolve_diff_base(repo_root: Path) -> str:
|
|
266
|
+
"""Compute the merge-base with the upstream tracking branch.
|
|
267
|
+
|
|
268
|
+
Errors out when no upstream can be resolved (detached HEAD, no tracking
|
|
269
|
+
branch, branch not pushed to remote).
|
|
270
|
+
"""
|
|
271
|
+
upstream = get_upstream_ref(repo_root)
|
|
272
|
+
if upstream is None:
|
|
273
|
+
exit_with_error(
|
|
274
|
+
"Cannot determine the diff base: no upstream tracking branch found.\n"
|
|
275
|
+
"Make sure you are on a branch that has been pushed to a remote "
|
|
276
|
+
"with a tracking branch configured, e.g.:\n"
|
|
277
|
+
" git push -u origin <branch>\n"
|
|
278
|
+
"Or use an existing branch that tracks an upstream.",
|
|
279
|
+
)
|
|
280
|
+
return get_merge_base(repo_root, upstream)
|
|
281
|
+
|
|
282
|
+
|
|
263
283
|
@session_app.command("start")
|
|
264
284
|
def session_start() -> None:
|
|
265
|
-
"""Record the
|
|
285
|
+
"""Record the merge-base with the upstream tracking branch as the diff base.
|
|
266
286
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
287
|
+
Unlike the old HEAD-based approach, the merge-base always exists on the
|
|
288
|
+
remote so the backend can check it out. Branches without a remote tracking
|
|
289
|
+
ref (detached HEAD, no upstream, not pushed) are rejected with a clear
|
|
290
|
+
message — run ``asrt session start`` from a branch with an upstream.
|
|
270
291
|
|
|
271
292
|
`session start` does not write skill files — that is `asrt install`, the
|
|
272
293
|
one-time onboarding step. It also never touches user content in `CLAUDE.md` /
|
|
@@ -276,7 +297,7 @@ def session_start() -> None:
|
|
|
276
297
|
repo_root = find_git_root(Path.cwd())
|
|
277
298
|
_ensure_gitignore_limits_assertion_state(repo_root)
|
|
278
299
|
|
|
279
|
-
base_sha =
|
|
300
|
+
base_sha = _resolve_diff_base(repo_root)
|
|
280
301
|
assertion_dir = repo_root / ASSERTION_DIR_NAME
|
|
281
302
|
assertion_dir.mkdir(exist_ok=True)
|
|
282
303
|
base_sha_path = assertion_dir / BASE_SHA_FILE_NAME
|
|
@@ -294,9 +315,9 @@ def session_start() -> None:
|
|
|
294
315
|
|
|
295
316
|
@app.command("new")
|
|
296
317
|
def new() -> None:
|
|
297
|
-
"""Reset local Assertion run state and record
|
|
318
|
+
"""Reset local Assertion run state and record the merge-base as the diff base."""
|
|
298
319
|
repo_root = find_git_root(Path.cwd())
|
|
299
|
-
base_sha =
|
|
320
|
+
base_sha = _resolve_diff_base(repo_root)
|
|
300
321
|
assertion_dir = repo_root / ASSERTION_DIR_NAME
|
|
301
322
|
assertion_dir.mkdir(exist_ok=True)
|
|
302
323
|
|
|
@@ -401,13 +422,10 @@ def checkpoint(
|
|
|
401
422
|
assert stack_id is not None
|
|
402
423
|
|
|
403
424
|
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
425
|
base_sha = read_init_base_sha(ctx.assertion_dir)
|
|
409
426
|
head_branch = get_head_branch(ctx.repo_root)
|
|
410
|
-
diff_text =
|
|
427
|
+
diff_text = get_full_diff(ctx.repo_root, base_sha)
|
|
428
|
+
uncommitted_diff_text = get_uncommitted_diff(ctx.repo_root)
|
|
411
429
|
metadata = update_metadata_anchor(
|
|
412
430
|
ctx.metadata_path, metadata, base_sha, head_branch=head_branch
|
|
413
431
|
)
|
|
@@ -415,6 +433,7 @@ def checkpoint(
|
|
|
415
433
|
bundle_bytes = build_bundle(
|
|
416
434
|
metadata=metadata,
|
|
417
435
|
diff_text=diff_text,
|
|
436
|
+
uncommitted_diff_text=uncommitted_diff_text,
|
|
418
437
|
prompts_text=ctx.prompts_path.read_text(encoding="utf-8"),
|
|
419
438
|
checkpoint_text=message,
|
|
420
439
|
)
|
|
@@ -505,11 +524,10 @@ def verify(
|
|
|
505
524
|
ctx, metadata = load_existing_session(Path.cwd())
|
|
506
525
|
stack_id = metadata.stack_id
|
|
507
526
|
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
527
|
base_sha = read_init_base_sha(ctx.assertion_dir)
|
|
511
528
|
head_branch = get_head_branch(ctx.repo_root)
|
|
512
|
-
diff_text =
|
|
529
|
+
diff_text = get_full_diff(ctx.repo_root, base_sha)
|
|
530
|
+
uncommitted_diff_text = get_uncommitted_diff(ctx.repo_root)
|
|
513
531
|
metadata = update_metadata_anchor(
|
|
514
532
|
ctx.metadata_path, metadata, base_sha, head_branch=head_branch
|
|
515
533
|
)
|
|
@@ -517,6 +535,7 @@ def verify(
|
|
|
517
535
|
bundle_bytes = build_bundle(
|
|
518
536
|
metadata=metadata,
|
|
519
537
|
diff_text=diff_text,
|
|
538
|
+
uncommitted_diff_text=uncommitted_diff_text,
|
|
520
539
|
prompts_text=ctx.prompts_path.read_text(encoding="utf-8"),
|
|
521
540
|
checkpoint_text=ctx.checkpoint_path.read_text(encoding="utf-8"),
|
|
522
541
|
)
|
|
@@ -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
|
|
@@ -6,8 +6,12 @@ from click.exceptions import Exit as ClickExit
|
|
|
6
6
|
|
|
7
7
|
from git import (
|
|
8
8
|
find_git_root,
|
|
9
|
+
get_full_diff,
|
|
10
|
+
get_head_branch,
|
|
9
11
|
get_head_sha,
|
|
12
|
+
get_merge_base,
|
|
10
13
|
get_uncommitted_diff,
|
|
14
|
+
get_upstream_ref,
|
|
11
15
|
run_git_command,
|
|
12
16
|
)
|
|
13
17
|
|
|
@@ -16,7 +20,6 @@ from git import (
|
|
|
16
20
|
|
|
17
21
|
|
|
18
22
|
def test_run_git_command_success(tmp_path: Path) -> None:
|
|
19
|
-
# init a real git repo
|
|
20
23
|
subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True)
|
|
21
24
|
result = run_git_command(tmp_path, ["rev-parse", "--git-dir"])
|
|
22
25
|
assert result == ".git"
|
|
@@ -66,6 +69,142 @@ def test_get_head_sha(tmp_path: Path) -> None:
|
|
|
66
69
|
assert len(sha) == 40
|
|
67
70
|
|
|
68
71
|
|
|
72
|
+
# --- get_upstream_ref ---
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_get_upstream_ref_no_remote(tmp_path: Path) -> None:
|
|
76
|
+
"""A bare repo with no remote returns None."""
|
|
77
|
+
subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True)
|
|
78
|
+
subprocess.run(
|
|
79
|
+
["git", "commit", "--allow-empty", "-m", "init"],
|
|
80
|
+
cwd=tmp_path,
|
|
81
|
+
capture_output=True,
|
|
82
|
+
check=True,
|
|
83
|
+
env={
|
|
84
|
+
"GIT_AUTHOR_NAME": "T",
|
|
85
|
+
"GIT_AUTHOR_EMAIL": "t@t",
|
|
86
|
+
"GIT_COMMITTER_NAME": "T",
|
|
87
|
+
"GIT_COMMITTER_EMAIL": "t@t",
|
|
88
|
+
"HOME": str(tmp_path),
|
|
89
|
+
"PATH": "/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin",
|
|
90
|
+
},
|
|
91
|
+
)
|
|
92
|
+
ref = get_upstream_ref(tmp_path)
|
|
93
|
+
assert ref is None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_get_upstream_ref_picks_configured_upstream(tmp_path: Path) -> None:
|
|
97
|
+
"""When @{upstream} is configured it should be returned."""
|
|
98
|
+
subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True)
|
|
99
|
+
subprocess.run(
|
|
100
|
+
["git", "commit", "--allow-empty", "-m", "init"],
|
|
101
|
+
cwd=tmp_path,
|
|
102
|
+
capture_output=True,
|
|
103
|
+
check=True,
|
|
104
|
+
env={
|
|
105
|
+
"GIT_AUTHOR_NAME": "T",
|
|
106
|
+
"GIT_AUTHOR_EMAIL": "t@t",
|
|
107
|
+
"GIT_COMMITTER_NAME": "T",
|
|
108
|
+
"GIT_COMMITTER_EMAIL": "t@t",
|
|
109
|
+
"HOME": str(tmp_path),
|
|
110
|
+
"PATH": "/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin",
|
|
111
|
+
},
|
|
112
|
+
)
|
|
113
|
+
subprocess.run(
|
|
114
|
+
["git", "remote", "add", "origin", "http://example.com/repo.git"],
|
|
115
|
+
cwd=tmp_path, capture_output=True, check=True,
|
|
116
|
+
)
|
|
117
|
+
subprocess.run(
|
|
118
|
+
["git", "update-ref", "refs/remotes/origin/main", "HEAD"],
|
|
119
|
+
cwd=tmp_path, capture_output=True, check=True,
|
|
120
|
+
)
|
|
121
|
+
subprocess.run(
|
|
122
|
+
["git", "branch", "-u", "origin/main"],
|
|
123
|
+
cwd=tmp_path, capture_output=True, check=True,
|
|
124
|
+
)
|
|
125
|
+
ref = get_upstream_ref(tmp_path)
|
|
126
|
+
assert ref == "origin/main"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_get_upstream_ref_fallback_origin_branch(tmp_path: Path) -> None:
|
|
130
|
+
"""When @{upstream} is not configured but origin/<branch> exists, use it."""
|
|
131
|
+
subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True)
|
|
132
|
+
subprocess.run(
|
|
133
|
+
["git", "commit", "--allow-empty", "-m", "init"],
|
|
134
|
+
cwd=tmp_path,
|
|
135
|
+
capture_output=True,
|
|
136
|
+
check=True,
|
|
137
|
+
env={
|
|
138
|
+
"GIT_AUTHOR_NAME": "T",
|
|
139
|
+
"GIT_AUTHOR_EMAIL": "t@t",
|
|
140
|
+
"GIT_COMMITTER_NAME": "T",
|
|
141
|
+
"GIT_COMMITTER_EMAIL": "t@t",
|
|
142
|
+
"HOME": str(tmp_path),
|
|
143
|
+
"PATH": "/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin",
|
|
144
|
+
},
|
|
145
|
+
)
|
|
146
|
+
subprocess.run(
|
|
147
|
+
["git", "remote", "add", "origin", "http://example.com/repo.git"],
|
|
148
|
+
cwd=tmp_path, capture_output=True, check=True,
|
|
149
|
+
)
|
|
150
|
+
branch = subprocess.run(
|
|
151
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
152
|
+
cwd=tmp_path, capture_output=True, text=True, check=True,
|
|
153
|
+
).stdout.strip()
|
|
154
|
+
subprocess.run(
|
|
155
|
+
["git", "update-ref", f"refs/remotes/origin/{branch}", "HEAD"],
|
|
156
|
+
cwd=tmp_path, capture_output=True, check=True,
|
|
157
|
+
)
|
|
158
|
+
ref = get_upstream_ref(tmp_path)
|
|
159
|
+
assert ref == f"origin/{branch}"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_get_upstream_ref_detached_head(tmp_path: Path) -> None:
|
|
163
|
+
"""Detached HEAD returns None when origin/<branch> is also unset."""
|
|
164
|
+
subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True)
|
|
165
|
+
subprocess.run(
|
|
166
|
+
["git", "commit", "--allow-empty", "-m", "init"],
|
|
167
|
+
cwd=tmp_path,
|
|
168
|
+
capture_output=True,
|
|
169
|
+
check=True,
|
|
170
|
+
env={
|
|
171
|
+
"GIT_AUTHOR_NAME": "T",
|
|
172
|
+
"GIT_AUTHOR_EMAIL": "t@t",
|
|
173
|
+
"GIT_COMMITTER_NAME": "T",
|
|
174
|
+
"GIT_COMMITTER_EMAIL": "t@t",
|
|
175
|
+
"HOME": str(tmp_path),
|
|
176
|
+
"PATH": "/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin",
|
|
177
|
+
},
|
|
178
|
+
)
|
|
179
|
+
ref = get_upstream_ref(tmp_path)
|
|
180
|
+
assert ref is None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# --- get_merge_base ---
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def test_get_merge_base_returns_head_when_at_upstream(tmp_path: Path) -> None:
|
|
187
|
+
"""When HEAD is at the merge-base with its upstream, they should match."""
|
|
188
|
+
subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True)
|
|
189
|
+
subprocess.run(
|
|
190
|
+
["git", "commit", "--allow-empty", "-m", "init"],
|
|
191
|
+
cwd=tmp_path,
|
|
192
|
+
capture_output=True,
|
|
193
|
+
check=True,
|
|
194
|
+
env={
|
|
195
|
+
"GIT_AUTHOR_NAME": "T",
|
|
196
|
+
"GIT_AUTHOR_EMAIL": "t@t",
|
|
197
|
+
"GIT_COMMITTER_NAME": "T",
|
|
198
|
+
"GIT_COMMITTER_EMAIL": "t@t",
|
|
199
|
+
"HOME": str(tmp_path),
|
|
200
|
+
"PATH": "/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin",
|
|
201
|
+
},
|
|
202
|
+
)
|
|
203
|
+
head = get_head_sha(tmp_path)
|
|
204
|
+
merge_base = get_merge_base(tmp_path, head)
|
|
205
|
+
assert merge_base == head
|
|
206
|
+
|
|
207
|
+
|
|
69
208
|
# --- get_uncommitted_diff ---
|
|
70
209
|
|
|
71
210
|
|
|
@@ -92,13 +231,12 @@ def _init_repo_with_commit(tmp_path: Path) -> None:
|
|
|
92
231
|
|
|
93
232
|
def test_get_uncommitted_diff_clean(tmp_path: Path) -> None:
|
|
94
233
|
_init_repo_with_commit(tmp_path)
|
|
95
|
-
diff = get_uncommitted_diff(tmp_path
|
|
234
|
+
diff = get_uncommitted_diff(tmp_path)
|
|
96
235
|
assert diff == ""
|
|
97
236
|
|
|
98
237
|
|
|
99
238
|
def test_get_uncommitted_diff_tracked_changes(tmp_path: Path) -> None:
|
|
100
239
|
_init_repo_with_commit(tmp_path)
|
|
101
|
-
# Create and commit a file, then modify it
|
|
102
240
|
f = tmp_path / "hello.txt"
|
|
103
241
|
f.write_text("original\n")
|
|
104
242
|
subprocess.run(
|
|
@@ -112,7 +250,7 @@ def test_get_uncommitted_diff_tracked_changes(tmp_path: Path) -> None:
|
|
|
112
250
|
env={**GIT_ENV, "HOME": str(tmp_path)},
|
|
113
251
|
)
|
|
114
252
|
f.write_text("modified\n")
|
|
115
|
-
diff = get_uncommitted_diff(tmp_path
|
|
253
|
+
diff = get_uncommitted_diff(tmp_path)
|
|
116
254
|
assert "hello.txt" in diff
|
|
117
255
|
assert "-original" in diff
|
|
118
256
|
assert "+modified" in diff
|
|
@@ -125,7 +263,7 @@ def test_get_uncommitted_diff_staged_changes(tmp_path: Path) -> None:
|
|
|
125
263
|
subprocess.run(
|
|
126
264
|
["git", "add", "staged.txt"], cwd=tmp_path, capture_output=True, check=True
|
|
127
265
|
)
|
|
128
|
-
diff = get_uncommitted_diff(tmp_path
|
|
266
|
+
diff = get_uncommitted_diff(tmp_path)
|
|
129
267
|
assert "staged.txt" in diff
|
|
130
268
|
assert "+staged content" in diff
|
|
131
269
|
|
|
@@ -134,7 +272,7 @@ def test_get_uncommitted_diff_untracked_files(tmp_path: Path) -> None:
|
|
|
134
272
|
_init_repo_with_commit(tmp_path)
|
|
135
273
|
f = tmp_path / "new_file.py"
|
|
136
274
|
f.write_text("print('hello')\n")
|
|
137
|
-
diff = get_uncommitted_diff(tmp_path
|
|
275
|
+
diff = get_uncommitted_diff(tmp_path)
|
|
138
276
|
assert "new_file.py" in diff
|
|
139
277
|
assert "new file mode" in diff
|
|
140
278
|
assert "+print('hello')" in diff
|
|
@@ -142,7 +280,6 @@ def test_get_uncommitted_diff_untracked_files(tmp_path: Path) -> None:
|
|
|
142
280
|
|
|
143
281
|
def test_get_uncommitted_diff_combines_all_sources(tmp_path: Path) -> None:
|
|
144
282
|
_init_repo_with_commit(tmp_path)
|
|
145
|
-
# Tracked modification
|
|
146
283
|
tracked = tmp_path / "tracked.txt"
|
|
147
284
|
tracked.write_text("v1\n")
|
|
148
285
|
subprocess.run(
|
|
@@ -156,16 +293,14 @@ def test_get_uncommitted_diff_combines_all_sources(tmp_path: Path) -> None:
|
|
|
156
293
|
env={**GIT_ENV, "HOME": str(tmp_path)},
|
|
157
294
|
)
|
|
158
295
|
tracked.write_text("v2\n")
|
|
159
|
-
# Staged new file
|
|
160
296
|
staged = tmp_path / "staged.txt"
|
|
161
297
|
staged.write_text("new\n")
|
|
162
298
|
subprocess.run(
|
|
163
299
|
["git", "add", "staged.txt"], cwd=tmp_path, capture_output=True, check=True
|
|
164
300
|
)
|
|
165
|
-
# Untracked file
|
|
166
301
|
(tmp_path / "untracked.txt").write_text("surprise\n")
|
|
167
302
|
|
|
168
|
-
diff = get_uncommitted_diff(tmp_path
|
|
303
|
+
diff = get_uncommitted_diff(tmp_path)
|
|
169
304
|
assert "tracked.txt" in diff
|
|
170
305
|
assert "staged.txt" in diff
|
|
171
306
|
assert "untracked.txt" in diff
|
|
@@ -176,7 +311,7 @@ def test_get_uncommitted_diff_includes_untracked_file(tmp_path: Path) -> None:
|
|
|
176
311
|
new_file = tmp_path / "notes.txt"
|
|
177
312
|
new_file.write_text("hello\nworld\n", encoding="utf-8")
|
|
178
313
|
|
|
179
|
-
diff = get_uncommitted_diff(tmp_path
|
|
314
|
+
diff = get_uncommitted_diff(tmp_path)
|
|
180
315
|
|
|
181
316
|
assert "diff --git a/notes.txt b/notes.txt" in diff
|
|
182
317
|
assert "new file mode" in diff
|
|
@@ -189,22 +324,16 @@ def test_get_uncommitted_diff_includes_empty_untracked_file(tmp_path: Path) -> N
|
|
|
189
324
|
_init_repo_with_commit(tmp_path)
|
|
190
325
|
(tmp_path / "empty.txt").write_text("", encoding="utf-8")
|
|
191
326
|
|
|
192
|
-
diff = get_uncommitted_diff(tmp_path
|
|
327
|
+
diff = get_uncommitted_diff(tmp_path)
|
|
193
328
|
|
|
194
329
|
assert "diff --git a/empty.txt b/empty.txt" in diff
|
|
195
330
|
assert "new file mode" in diff
|
|
196
331
|
|
|
197
332
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
def test_get_uncommitted_diff_includes_local_only_commit_against_base(
|
|
202
|
-
tmp_path: Path,
|
|
203
|
-
) -> None:
|
|
204
|
-
"""A local-only commit on top of the recorded base must show up in the diff
|
|
205
|
-
sent to the verifier — otherwise the verifier would never see it."""
|
|
333
|
+
def test_get_uncommitted_diff_excludes_local_commits(tmp_path: Path) -> None:
|
|
334
|
+
"""A local-only commit must NOT show up in the uncommitted diff."""
|
|
206
335
|
_init_repo_with_commit(tmp_path)
|
|
207
|
-
|
|
336
|
+
head_at_init = run_git_command(tmp_path, ["rev-parse", "HEAD"])
|
|
208
337
|
|
|
209
338
|
(tmp_path / "local.txt").write_text("local change\n")
|
|
210
339
|
env = {**GIT_ENV, "HOME": str(tmp_path)}
|
|
@@ -219,9 +348,50 @@ def test_get_uncommitted_diff_includes_local_only_commit_against_base(
|
|
|
219
348
|
env=env,
|
|
220
349
|
)
|
|
221
350
|
|
|
222
|
-
diff = get_uncommitted_diff(tmp_path
|
|
223
|
-
assert "
|
|
224
|
-
|
|
351
|
+
diff = get_uncommitted_diff(tmp_path)
|
|
352
|
+
assert diff == "", (
|
|
353
|
+
"Local-only committed changes must not appear in the uncommitted diff"
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
# Verify the full diff against base_sha still includes them.
|
|
357
|
+
full_diff = get_full_diff(tmp_path, head_at_init)
|
|
358
|
+
assert "local.txt" in full_diff
|
|
359
|
+
assert "+local change" in full_diff
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
# --- get_full_diff ---
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def test_get_full_diff_includes_local_commits(tmp_path: Path) -> None:
|
|
366
|
+
_init_repo_with_commit(tmp_path)
|
|
367
|
+
base_sha = run_git_command(tmp_path, ["rev-parse", "HEAD"])
|
|
368
|
+
|
|
369
|
+
(tmp_path / "full.txt").write_text("full change\n")
|
|
370
|
+
env = {**GIT_ENV, "HOME": str(tmp_path)}
|
|
371
|
+
subprocess.run(
|
|
372
|
+
["git", "add", "full.txt"], cwd=tmp_path, capture_output=True, check=True
|
|
373
|
+
)
|
|
374
|
+
subprocess.run(
|
|
375
|
+
["git", "commit", "-m", "full-only"],
|
|
376
|
+
cwd=tmp_path,
|
|
377
|
+
capture_output=True,
|
|
378
|
+
check=True,
|
|
379
|
+
env=env,
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
full_diff = get_full_diff(tmp_path, base_sha)
|
|
383
|
+
assert "full.txt" in full_diff
|
|
384
|
+
assert "+full change" in full_diff
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def test_get_full_diff_includes_uncommitted(tmp_path: Path) -> None:
|
|
388
|
+
_init_repo_with_commit(tmp_path)
|
|
389
|
+
base_sha = run_git_command(tmp_path, ["rev-parse", "HEAD"])
|
|
390
|
+
|
|
391
|
+
(tmp_path / "unstaged.txt").write_text("unstaged\n")
|
|
392
|
+
|
|
393
|
+
full_diff = get_full_diff(tmp_path, base_sha)
|
|
394
|
+
assert "unstaged.txt" in full_diff
|
|
225
395
|
|
|
226
396
|
|
|
227
397
|
# --- trailing newline (diff must never be corrupt for git apply) ---
|
|
@@ -231,7 +401,6 @@ def test_get_uncommitted_diff_ends_with_newline_when_not_empty(tmp_path: Path) -
|
|
|
231
401
|
"""Diff must end with \\n so git apply on the receiving end never chokes."""
|
|
232
402
|
_init_repo_with_commit(tmp_path)
|
|
233
403
|
|
|
234
|
-
# Tracked change
|
|
235
404
|
f = tmp_path / "file.txt"
|
|
236
405
|
f.write_text("content\n")
|
|
237
406
|
subprocess.run(
|
|
@@ -246,13 +415,12 @@ def test_get_uncommitted_diff_ends_with_newline_when_not_empty(tmp_path: Path) -
|
|
|
246
415
|
)
|
|
247
416
|
f.write_text("modified\n")
|
|
248
417
|
|
|
249
|
-
diff = get_uncommitted_diff(tmp_path
|
|
418
|
+
diff = get_uncommitted_diff(tmp_path)
|
|
250
419
|
assert diff != ""
|
|
251
420
|
assert diff.endswith("\n"), f"tracked diff should end with newline, got: {diff[-10:]!r}"
|
|
252
421
|
|
|
253
|
-
# Untracked only
|
|
254
422
|
(tmp_path / "untracked.py").write_text("x = 1\n")
|
|
255
|
-
diff2 = get_uncommitted_diff(tmp_path
|
|
423
|
+
diff2 = get_uncommitted_diff(tmp_path)
|
|
256
424
|
assert diff2 != ""
|
|
257
425
|
assert diff2.endswith("\n"), f"untracked diff should end with newline, got: {diff2[-10:]!r}"
|
|
258
426
|
|
|
@@ -282,7 +450,6 @@ def test_get_uncommitted_diff_preserves_trailing_blank_context_lines(
|
|
|
282
450
|
check=True,
|
|
283
451
|
env={**GIT_ENV, "HOME": str(tmp_path)},
|
|
284
452
|
)
|
|
285
|
-
base_sha = run_git_command(tmp_path, ["rev-parse", "HEAD"])
|
|
286
453
|
|
|
287
454
|
source.write_text(
|
|
288
455
|
"\n".join(
|
|
@@ -296,7 +463,7 @@ def test_get_uncommitted_diff_preserves_trailing_blank_context_lines(
|
|
|
296
463
|
encoding="utf-8",
|
|
297
464
|
)
|
|
298
465
|
|
|
299
|
-
diff = get_uncommitted_diff(tmp_path
|
|
466
|
+
diff = get_uncommitted_diff(tmp_path)
|
|
300
467
|
|
|
301
468
|
assert diff.endswith(" \n")
|
|
302
469
|
subprocess.run(["git", "checkout", "--", "crypto.ts"], cwd=tmp_path, check=True)
|
|
@@ -34,14 +34,18 @@ def _run_git(repo: Path, *args: str) -> str:
|
|
|
34
34
|
def _init_repo(tmp_path: Path) -> str:
|
|
35
35
|
_run_git(tmp_path, "init")
|
|
36
36
|
_run_git(tmp_path, "commit", "--allow-empty", "-m", "init")
|
|
37
|
-
|
|
37
|
+
branch = _run_git(tmp_path, "rev-parse", "--abbrev-ref", "HEAD")
|
|
38
|
+
_run_git(tmp_path, "remote", "add", "origin", "http://example.com/repo.git")
|
|
39
|
+
_run_git(tmp_path, "update-ref", f"refs/remotes/origin/{branch}", "HEAD")
|
|
40
|
+
_run_git(tmp_path, "branch", "-u", f"origin/{branch}")
|
|
41
|
+
return _run_git(tmp_path, "merge-base", "HEAD", f"origin/{branch}")
|
|
38
42
|
|
|
39
43
|
|
|
40
|
-
def
|
|
44
|
+
def test_new_clears_run_state_and_records_merge_base(
|
|
41
45
|
tmp_path: Path,
|
|
42
46
|
monkeypatch,
|
|
43
47
|
) -> None:
|
|
44
|
-
|
|
48
|
+
merge_base = _init_repo(tmp_path)
|
|
45
49
|
monkeypatch.chdir(tmp_path)
|
|
46
50
|
|
|
47
51
|
assertion_dir = tmp_path / ".assertion"
|
|
@@ -59,7 +63,7 @@ def test_new_clears_run_state_and_records_current_head(
|
|
|
59
63
|
result = runner.invoke(main.app, ["new"])
|
|
60
64
|
|
|
61
65
|
assert result.exit_code == 0, result.stderr
|
|
62
|
-
assert (assertion_dir / "base_sha").read_text() ==
|
|
66
|
+
assert (assertion_dir / "base_sha").read_text() == merge_base + "\n"
|
|
63
67
|
assert not (assertion_dir / "metadata.json").exists()
|
|
64
68
|
assert not (assertion_dir / "prompts").exists()
|
|
65
69
|
assert not (assertion_dir / "checkpoint").exists()
|
|
@@ -73,25 +77,25 @@ def test_new_succeeds_when_assertion_dir_missing(
|
|
|
73
77
|
tmp_path: Path,
|
|
74
78
|
monkeypatch,
|
|
75
79
|
) -> None:
|
|
76
|
-
|
|
80
|
+
merge_base = _init_repo(tmp_path)
|
|
77
81
|
monkeypatch.chdir(tmp_path)
|
|
78
82
|
|
|
79
83
|
result = runner.invoke(main.app, ["new"])
|
|
80
84
|
|
|
81
85
|
assert result.exit_code == 0, result.stderr
|
|
82
|
-
assert (tmp_path / ".assertion" / "base_sha").read_text() ==
|
|
86
|
+
assert (tmp_path / ".assertion" / "base_sha").read_text() == merge_base + "\n"
|
|
83
87
|
|
|
84
88
|
|
|
85
|
-
def
|
|
89
|
+
def test_new_keeps_merge_base_after_local_head_changes(
|
|
86
90
|
tmp_path: Path,
|
|
87
91
|
monkeypatch,
|
|
88
92
|
) -> None:
|
|
89
|
-
|
|
93
|
+
merge_base = _init_repo(tmp_path)
|
|
90
94
|
monkeypatch.chdir(tmp_path)
|
|
91
95
|
|
|
92
96
|
first_result = runner.invoke(main.app, ["new"])
|
|
93
97
|
assert first_result.exit_code == 0, first_result.stderr
|
|
94
|
-
assert (tmp_path / ".assertion" / "base_sha").read_text() ==
|
|
98
|
+
assert (tmp_path / ".assertion" / "base_sha").read_text() == merge_base + "\n"
|
|
95
99
|
|
|
96
100
|
_run_git(tmp_path, "commit", "--allow-empty", "-m", "second")
|
|
97
101
|
second_head = _run_git(tmp_path, "rev-parse", "HEAD")
|
|
@@ -99,8 +103,8 @@ def test_new_updates_base_sha_after_head_changes(
|
|
|
99
103
|
second_result = runner.invoke(main.app, ["new"])
|
|
100
104
|
|
|
101
105
|
assert second_result.exit_code == 0, second_result.stderr
|
|
102
|
-
assert second_head !=
|
|
103
|
-
assert (tmp_path / ".assertion" / "base_sha").read_text() ==
|
|
106
|
+
assert second_head != merge_base
|
|
107
|
+
assert (tmp_path / ".assertion" / "base_sha").read_text() == merge_base + "\n"
|
|
104
108
|
|
|
105
109
|
|
|
106
110
|
def test_new_leaves_next_checkpoint_requiring_stack(
|
|
@@ -119,3 +123,17 @@ def test_new_leaves_next_checkpoint_requiring_stack(
|
|
|
119
123
|
|
|
120
124
|
assert result.exit_code == 0, result.stderr
|
|
121
125
|
assert not (assertion_dir / "metadata.json").exists()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_new_requires_upstream_tracking_branch(
|
|
129
|
+
tmp_path: Path,
|
|
130
|
+
monkeypatch,
|
|
131
|
+
) -> None:
|
|
132
|
+
_run_git(tmp_path, "init")
|
|
133
|
+
_run_git(tmp_path, "commit", "--allow-empty", "-m", "init")
|
|
134
|
+
monkeypatch.chdir(tmp_path)
|
|
135
|
+
|
|
136
|
+
result = runner.invoke(main.app, ["new"])
|
|
137
|
+
|
|
138
|
+
assert result.exit_code == 1
|
|
139
|
+
assert "no upstream tracking branch" in result.stderr
|
|
@@ -20,20 +20,30 @@ _GIT_COMMIT_ENV = {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
|
|
23
|
-
def
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
resolvable HEAD.
|
|
28
|
-
"""
|
|
29
|
-
subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True)
|
|
30
|
-
subprocess.run(
|
|
31
|
-
["git", "commit", "--allow-empty", "-m", "init"],
|
|
32
|
-
cwd=tmp_path,
|
|
23
|
+
def _run_git(repo: Path, *args: str) -> str:
|
|
24
|
+
result = subprocess.run(
|
|
25
|
+
["git", *args],
|
|
26
|
+
cwd=repo,
|
|
33
27
|
capture_output=True,
|
|
34
28
|
check=True,
|
|
35
|
-
env={**_GIT_COMMIT_ENV, "HOME": str(
|
|
29
|
+
env={**_GIT_COMMIT_ENV, "HOME": str(repo)},
|
|
30
|
+
text=True,
|
|
36
31
|
)
|
|
32
|
+
return result.stdout.strip()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _init_repo(tmp_path: Path) -> None:
|
|
36
|
+
"""Init a git repo with an upstream ref so merge-base resolution works.
|
|
37
|
+
|
|
38
|
+
`asrt session start` records the merge-base with the upstream branch as the
|
|
39
|
+
diff base, so tests need a resolvable upstream.
|
|
40
|
+
"""
|
|
41
|
+
_run_git(tmp_path, "init")
|
|
42
|
+
_run_git(tmp_path, "commit", "--allow-empty", "-m", "init")
|
|
43
|
+
branch = _run_git(tmp_path, "rev-parse", "--abbrev-ref", "HEAD")
|
|
44
|
+
_run_git(tmp_path, "remote", "add", "origin", "http://example.com/repo.git")
|
|
45
|
+
_run_git(tmp_path, "update-ref", f"refs/remotes/origin/{branch}", "HEAD")
|
|
46
|
+
_run_git(tmp_path, "branch", "-u", f"origin/{branch}")
|
|
37
47
|
|
|
38
48
|
|
|
39
49
|
def test_session_start_does_not_write_skill_or_agent_files(
|
|
@@ -187,30 +197,46 @@ def test_session_start_requires_git_repo(tmp_path: Path, monkeypatch) -> None:
|
|
|
187
197
|
assert "git repository" in result.stderr
|
|
188
198
|
|
|
189
199
|
|
|
190
|
-
def
|
|
191
|
-
|
|
200
|
+
def test_session_start_requires_upstream_tracking_branch(
|
|
201
|
+
tmp_path: Path, monkeypatch
|
|
202
|
+
) -> None:
|
|
203
|
+
_run_git(tmp_path, "init")
|
|
204
|
+
_run_git(tmp_path, "commit", "--allow-empty", "-m", "init")
|
|
205
|
+
monkeypatch.chdir(tmp_path)
|
|
192
206
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
207
|
+
result = runner.invoke(main.app, ["session", "start"])
|
|
208
|
+
|
|
209
|
+
assert result.exit_code == 1
|
|
210
|
+
assert "no upstream tracking branch" in result.stderr
|
|
211
|
+
assert not (tmp_path / ".assertion" / "base_sha").exists()
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def test_session_start_records_merge_base_as_base(
|
|
215
|
+
tmp_path: Path, monkeypatch
|
|
216
|
+
) -> None:
|
|
217
|
+
"""`asrt session start` pins the diff base to the branch merge-base.
|
|
218
|
+
|
|
219
|
+
Every subsequent checkpoint/verify diffs against this SHA, so backend can
|
|
220
|
+
check out the upstream-visible base before applying the full diff.
|
|
196
221
|
"""
|
|
197
222
|
_init_repo(tmp_path)
|
|
198
223
|
monkeypatch.chdir(tmp_path)
|
|
224
|
+
upstream = _run_git(
|
|
225
|
+
tmp_path, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
_run_git(tmp_path, "commit", "--allow-empty", "-m", "local-only")
|
|
199
229
|
|
|
200
230
|
result = runner.invoke(main.app, ["session", "start"])
|
|
201
231
|
assert result.exit_code == 0, result.stderr
|
|
202
232
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
cwd=tmp_path,
|
|
206
|
-
capture_output=True,
|
|
207
|
-
text=True,
|
|
208
|
-
check=True,
|
|
209
|
-
).stdout.strip()
|
|
233
|
+
merge_base = _run_git(tmp_path, "merge-base", "HEAD", upstream)
|
|
234
|
+
head_sha = _run_git(tmp_path, "rev-parse", "HEAD")
|
|
210
235
|
|
|
211
236
|
base_sha_path = tmp_path / ".assertion" / "base_sha"
|
|
212
237
|
assert base_sha_path.exists()
|
|
213
|
-
assert base_sha_path.read_text().strip() ==
|
|
238
|
+
assert base_sha_path.read_text().strip() == merge_base
|
|
239
|
+
assert merge_base != head_sha
|
|
214
240
|
|
|
215
241
|
|
|
216
242
|
def test_session_start_errors_when_repo_has_no_commits(
|
assertion_cli-0.5.8/git.py
DELETED
|
@@ -1,138 +0,0 @@
|
|
|
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_head_branch(repo_root: Path) -> str | None:
|
|
50
|
-
"""Return the current branch name, or None if HEAD is detached."""
|
|
51
|
-
try:
|
|
52
|
-
name = run_git_command(repo_root, ["rev-parse", "--abbrev-ref", "HEAD"])
|
|
53
|
-
except RuntimeError:
|
|
54
|
-
return None
|
|
55
|
-
return name if name and name != "HEAD" else None
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def _build_untracked_diff(repo_root: Path, rel_path: str) -> str:
|
|
59
|
-
completed = subprocess.run(
|
|
60
|
-
[
|
|
61
|
-
"git",
|
|
62
|
-
"diff",
|
|
63
|
-
"--no-index",
|
|
64
|
-
"--src-prefix=a/",
|
|
65
|
-
"--dst-prefix=b/",
|
|
66
|
-
"--",
|
|
67
|
-
"/dev/null",
|
|
68
|
-
rel_path,
|
|
69
|
-
],
|
|
70
|
-
cwd=repo_root,
|
|
71
|
-
capture_output=True,
|
|
72
|
-
text=True,
|
|
73
|
-
check=False,
|
|
74
|
-
)
|
|
75
|
-
if completed.returncode not in (0, 1):
|
|
76
|
-
message = completed.stderr.strip() or completed.stdout.strip() or "git failed"
|
|
77
|
-
raise RuntimeError(message)
|
|
78
|
-
return completed.stdout
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
# Paths the assertion-cli owns end-to-end. Excluded from the diff bundle so
|
|
82
|
-
# reviewers don't flag our own bootstrap files as "unrelated changes" — they're
|
|
83
|
-
# generated by `asrt install` and have nothing to do with the user's feature
|
|
84
|
-
# work. CLAUDE.md / AGENTS.md are intentionally NOT in this list: `asrt install`
|
|
85
|
-
# writes a small pointer block into them once at onboarding (meant to be
|
|
86
|
-
# committed before work starts, so it never shows in a work-session diff), but
|
|
87
|
-
# the rest of those files is the user's own and must flow through to reviewers.
|
|
88
|
-
_ASSERTION_EXCLUDED_PATHSPECS = [
|
|
89
|
-
":(exclude).assertion",
|
|
90
|
-
":(exclude).claude/skills/assertion-cli",
|
|
91
|
-
":(exclude).agents/skills/assertion-cli",
|
|
92
|
-
]
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
def get_uncommitted_diff(repo_root: Path, base_sha: str) -> str:
|
|
96
|
-
"""Build a unified diff from `base_sha` to the current working tree.
|
|
97
|
-
|
|
98
|
-
`git diff <base_sha>` compares the working tree directly to the base
|
|
99
|
-
commit, which subsumes both committed-since-base and unstaged tweaks in
|
|
100
|
-
one shot — so a single call covers all tracked changes regardless of
|
|
101
|
-
whether the agent committed locally. Untracked files are still added
|
|
102
|
-
separately as new-file diffs.
|
|
103
|
-
"""
|
|
104
|
-
try:
|
|
105
|
-
tracked = run_git_command(
|
|
106
|
-
repo_root,
|
|
107
|
-
["diff", base_sha, "--", *_ASSERTION_EXCLUDED_PATHSPECS],
|
|
108
|
-
preserve_stdout=True,
|
|
109
|
-
)
|
|
110
|
-
|
|
111
|
-
untracked_output = run_git_command(
|
|
112
|
-
repo_root,
|
|
113
|
-
[
|
|
114
|
-
"ls-files",
|
|
115
|
-
"--others",
|
|
116
|
-
"--exclude-standard",
|
|
117
|
-
"--",
|
|
118
|
-
*_ASSERTION_EXCLUDED_PATHSPECS,
|
|
119
|
-
],
|
|
120
|
-
)
|
|
121
|
-
untracked_diffs = [
|
|
122
|
-
_build_untracked_diff(repo_root, rel_path)
|
|
123
|
-
for rel_path in untracked_output.splitlines()
|
|
124
|
-
if rel_path
|
|
125
|
-
]
|
|
126
|
-
|
|
127
|
-
parts = [
|
|
128
|
-
_ensure_trailing_newline(p)
|
|
129
|
-
for p in [tracked, *untracked_diffs]
|
|
130
|
-
if p
|
|
131
|
-
]
|
|
132
|
-
return "".join(parts)
|
|
133
|
-
except RuntimeError as exc:
|
|
134
|
-
exit_with_error(f"Failed to collect git diff: {exc}")
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
def _ensure_trailing_newline(value: str) -> str:
|
|
138
|
-
return value if value.endswith("\n") else value + "\n"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|