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.
Files changed (32) hide show
  1. {assertion_cli-0.5.8 → assertion_cli-0.5.9}/PKG-INFO +1 -1
  2. {assertion_cli-0.5.8 → assertion_cli-0.5.9}/assertion_cli.egg-info/PKG-INFO +1 -1
  3. {assertion_cli-0.5.8 → assertion_cli-0.5.9}/bundle.py +3 -0
  4. assertion_cli-0.5.9/git.py +199 -0
  5. {assertion_cli-0.5.8 → assertion_cli-0.5.9}/main.py +35 -16
  6. {assertion_cli-0.5.8 → assertion_cli-0.5.9}/pyproject.toml +1 -1
  7. {assertion_cli-0.5.8 → assertion_cli-0.5.9}/session.py +5 -4
  8. {assertion_cli-0.5.8 → assertion_cli-0.5.9}/tests/test_bundle.py +8 -0
  9. {assertion_cli-0.5.8 → assertion_cli-0.5.9}/tests/test_git.py +197 -30
  10. {assertion_cli-0.5.8 → assertion_cli-0.5.9}/tests/test_new.py +29 -11
  11. {assertion_cli-0.5.8 → assertion_cli-0.5.9}/tests/test_session_start.py +50 -24
  12. assertion_cli-0.5.8/git.py +0 -138
  13. {assertion_cli-0.5.8 → assertion_cli-0.5.9}/README.md +0 -0
  14. {assertion_cli-0.5.8 → assertion_cli-0.5.9}/api.py +0 -0
  15. {assertion_cli-0.5.8 → assertion_cli-0.5.9}/assertion_cli.egg-info/SOURCES.txt +0 -0
  16. {assertion_cli-0.5.8 → assertion_cli-0.5.9}/assertion_cli.egg-info/dependency_links.txt +0 -0
  17. {assertion_cli-0.5.8 → assertion_cli-0.5.9}/assertion_cli.egg-info/entry_points.txt +0 -0
  18. {assertion_cli-0.5.8 → assertion_cli-0.5.9}/assertion_cli.egg-info/requires.txt +0 -0
  19. {assertion_cli-0.5.8 → assertion_cli-0.5.9}/assertion_cli.egg-info/top_level.txt +0 -0
  20. {assertion_cli-0.5.8 → assertion_cli-0.5.9}/link.py +0 -0
  21. {assertion_cli-0.5.8 → assertion_cli-0.5.9}/models.py +0 -0
  22. {assertion_cli-0.5.8 → assertion_cli-0.5.9}/setup.cfg +0 -0
  23. {assertion_cli-0.5.8 → assertion_cli-0.5.9}/templates/AGENTS.md +0 -0
  24. {assertion_cli-0.5.8 → assertion_cli-0.5.9}/templates/SKILL.md +0 -0
  25. {assertion_cli-0.5.8 → assertion_cli-0.5.9}/templates/__init__.py +0 -0
  26. {assertion_cli-0.5.8 → assertion_cli-0.5.9}/tests/test_api.py +0 -0
  27. {assertion_cli-0.5.8 → assertion_cli-0.5.9}/tests/test_decision.py +0 -0
  28. {assertion_cli-0.5.8 → assertion_cli-0.5.9}/tests/test_install.py +0 -0
  29. {assertion_cli-0.5.8 → assertion_cli-0.5.9}/tests/test_link.py +0 -0
  30. {assertion_cli-0.5.8 → assertion_cli-0.5.9}/tests/test_main.py +0 -0
  31. {assertion_cli-0.5.8 → assertion_cli-0.5.9}/tests/test_prompt.py +0 -0
  32. {assertion_cli-0.5.8 → assertion_cli-0.5.9}/tests/test_session.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: assertion-cli
3
- Version: 0.5.8
3
+ Version: 0.5.9
4
4
  Summary: CLI for the Assertion API
5
5
  Requires-Python: >=3.13
6
6
  Description-Content-Type: text/markdown
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: assertion-cli
3
- Version: 0.5.8
3
+ Version: 0.5.9
4
4
  Summary: CLI for the Assertion API
5
5
  Requires-Python: >=3.13
6
6
  Description-Content-Type: text/markdown
@@ -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
- get_head_sha,
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 current HEAD SHA as the diff base for this repo's work session.
285
+ """Record the merge-base with the upstream tracking branch as the diff base.
266
286
 
267
- The base is what the backend `git checkout`s against before applying the
268
- diff capturing it once here means later commands don't need to rediscover
269
- it (which previously broke in sandboxes with no remote-tracking refs).
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 = get_head_sha(repo_root)
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 current HEAD as the diff base."""
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 = get_head_sha(repo_root)
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 = get_uncommitted_diff(ctx.repo_root, base_sha)
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 = get_uncommitted_diff(ctx.repo_root, base_sha)
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
  )
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "assertion-cli"
7
- version = "0.5.8"
7
+ version = "0.5.9"
8
8
  description = "CLI for the Assertion API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13"
@@ -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
- Exits with a clear error if `asrt session start` hasn't been run or the file
23
- is empty. The base SHA is the commit the backend `git checkout`s against
24
- before applying the diff, so every checkpoint/verify must have it.
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 HEAD SHA that checkpoint/verify diff against."
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, "HEAD")
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, "HEAD")
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, "HEAD")
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, "HEAD")
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, "HEAD")
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, "HEAD")
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, "HEAD")
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
- # --- local-only commits flow through the diff against a recorded base ---
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
- base_sha = run_git_command(tmp_path, ["rev-parse", "HEAD"])
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, base_sha)
223
- assert "local.txt" in diff
224
- assert "+local change" in diff
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, "HEAD")
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, "HEAD")
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, base_sha)
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
- return _run_git(tmp_path, "rev-parse", "HEAD")
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 test_new_clears_run_state_and_records_current_head(
44
+ def test_new_clears_run_state_and_records_merge_base(
41
45
  tmp_path: Path,
42
46
  monkeypatch,
43
47
  ) -> None:
44
- head = _init_repo(tmp_path)
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() == head + "\n"
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
- head = _init_repo(tmp_path)
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() == head + "\n"
86
+ assert (tmp_path / ".assertion" / "base_sha").read_text() == merge_base + "\n"
83
87
 
84
88
 
85
- def test_new_updates_base_sha_after_head_changes(
89
+ def test_new_keeps_merge_base_after_local_head_changes(
86
90
  tmp_path: Path,
87
91
  monkeypatch,
88
92
  ) -> None:
89
- first_head = _init_repo(tmp_path)
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() == first_head + "\n"
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 != first_head
103
- assert (tmp_path / ".assertion" / "base_sha").read_text() == second_head + "\n"
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 _init_repo(tmp_path: Path) -> None:
24
- """Init a git repo with an initial commit so `git rev-parse HEAD` works.
25
-
26
- `asrt session start` records HEAD as the diff base, so tests need a
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(tmp_path)},
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 test_session_start_records_head_sha_as_base(tmp_path: Path, monkeypatch) -> None:
191
- """`asrt session start` pins the diff base for this repo to current HEAD.
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
- Every subsequent checkpoint/verify diffs against this SHA, so backend
194
- `git checkout` always targets a commit the user/agent has committed at
195
- least once locally (and presumably pushed).
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
- head_sha = subprocess.run(
204
- ["git", "rev-parse", "HEAD"],
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() == head_sha
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(
@@ -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