assertion-cli 0.5.8__tar.gz → 0.5.10__tar.gz

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