assertion-cli 0.5.12__tar.gz → 0.5.13__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 (31) hide show
  1. {assertion_cli-0.5.12 → assertion_cli-0.5.13}/PKG-INFO +1 -1
  2. {assertion_cli-0.5.12 → assertion_cli-0.5.13}/assertion_cli.egg-info/PKG-INFO +1 -1
  3. {assertion_cli-0.5.12 → assertion_cli-0.5.13}/assertion_cli.egg-info/SOURCES.txt +0 -1
  4. {assertion_cli-0.5.12 → assertion_cli-0.5.13}/bundle.py +8 -11
  5. {assertion_cli-0.5.12 → assertion_cli-0.5.13}/git.py +49 -0
  6. {assertion_cli-0.5.12 → assertion_cli-0.5.13}/main.py +101 -61
  7. {assertion_cli-0.5.12 → assertion_cli-0.5.13}/models.py +7 -7
  8. {assertion_cli-0.5.12 → assertion_cli-0.5.13}/pyproject.toml +1 -1
  9. {assertion_cli-0.5.12 → assertion_cli-0.5.13}/session.py +20 -19
  10. {assertion_cli-0.5.12 → assertion_cli-0.5.13}/templates/SKILL.md +10 -16
  11. {assertion_cli-0.5.12 → assertion_cli-0.5.13}/tests/test_bundle.py +10 -9
  12. {assertion_cli-0.5.12 → assertion_cli-0.5.13}/tests/test_git.py +135 -2
  13. {assertion_cli-0.5.12 → assertion_cli-0.5.13}/tests/test_install.py +2 -5
  14. {assertion_cli-0.5.12 → assertion_cli-0.5.13}/tests/test_session.py +9 -5
  15. {assertion_cli-0.5.12 → assertion_cli-0.5.13}/tests/test_session_start.py +44 -10
  16. assertion_cli-0.5.12/tests/test_new.py +0 -142
  17. {assertion_cli-0.5.12 → assertion_cli-0.5.13}/README.md +0 -0
  18. {assertion_cli-0.5.12 → assertion_cli-0.5.13}/api.py +0 -0
  19. {assertion_cli-0.5.12 → assertion_cli-0.5.13}/assertion_cli.egg-info/dependency_links.txt +0 -0
  20. {assertion_cli-0.5.12 → assertion_cli-0.5.13}/assertion_cli.egg-info/entry_points.txt +0 -0
  21. {assertion_cli-0.5.12 → assertion_cli-0.5.13}/assertion_cli.egg-info/requires.txt +0 -0
  22. {assertion_cli-0.5.12 → assertion_cli-0.5.13}/assertion_cli.egg-info/top_level.txt +0 -0
  23. {assertion_cli-0.5.12 → assertion_cli-0.5.13}/link.py +0 -0
  24. {assertion_cli-0.5.12 → assertion_cli-0.5.13}/setup.cfg +0 -0
  25. {assertion_cli-0.5.12 → assertion_cli-0.5.13}/templates/AGENTS.md +0 -0
  26. {assertion_cli-0.5.12 → assertion_cli-0.5.13}/templates/__init__.py +0 -0
  27. {assertion_cli-0.5.12 → assertion_cli-0.5.13}/tests/test_api.py +0 -0
  28. {assertion_cli-0.5.12 → assertion_cli-0.5.13}/tests/test_decision.py +0 -0
  29. {assertion_cli-0.5.12 → assertion_cli-0.5.13}/tests/test_link.py +0 -0
  30. {assertion_cli-0.5.12 → assertion_cli-0.5.13}/tests/test_main.py +0 -0
  31. {assertion_cli-0.5.12 → assertion_cli-0.5.13}/tests/test_prompt.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: assertion-cli
3
- Version: 0.5.12
3
+ Version: 0.5.13
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.12
3
+ Version: 0.5.13
4
4
  Summary: CLI for the Assertion API
5
5
  Requires-Python: >=3.13
6
6
  Description-Content-Type: text/markdown
@@ -23,7 +23,6 @@ tests/test_git.py
23
23
  tests/test_install.py
24
24
  tests/test_link.py
25
25
  tests/test_main.py
26
- tests/test_new.py
27
26
  tests/test_prompt.py
28
27
  tests/test_session.py
29
28
  tests/test_session_start.py
@@ -6,24 +6,21 @@ 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
+ REVIEW_DIFF_ARCHIVE_PATH = "review.diff"
10
10
  METADATA_ARCHIVE_PATH = f"{ASSERTION_DIR_NAME}/metadata.json"
11
11
  PROMPTS_ARCHIVE_PATH = f"{ASSERTION_DIR_NAME}/prompts"
12
12
  CHECKPOINT_ARCHIVE_PATH = f"{ASSERTION_DIR_NAME}/checkpoint"
13
13
 
14
14
 
15
15
  def _metadata_wire_json(metadata: MetadataPayload) -> str:
16
- """Serialize metadata for the bundle, remapping `base_sha` → `head_sha`.
16
+ """Serialize metadata for the bundle.
17
17
 
18
- The CLI stores the diff base as `base_sha` because that matches what the
19
- field means from the CLI's perspective (the foundation the diff builds
20
- on). The backend reads it as `head_sha` because after its `git checkout`
21
- that SHA literally is the clone's HEAD. Same value, different name per
22
- side; the remap lives here so neither side has to know about the other's
23
- perspective.
18
+ `clone_sha` is the remote commit the backend sandbox checks out, so it is
19
+ emitted as `head_sha` on the wire. `base_sha` is the verification base
20
+ (where the user wants to start verifying from) and is emitted as-is.
24
21
  """
25
22
  payload = metadata.model_dump()
26
- payload["head_sha"] = payload.pop("base_sha", None)
23
+ payload["head_sha"] = payload.pop("clone_sha", None)
27
24
  return json.dumps(payload, indent=2) + "\n"
28
25
 
29
26
 
@@ -31,7 +28,7 @@ def build_bundle(
31
28
  *,
32
29
  metadata: MetadataPayload,
33
30
  diff_text: str,
34
- uncommitted_diff_text: str,
31
+ review_diff_text: str,
35
32
  prompts_text: str,
36
33
  checkpoint_text: str,
37
34
  ) -> bytes:
@@ -41,5 +38,5 @@ def build_bundle(
41
38
  zf.writestr(PROMPTS_ARCHIVE_PATH, prompts_text)
42
39
  zf.writestr(CHECKPOINT_ARCHIVE_PATH, checkpoint_text)
43
40
  zf.writestr(DIFF_ARCHIVE_PATH, diff_text)
44
- zf.writestr(UNCOMMITTED_DIFF_ARCHIVE_PATH, uncommitted_diff_text)
41
+ zf.writestr(REVIEW_DIFF_ARCHIVE_PATH, review_diff_text)
45
42
  return buf.getvalue()
@@ -132,6 +132,55 @@ def get_merge_base(repo_root: Path, upstream: str) -> str:
132
132
  )
133
133
 
134
134
 
135
+ def is_ancestor(repo_root: Path, maybe_ancestor: str, descendant: str) -> bool:
136
+ """Return True if *maybe_ancestor* is an ancestor of *descendant*."""
137
+ try:
138
+ run_git_command(
139
+ repo_root, ["merge-base", "--is-ancestor", maybe_ancestor, descendant]
140
+ )
141
+ return True
142
+ except RuntimeError:
143
+ return False
144
+
145
+
146
+ def get_remote_ancestor(repo_root: Path, sha: str) -> str | None:
147
+ """Return the newest remote commit that is an ancestor of *sha*.
148
+
149
+ Uses local remote-tracking refs so this works offline. Returns ``None``
150
+ when no remote-tracking ref contains an ancestor of *sha*.
151
+ """
152
+ try:
153
+ merged = run_git_command(
154
+ repo_root,
155
+ ["branch", "-r", "--merged", sha, "--format=%(refname:short)"],
156
+ )
157
+ except RuntimeError:
158
+ return None
159
+
160
+ candidates = [r for r in merged.splitlines() if r]
161
+ if not candidates:
162
+ return None
163
+
164
+ newest_ref: str | None = None
165
+ newest_ts = -1
166
+ for ref in candidates:
167
+ try:
168
+ ts = int(run_git_command(repo_root, ["log", "-1", "--format=%ct", ref]))
169
+ except (RuntimeError, ValueError):
170
+ continue
171
+ if ts > newest_ts:
172
+ newest_ts = ts
173
+ newest_ref = ref
174
+
175
+ if newest_ref is None:
176
+ return None
177
+
178
+ try:
179
+ return run_git_command(repo_root, ["merge-base", sha, newest_ref])
180
+ except RuntimeError:
181
+ return None
182
+
183
+
135
184
  def get_head_branch(repo_root: Path) -> str | None:
136
185
  """Return the current branch name, or None if HEAD is detached."""
137
186
  try:
@@ -17,22 +17,22 @@ from git import (
17
17
  get_full_diff,
18
18
  get_head_branch,
19
19
  get_head_sha,
20
- get_merge_base,
21
- get_uncommitted_diff,
20
+ get_remote_ancestor,
22
21
  get_upstream_ref,
22
+ is_ancestor,
23
23
  run_git_command,
24
24
  )
25
25
  from link import load_link, save_link
26
26
  from models import CheckpointResponse, SessionStatus, render_stack_list
27
27
  from session import (
28
28
  ASSERTION_DIR_NAME,
29
- BASE_SHA_FILE_NAME,
30
29
  METADATA_FILE_NAME,
31
30
  PROMPTS_FILE_NAME,
31
+ VERIFY_BASE_SHA_FILE_NAME,
32
32
  append_checkpoint_entry,
33
33
  continue_session,
34
34
  load_existing_session,
35
- read_init_base_sha,
35
+ read_init_verify_base_sha,
36
36
  start_new_session,
37
37
  update_metadata_anchor,
38
38
  )
@@ -264,18 +264,24 @@ session_app = typer.Typer(help="Manage Assertion work sessions.")
264
264
  app.add_typer(session_app, name="session")
265
265
 
266
266
 
267
- def _resolve_diff_base(repo_root: Path) -> str:
268
- """Compute the merge-base with the upstream tracking branch.
267
+ def _resolve_verify_base(repo_root: Path, from_ref: str | None) -> str:
268
+ """Resolve the verification base SHA.
269
269
 
270
- Fetches from origin first so the merge-base SHA is guaranteed to exist
271
- on the remote when the backend checks it out stale remote tracking
272
- refs (from a force-pushed or rebased branch) would otherwise produce a
273
- merge-base that's no longer reachable from any remote ref.
274
-
275
- Falls back to HEAD when no upstream can be resolved (e.g. Codex cloud
276
- sandboxes with no remote tracking refs) — the branch was cloned from the
277
- remote so HEAD exists on the remote at session start time.
270
+ If *from_ref* is provided, resolve it and validate that it is an ancestor
271
+ of HEAD. Otherwise compute the merge-base with the upstream tracking branch,
272
+ falling back to HEAD when no upstream is available.
278
273
  """
274
+ if from_ref is not None:
275
+ try:
276
+ sha = run_git_command(repo_root, ["rev-parse", "--verify", from_ref])
277
+ except RuntimeError as exc:
278
+ exit_with_error(f"Invalid --from ref '{from_ref}': {exc}")
279
+ if not is_ancestor(repo_root, sha, "HEAD"):
280
+ exit_with_error(
281
+ f"--from ref {sha[:12]} is not an ancestor of HEAD."
282
+ )
283
+ return sha
284
+
279
285
  try:
280
286
  run_git_command(repo_root, ["fetch", "--no-tags", "origin"])
281
287
  except RuntimeError:
@@ -288,18 +294,61 @@ def _resolve_diff_base(repo_root: Path) -> str:
288
294
  err=True,
289
295
  )
290
296
  return get_head_sha(repo_root)
291
- return get_merge_base(repo_root, upstream)
297
+ try:
298
+ return run_git_command(repo_root, ["merge-base", "HEAD", upstream])
299
+ except RuntimeError as exc:
300
+ exit_with_error(
301
+ f"Failed to compute merge-base between HEAD and {upstream}: {exc}"
302
+ )
303
+
304
+
305
+ def _resolve_clone_anchor(repo_root: Path, verify_base: str) -> str:
306
+ """Return a remote commit the sandbox can check out.
307
+
308
+ If *verify_base* itself is reachable from a remote ref, use it. Otherwise
309
+ find the newest remote ancestor of *verify_base*. If no remote is reachable
310
+ at all, fall back to HEAD (the branch was cloned from the remote).
311
+ """
312
+ ancestor = get_remote_ancestor(repo_root, verify_base)
313
+ if ancestor is not None:
314
+ return ancestor
315
+
316
+ typer.echo(
317
+ "Warning: no remote reachable, falling back to HEAD for clone anchor.",
318
+ err=True,
319
+ )
320
+ return get_head_sha(repo_root)
321
+
322
+
323
+ def _reset_assertion_dir(assertion_dir: Path) -> int:
324
+ """Remove all local Assertion state, keeping committed seed hooks trackable."""
325
+ removed = 0
326
+ if not assertion_dir.exists():
327
+ return removed
328
+ for child in assertion_dir.iterdir():
329
+ if child.name == "seed.py":
330
+ continue
331
+ if child.is_dir():
332
+ shutil.rmtree(child)
333
+ else:
334
+ child.unlink()
335
+ removed += 1
336
+ return removed
292
337
 
293
338
 
294
339
  @session_app.command("start")
295
- def session_start() -> None:
296
- """Record the merge-base with the upstream tracking branch as the diff base.
340
+ def session_start(
341
+ from_ref: str | None = typer.Option(
342
+ None,
343
+ "--from",
344
+ help="Verification base ref (commit/branch/tag). Defaults to merge-base with upstream.",
345
+ ),
346
+ ) -> None:
347
+ """Start a fresh Assertion session and record the verification base.
297
348
 
298
- Unlike the old HEAD-based approach, the merge-base always exists on the
299
- remote so the backend can check it out. When no upstream tracking branch
300
- can be resolved (e.g. Codex cloud sandboxes), falls back to HEAD — the
301
- branch was cloned from the remote so HEAD exists on the remote at session
302
- start time.
349
+ Resets `.assertion/` (except `.assertion/seed.py`) so each session starts
350
+ clean. When no upstream tracking branch can be resolved, falls back to
351
+ HEAD as the verification base.
303
352
 
304
353
  `session start` does not write skill files — that is `asrt install`, the
305
354
  one-time onboarding step. It also never touches user content in `CLAUDE.md` /
@@ -309,14 +358,20 @@ def session_start() -> None:
309
358
  repo_root = find_git_root(Path.cwd())
310
359
  _ensure_gitignore_limits_assertion_state(repo_root)
311
360
 
312
- base_sha = _resolve_diff_base(repo_root)
361
+ verify_base = _resolve_verify_base(repo_root, from_ref)
313
362
  assertion_dir = repo_root / ASSERTION_DIR_NAME
314
363
  assertion_dir.mkdir(exist_ok=True)
315
- base_sha_path = assertion_dir / BASE_SHA_FILE_NAME
316
- base_sha_path.write_text(base_sha + "\n", encoding="utf-8")
364
+ removed = _reset_assertion_dir(assertion_dir)
365
+
366
+ verify_base_path = assertion_dir / VERIFY_BASE_SHA_FILE_NAME
367
+ verify_base_path.write_text(verify_base + "\n", encoding="utf-8")
317
368
  typer.echo(
318
- f"Recorded diff base {base_sha[:12]} → {base_sha_path.relative_to(repo_root)}"
369
+ f"Recorded verification base {verify_base[:12]} → {verify_base_path.relative_to(repo_root)}"
319
370
  )
371
+ if removed:
372
+ typer.echo(
373
+ f"Cleared {removed} prior .assertion entr{'y' if removed == 1 else 'ies'}."
374
+ )
320
375
 
321
376
  typer.echo("")
322
377
  typer.echo("The coding agent will now follow the Assertion workflow:")
@@ -325,31 +380,6 @@ def session_start() -> None:
325
380
  typer.echo(" - asrt verify at completion, then PR")
326
381
 
327
382
 
328
- @app.command("new")
329
- def new() -> None:
330
- """Reset local Assertion run state and record the merge-base as the diff base."""
331
- repo_root = find_git_root(Path.cwd())
332
- base_sha = _resolve_diff_base(repo_root)
333
- assertion_dir = repo_root / ASSERTION_DIR_NAME
334
- assertion_dir.mkdir(exist_ok=True)
335
-
336
- removed = 0
337
- for child in assertion_dir.iterdir():
338
- if child.is_dir():
339
- shutil.rmtree(child)
340
- else:
341
- child.unlink()
342
- removed += 1
343
-
344
- base_sha_path = assertion_dir / BASE_SHA_FILE_NAME
345
- base_sha_path.write_text(base_sha + "\n", encoding="utf-8")
346
-
347
- typer.echo(f"Cleared {removed} .assertion entr{'y' if removed == 1 else 'ies'}.")
348
- typer.echo(
349
- f"Recorded diff base {base_sha[:12]} → {base_sha_path.relative_to(repo_root)}"
350
- )
351
-
352
-
353
383
  @app.command("prompt")
354
384
  def prompt_cmd(
355
385
  text: str = typer.Argument(..., help="User prompt text to record."),
@@ -434,18 +464,23 @@ def checkpoint(
434
464
  assert stack_id is not None
435
465
 
436
466
  append_checkpoint_entry(ctx.checkpoint_path, message)
437
- base_sha = read_init_base_sha(ctx.assertion_dir)
467
+ verify_base = read_init_verify_base_sha(ctx.assertion_dir)
468
+ clone_anchor = _resolve_clone_anchor(ctx.repo_root, verify_base)
438
469
  head_branch = get_head_branch(ctx.repo_root)
439
- diff_text = get_full_diff(ctx.repo_root, base_sha)
440
- uncommitted_diff_text = get_uncommitted_diff(ctx.repo_root)
470
+ diff_text = get_full_diff(ctx.repo_root, clone_anchor)
471
+ review_diff_text = get_full_diff(ctx.repo_root, verify_base)
441
472
  metadata = update_metadata_anchor(
442
- ctx.metadata_path, metadata, base_sha, head_branch=head_branch
473
+ ctx.metadata_path,
474
+ metadata,
475
+ verify_base,
476
+ clone_sha=clone_anchor,
477
+ head_branch=head_branch,
443
478
  )
444
479
 
445
480
  bundle_bytes = build_bundle(
446
481
  metadata=metadata,
447
482
  diff_text=diff_text,
448
- uncommitted_diff_text=uncommitted_diff_text,
483
+ review_diff_text=review_diff_text,
449
484
  prompts_text=ctx.prompts_path.read_text(encoding="utf-8"),
450
485
  checkpoint_text=message,
451
486
  )
@@ -536,18 +571,23 @@ def verify(
536
571
  ctx, metadata = load_existing_session(Path.cwd())
537
572
  stack_id = metadata.stack_id
538
573
  assert stack_id is not None
539
- base_sha = read_init_base_sha(ctx.assertion_dir)
574
+ verify_base = read_init_verify_base_sha(ctx.assertion_dir)
575
+ clone_anchor = _resolve_clone_anchor(ctx.repo_root, verify_base)
540
576
  head_branch = get_head_branch(ctx.repo_root)
541
- diff_text = get_full_diff(ctx.repo_root, base_sha)
542
- uncommitted_diff_text = get_uncommitted_diff(ctx.repo_root)
577
+ diff_text = get_full_diff(ctx.repo_root, clone_anchor)
578
+ review_diff_text = get_full_diff(ctx.repo_root, verify_base)
543
579
  metadata = update_metadata_anchor(
544
- ctx.metadata_path, metadata, base_sha, head_branch=head_branch
580
+ ctx.metadata_path,
581
+ metadata,
582
+ verify_base,
583
+ clone_sha=clone_anchor,
584
+ head_branch=head_branch,
545
585
  )
546
586
 
547
587
  bundle_bytes = build_bundle(
548
588
  metadata=metadata,
549
589
  diff_text=diff_text,
550
- uncommitted_diff_text=uncommitted_diff_text,
590
+ review_diff_text=review_diff_text,
551
591
  prompts_text=ctx.prompts_path.read_text(encoding="utf-8"),
552
592
  checkpoint_text=ctx.checkpoint_path.read_text(encoding="utf-8"),
553
593
  )
@@ -64,13 +64,12 @@ class ErrorResponse(BaseModel):
64
64
  class MetadataPayload(BaseModel):
65
65
  """Local on-disk schema for `.assertion/metadata.json`.
66
66
 
67
- `base_sha` is the diff base (the commit `asrt session start` recorded; the commit
68
- the backend checks out before applying the diff). In the bundle sent to
69
- the backend it is remapped to `head_sha` for wire compatibility from
70
- the backend's perspective, after `git checkout <sha>`, that SHA IS its
71
- HEAD, so the name reads correctly on the consumer side. Locally we keep
72
- `base_sha` because it matches what the CLI stores (`.assertion/base_sha`)
73
- and what the value represents from the CLI's perspective.
67
+ `base_sha` is the verification base (the commit the user wants to start
68
+ verifying from). `clone_sha` is the commit the backend sandbox checks out
69
+ before applying the diff; it is always a remote commit (or `HEAD` as a
70
+ fallback when no remote is reachable). The bundle wire format maps
71
+ `clone_sha` to `head_sha` because from the backend's perspective that
72
+ SHA is the clone's HEAD.
74
73
  """
75
74
 
76
75
  model_config = ConfigDict(extra="allow")
@@ -78,6 +77,7 @@ class MetadataPayload(BaseModel):
78
77
  session_id: str
79
78
  stack_id: str | None = None
80
79
  base_sha: str | None = None
80
+ clone_sha: str | None = None
81
81
  head_branch: str | None = None
82
82
 
83
83
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "assertion-cli"
7
- version = "0.5.12"
7
+ version = "0.5.13"
8
8
  description = "CLI for the Assertion API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13"
@@ -13,28 +13,26 @@ ASSERTION_DIR_NAME = ".assertion"
13
13
  PROMPTS_FILE_NAME = "prompts"
14
14
  CHECKPOINT_FILE_NAME = "checkpoint"
15
15
  METADATA_FILE_NAME = "metadata.json"
16
- BASE_SHA_FILE_NAME = "base_sha"
16
+ VERIFY_BASE_SHA_FILE_NAME = "verify_base_sha"
17
17
 
18
18
 
19
- def read_init_base_sha(assertion_dir: Path) -> str:
20
- """Return the diff base SHA recorded by `asrt session start`.
19
+ def read_init_verify_base_sha(assertion_dir: Path) -> str:
20
+ """Return the verification base SHA recorded by `asrt session start`.
21
21
 
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.
22
+ The verify base is the commit the user wants to start verifying from.
23
+ Every checkpoint/verify reads this value; it never changes within a session.
25
24
  """
26
- path = assertion_dir / BASE_SHA_FILE_NAME
25
+ path = assertion_dir / VERIFY_BASE_SHA_FILE_NAME
27
26
  if not path.exists():
28
27
  exit_with_error(
29
- "No diff base recorded for this repo. Run `asrt session start` "
30
- "first — it captures the merge-base SHA that checkpoint/verify "
31
- "diff against."
28
+ "No verification base recorded for this repo. Run `asrt session start` "
29
+ "first — it captures the commit that checkpoint/verify diff against."
32
30
  )
33
31
  content = path.read_text(encoding="utf-8").strip()
34
32
  if not content:
35
33
  exit_with_error(
36
34
  f"{path.name} is empty. Re-run `asrt session start` to record the "
37
- "diff base."
35
+ "verification base."
38
36
  )
39
37
  return content
40
38
 
@@ -43,7 +41,7 @@ def _require_repo_ready(start_path: Path) -> Path:
43
41
  repo_root = find_git_root(start_path)
44
42
  # Fail fast if `asrt session start` hasn't run — checkpoint/verify need the
45
43
  # base SHA it records before doing any work or making any network calls.
46
- read_init_base_sha(repo_root / ASSERTION_DIR_NAME)
44
+ read_init_verify_base_sha(repo_root / ASSERTION_DIR_NAME)
47
45
  return repo_root
48
46
 
49
47
 
@@ -191,16 +189,19 @@ def update_metadata_anchor(
191
189
  metadata_path: Path,
192
190
  metadata: MetadataPayload,
193
191
  base_sha: str,
192
+ clone_sha: str | None = None,
194
193
  head_branch: str | None = None,
195
194
  ) -> MetadataPayload:
196
- """Refresh the per-session git anchor: diff base + branch name.
195
+ """Refresh the per-session git anchor: diff base + clone anchor + branch name.
197
196
 
198
- `base_sha` is the diff base (recorded by `asrt session start`, same value on
199
- every call within a session). `head_branch` is the current local branch — may
200
- legitimately change within a session if the user renames or switches.
197
+ `base_sha` is the verification base (recorded by `asrt session start`, same
198
+ value on every call within a session). `clone_sha` is the remote commit the
199
+ backend checks out, recomputed per checkpoint/verify. `head_branch` is the
200
+ current local branch — may legitimately change within a session.
201
201
  """
202
- updated = metadata.model_copy(
203
- update={"base_sha": base_sha, "head_branch": head_branch}
204
- )
202
+ update: dict[str, object] = {"base_sha": base_sha, "head_branch": head_branch}
203
+ if clone_sha is not None:
204
+ update["clone_sha"] = clone_sha
205
+ updated = metadata.model_copy(update=update)
205
206
  metadata_path.write_text(updated.model_dump_json(indent=2) + "\n", encoding="utf-8")
206
207
  return updated
@@ -15,20 +15,14 @@ Before any `asrt` call:
15
15
 
16
16
  - You are inside a git repository.
17
17
  - `asrt install` has been run once in this repo. It installs the skill files you are reading right now (plus the `AGENTS.md` / `CLAUDE.md` pointer and `.gitignore` rules). This is an onboarding step, not a per-run one.
18
- - `asrt session start` has been run for this work session. It records the merge-base with the upstream tracking branch (falling back to HEAD when no upstream is available) as the diff base that every checkpoint/verify diffs against. If `asrt checkpoint` or `asrt verify` errors with "No diff base recorded", run `asrt session start` and retry.
18
+ - `asrt session start` has been run for this work session. It resets `.assertion/` (except `.assertion/seed.py`) and records the verification base — the merge-base with the upstream tracking branch by default, or an explicit `--from <ref>`. If `asrt checkpoint` or `asrt verify` errors with "No verification base recorded", run `asrt session start` and retry.
19
19
 
20
20
  **First command sequence for a new work session:**
21
21
 
22
- 1. Before any `asrt` call, check whether this repo may have previous Assertion state in `.assertion/`.
23
- 2. If previous Assertion state exists, ask the user: "Do you want to clean up the previous Assertion session and start fresh?"
24
- 3. When the harness supports structured prompts or selectable options, present the question with selectable `Yes` and `No` options. Do not ask the user to type `yes` or `no` unless selectable options are unavailable.
25
- 4. If the user answers yes, run `asrt new` before any other `asrt` command.
26
- 5. If the user answers no, continue with the existing session state.
27
- 6. After the reset decision is resolved, immediately log the active user prompt with `asrt prompt`. Treat the cleanup confirmation itself as a control response, not user intent to record, when it only answers this reset question.
22
+ 1. Run `asrt session start` to start fresh. This clears previous in-flight Assertion state (metadata, prompts log, link, verification status, screenshots) and records the current verification base.
23
+ 2. Immediately log the active user prompt with `asrt prompt`.
28
24
 
29
- This check is the only allowed exception to Rule 1's "first thing" requirement: resolve it before the first `asrt prompt` call, because `asrt new` clears the prompts log. `asrt new` clears the previous session's in-flight state (metadata, prompts log, link, verification status, screenshots) and records the current `HEAD` as the new diff base, so a new session starts clean on the current branch and anchors to the stack you pick on the first checkpoint. Do not run `asrt new` without explicit user consent — it resets the session.
30
-
31
- Treat `.assertion/metadata.json`, `.assertion/prompts`, and `.assertion/base_sha` as internal state owned by the CLI. Do not create, edit, or delete those files manually — use `asrt prompt` to append to the prompts log and `asrt new` to reset for a fresh session.
25
+ Treat `.assertion/metadata.json`, `.assertion/prompts`, and `.assertion/verify_base_sha` as internal state owned by the CLI. Do not create, edit, or delete those files manually use `asrt prompt` to append to the prompts log and `asrt session start` to reset for a fresh session.
32
26
 
33
27
  `asrt stacks` is read-only and lists every verification stack in the user's workspace, along with the repo each is configured for. Run it once per session before your first checkpoint so you can pick a stack ID; do not hardcode IDs from memory.
34
28
 
@@ -42,7 +36,7 @@ Treat `.assertion/metadata.json`, `.assertion/prompts`, and `.assertion/base_sha
42
36
  asrt prompt "<exact text of the user's message>"
43
37
  ```
44
38
 
45
- Exception: on the first user turn of a new work session, perform the Preflight fresh-session check first. If the user approves a reset, run `asrt new` before `asrt prompt`; otherwise skip the reset. Once that decision is resolved, immediately run `asrt prompt` for the active user message so the new/current session records the user's intent. If the only separate user message during preflight is a plain reset confirmation, do not record that confirmation as its own prompt.
39
+ Exception: on the first user turn of a new work session, perform the Preflight fresh-session check first by running `asrt session start`, then immediately run `asrt prompt` for the active user message so the new/current session records the user's intent.
46
40
 
47
41
  This applies to **every user turn**, not just the first. It applies even when the message is a one-word direction change ("nope"), a clarification ("use Postgres, not SQLite"), or a follow-up after verify ("also add dark mode").
48
42
 
@@ -117,9 +111,9 @@ When you pass `--stack`, briefly state which stack you chose and why ("Picked st
117
111
  | CLI error starts with | What it means | What to do |
118
112
  |---|---|---|
119
113
  | `No session exists yet and no --stack given.` | First checkpoint of a fresh `.assertion/`, but you didn't pick a stack. | Run `asrt stacks`, pick one using the signals above, pass `--stack <id>`. |
120
- | `This session is already anchored to a stack.` | You passed `--stack` after a session was already created. The CLI locks the stack at session creation. | Drop the `--stack` flag and re-run `asrt checkpoint "<message>"`, or ask the user to run `asrt new` if this should be a fresh session. |
114
+ | `This session is already anchored to a stack.` | You passed `--stack` after a session was already created. The CLI locks the stack at session creation. | Drop the `--stack` flag and re-run `asrt checkpoint "<message>"`, or run `asrt session start` if this should be a fresh session. |
121
115
  | `ERROR: Unknown stack '<id>'.` | The `--stack` you passed is not in the workspace. | Re-run `asrt stacks` (the list may have changed) and pass a valid id. |
122
- | `No diff base recorded for this repo.` | `asrt session start` wasn't run. | Run `asrt session start` and retry. |
116
+ | `No verification base recorded for this repo.` | `asrt session start` wasn't run. | Run `asrt session start` and retry. |
123
117
  | `ERROR: No active session to continue.` | You passed `--continue` but there is no session. | Drop `--continue` and pass `--stack <id>` to start one. |
124
118
 
125
119
  ---
@@ -205,7 +199,7 @@ A missing marker is a **blocking error**, not a warning: do not consider the PR
205
199
 
206
200
  **Do not open a PR without a clean `asrt verify` / `asrt verify-status`, and do not open one whose body omits the session marker.** Do not end a session with a failed/dirty verify unless the verification service itself is unavailable.
207
201
 
208
- **After the PR marker is confirmed:** ask the user whether they want to clean up the completed Assertion session and start fresh for the next session. If the user says yes, run `asrt new`. If the user says no, leave the existing `.assertion/` state intact.
202
+ **After the PR marker is confirmed:** ask the user whether they want to clean up the completed Assertion session and start fresh for the next session. If the user says yes, run `asrt session start`. If the user says no, leave the existing `.assertion/` state intact.
209
203
 
210
204
  ---
211
205
 
@@ -213,7 +207,7 @@ A missing marker is a **blocking error**, not a warning: do not consider the PR
213
207
 
214
208
  ```
215
209
  asrt prompt "<message>" # every user turn, before anything else
216
- asrt new # reset local run state only after user consent
210
+ asrt session start [--from <ref>] # start fresh; optional explicit verification base
217
211
  asrt stacks # list every stack — read once before first checkpoint
218
212
  asrt checkpoint --stack <id> "..." # first checkpoint of a session
219
213
  asrt checkpoint "..." # subsequent checkpoints — auto-continues
@@ -237,6 +231,6 @@ When running under a non-interactive harness (CI, benchmark runner, scripts):
237
231
  - Re-run `asrt checkpoint "<what you changed>" --json` (auto-continues the existing session).
238
232
  - Bound retries: stop after 3 attempts on the same failure mode, or when the same `reasoning` is returned twice in a row.
239
233
  4. `asrt verify --json` submits and exits with a one-line JSON `{"session_id", "url", "status": "submitted"}`. Poll `asrt verify-status --json` on your own cadence — terminal output is `{"session_id", "url", "status", "message", "summary", "screenshot_count"}` with exit `1` on `failed`, `0` otherwise. Choose the poll interval to fit your harness timeouts; do not block a single shell call on verify.
240
- 5. If the harness opens a PR, embed the hidden session marker (`asrt get-link`) in the PR body — same requirement as interactive mode. The bot matches the PR to the session by branch first, then by this marker.
234
+ 5. If the harness opens a PR, embed the hidden session marker (`asrt get-link`) in the PR body — same requirement as interactive mode. The bot matches the PR to the session by branch name first, then by this marker.
241
235
 
242
236
  Do not mix `--json` output with human-rendered output in the same run. Pick one mode and stick with it.
@@ -10,12 +10,15 @@ from models import MetadataPayload
10
10
 
11
11
  def test_build_bundle_writes_session_metadata() -> None:
12
12
  metadata = MetadataPayload(
13
- session_id="session-1", stack_id="stack-1", base_sha="abc123"
13
+ session_id="session-1",
14
+ stack_id="stack-1",
15
+ base_sha="base123",
16
+ clone_sha="clone456",
14
17
  )
15
18
  bundle_bytes = build_bundle(
16
19
  metadata=metadata,
17
20
  diff_text="diff --git a/file b/file",
18
- uncommitted_diff_text="diff --git a/file b/file",
21
+ review_diff_text="review diff",
19
22
  prompts_text='{"ts": "2026-05-28T14:32:01Z", "text": "Do the thing"}\n',
20
23
  checkpoint_text="Checkpoint text",
21
24
  )
@@ -24,11 +27,9 @@ def test_build_bundle_writes_session_metadata() -> None:
24
27
  meta_json = json.loads(zf.read(".assertion/metadata.json").decode("utf-8"))
25
28
  assert meta_json["session_id"] == "session-1"
26
29
  assert meta_json["stack_id"] == "stack-1"
27
- # Wire-format remap: local `base_sha` ships as `head_sha`. The backend
28
- # has not been migrated, so the bundle key must remain `head_sha` and
29
- # `base_sha` must not leak through.
30
- assert meta_json["head_sha"] == "abc123"
31
- assert "base_sha" not in meta_json
30
+ # clone_sha is the remote checkout anchor, so it ships as head_sha.
31
+ assert meta_json["head_sha"] == "clone456"
32
+ assert meta_json["base_sha"] == "base123"
32
33
 
33
34
  prompts_raw = zf.read(".assertion/prompts").decode("utf-8")
34
35
  assert '"text": "Do the thing"' in prompts_raw
@@ -37,5 +38,5 @@ def test_build_bundle_writes_session_metadata() -> None:
37
38
  git_diff = zf.read("git.diff").decode("utf-8")
38
39
  assert "diff --git a/file b/file" in git_diff
39
40
 
40
- uncommitted_diff = zf.read("uncommitted.diff").decode("utf-8")
41
- assert "diff --git a/file b/file" in uncommitted_diff
41
+ review_diff = zf.read("review.diff").decode("utf-8")
42
+ assert "review diff" in review_diff
@@ -2,15 +2,17 @@ import subprocess
2
2
  from pathlib import Path
3
3
 
4
4
  import pytest
5
- from click.exceptions import Exit as ClickExit
5
+ import typer
6
6
 
7
7
  from git import (
8
8
  find_git_root,
9
9
  get_full_diff,
10
10
  get_head_sha,
11
11
  get_merge_base,
12
+ get_remote_ancestor,
12
13
  get_uncommitted_diff,
13
14
  get_upstream_ref,
15
+ is_ancestor,
14
16
  run_git_command,
15
17
  )
16
18
 
@@ -41,7 +43,7 @@ def test_find_git_root_in_repo(tmp_path: Path) -> None:
41
43
 
42
44
 
43
45
  def test_find_git_root_outside_repo(tmp_path: Path) -> None:
44
- with pytest.raises(ClickExit):
46
+ with pytest.raises(typer.Exit):
45
47
  find_git_root(tmp_path)
46
48
 
47
49
 
@@ -656,3 +658,134 @@ def test_get_uncommitted_diff_preserves_trailing_blank_context_lines(
656
658
  check=False,
657
659
  )
658
660
  assert apply_check.returncode == 0, apply_check.stderr
661
+
662
+
663
+ # --- is_ancestor ---
664
+
665
+
666
+ def test_is_ancestor_true_for_parent(tmp_path: Path) -> None:
667
+ subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True)
668
+ subprocess.run(
669
+ ["git", "commit", "--allow-empty", "-m", "first"],
670
+ cwd=tmp_path,
671
+ capture_output=True,
672
+ check=True,
673
+ env={**GIT_ENV, "HOME": str(tmp_path)},
674
+ )
675
+ parent = run_git_command(tmp_path, ["rev-parse", "HEAD"])
676
+ subprocess.run(
677
+ ["git", "commit", "--allow-empty", "-m", "second"],
678
+ cwd=tmp_path,
679
+ capture_output=True,
680
+ check=True,
681
+ env={**GIT_ENV, "HOME": str(tmp_path)},
682
+ )
683
+ head = run_git_command(tmp_path, ["rev-parse", "HEAD"])
684
+
685
+ assert is_ancestor(tmp_path, parent, head) is True
686
+
687
+
688
+ def test_is_ancestor_false_for_sibling(tmp_path: Path) -> None:
689
+ subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True)
690
+ subprocess.run(
691
+ ["git", "commit", "--allow-empty", "-m", "first"],
692
+ cwd=tmp_path,
693
+ capture_output=True,
694
+ check=True,
695
+ env={**GIT_ENV, "HOME": str(tmp_path)},
696
+ )
697
+ parent = run_git_command(tmp_path, ["rev-parse", "HEAD"])
698
+ subprocess.run(
699
+ ["git", "commit", "--allow-empty", "-m", "second"],
700
+ cwd=tmp_path,
701
+ capture_output=True,
702
+ check=True,
703
+ env={**GIT_ENV, "HOME": str(tmp_path)},
704
+ )
705
+ sibling = subprocess.run(
706
+ ["git", "commit-tree", "HEAD^{tree}", "-p", parent, "-m", "sibling"],
707
+ cwd=tmp_path,
708
+ capture_output=True,
709
+ text=True,
710
+ check=True,
711
+ env={**GIT_ENV, "HOME": str(tmp_path)},
712
+ ).stdout.strip()
713
+
714
+ assert is_ancestor(tmp_path, sibling, "HEAD") is False
715
+
716
+
717
+ # --- get_remote_ancestor ---
718
+
719
+
720
+ def test_get_remote_ancestor_returns_sha_when_on_remote(tmp_path: Path) -> None:
721
+ subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True)
722
+ subprocess.run(
723
+ ["git", "commit", "--allow-empty", "-m", "first"],
724
+ cwd=tmp_path,
725
+ capture_output=True,
726
+ check=True,
727
+ env={**GIT_ENV, "HOME": str(tmp_path)},
728
+ )
729
+ sha = run_git_command(tmp_path, ["rev-parse", "HEAD"])
730
+ subprocess.run(
731
+ ["git", "remote", "add", "origin", "http://example.com/repo.git"],
732
+ cwd=tmp_path,
733
+ capture_output=True,
734
+ check=True,
735
+ )
736
+ subprocess.run(
737
+ ["git", "update-ref", "refs/remotes/origin/main", sha],
738
+ cwd=tmp_path,
739
+ capture_output=True,
740
+ check=True,
741
+ )
742
+
743
+ assert get_remote_ancestor(tmp_path, sha) == sha
744
+
745
+
746
+ def test_get_remote_ancestor_returns_nearest_remote_ancestor(tmp_path: Path) -> None:
747
+ subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True)
748
+ subprocess.run(
749
+ ["git", "commit", "--allow-empty", "-m", "remote"],
750
+ cwd=tmp_path,
751
+ capture_output=True,
752
+ check=True,
753
+ env={**GIT_ENV, "HOME": str(tmp_path)},
754
+ )
755
+ remote_sha = run_git_command(tmp_path, ["rev-parse", "HEAD"])
756
+ subprocess.run(
757
+ ["git", "commit", "--allow-empty", "-m", "local"],
758
+ cwd=tmp_path,
759
+ capture_output=True,
760
+ check=True,
761
+ env={**GIT_ENV, "HOME": str(tmp_path)},
762
+ )
763
+ local_sha = run_git_command(tmp_path, ["rev-parse", "HEAD"])
764
+ subprocess.run(
765
+ ["git", "remote", "add", "origin", "http://example.com/repo.git"],
766
+ cwd=tmp_path,
767
+ capture_output=True,
768
+ check=True,
769
+ )
770
+ subprocess.run(
771
+ ["git", "update-ref", "refs/remotes/origin/main", remote_sha],
772
+ cwd=tmp_path,
773
+ capture_output=True,
774
+ check=True,
775
+ )
776
+
777
+ assert get_remote_ancestor(tmp_path, local_sha) == remote_sha
778
+
779
+
780
+ def test_get_remote_ancestor_returns_none_without_remote(tmp_path: Path) -> None:
781
+ subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True)
782
+ subprocess.run(
783
+ ["git", "commit", "--allow-empty", "-m", "first"],
784
+ cwd=tmp_path,
785
+ capture_output=True,
786
+ check=True,
787
+ env={**GIT_ENV, "HOME": str(tmp_path)},
788
+ )
789
+ sha = run_git_command(tmp_path, ["rev-parse", "HEAD"])
790
+
791
+ assert get_remote_ancestor(tmp_path, sha) is None
@@ -131,11 +131,8 @@ def test_install_records_skill_cleanup_guidance(tmp_path: Path, monkeypatch) ->
131
131
 
132
132
  skill = (tmp_path / ".agents" / "skills" / "assertion-cli" / "SKILL.md").read_text()
133
133
  assert "First command sequence for a new work session" in skill
134
- assert (
135
- 'ask the user: "Do you want to clean up the previous Assertion session '
136
- 'and start fresh?"'
137
- ) in skill
138
- assert "Do not run `asrt new` without explicit user consent" in skill
134
+ assert "`asrt session start`" in skill
135
+ assert "`asrt new`" not in skill
139
136
 
140
137
 
141
138
  def test_install_requires_git_repo(tmp_path: Path, monkeypatch) -> None:
@@ -26,9 +26,10 @@ def test_update_metadata_anchor_preserves_session_id(tmp_path: Path) -> None:
26
26
  path = tmp_path / "metadata.json"
27
27
  original = MetadataPayload(session_id="session-1", stack_id="stack-1")
28
28
  path.write_text(original.model_dump_json())
29
- updated = update_metadata_anchor(path, original, "abc123")
29
+ updated = update_metadata_anchor(path, original, "abc123", clone_sha="def456")
30
30
  assert updated.session_id == "session-1"
31
31
  assert updated.base_sha == "abc123"
32
+ assert updated.clone_sha == "def456"
32
33
 
33
34
 
34
35
  def test_update_metadata_anchor_preserves_extra_fields(tmp_path: Path) -> None:
@@ -45,14 +46,17 @@ def test_update_metadata_anchor_preserves_extra_fields(tmp_path: Path) -> None:
45
46
  original = _load_metadata(path)
46
47
  assert original is not None
47
48
 
48
- updated = update_metadata_anchor(path, original, "abc123")
49
+ updated = update_metadata_anchor(path, original, "abc123", clone_sha="def456")
49
50
  saved = json.loads(path.read_text())
50
51
 
51
- # Local on-disk schema uses `base_sha`. The wire-format remap to
52
- # `head_sha` happens only at bundle-build time see test_bundle.py.
52
+ # Local on-disk schema uses `base_sha` for the verification base and
53
+ # `clone_sha` for the remote checkout anchor. The wire-format remap of
54
+ # `clone_sha` to `head_sha` happens only at bundle-build time.
53
55
  assert updated.base_sha == "abc123"
56
+ assert updated.clone_sha == "def456"
54
57
  assert saved["prompt"] == "Keep this value"
55
58
  assert saved["base_sha"] == "abc123"
59
+ assert saved["clone_sha"] == "def456"
56
60
  assert "head_sha" not in saved
57
61
 
58
62
 
@@ -155,7 +159,7 @@ def test_checkpoint_rejects_stack_flag_when_session_exists(
155
159
  session_id="existing-session", stack_id="existing-stack"
156
160
  ).model_dump_json()
157
161
  )
158
- (assertion_dir / "base_sha").write_text("a" * 40 + "\n")
162
+ (assertion_dir / "verify_base_sha").write_text("a" * 40 + "\n")
159
163
  (assertion_dir / "prompts").write_text(
160
164
  '{"ts": "2026-05-30T00:00:00Z", "text": "do the thing"}\n'
161
165
  )
@@ -66,7 +66,7 @@ def test_session_start_does_not_write_skill_or_agent_files(
66
66
  assert not (tmp_path / "AGENTS.md").exists()
67
67
 
68
68
 
69
- def test_session_start_ignores_assertion_state_but_keeps_seed_trackable(
69
+ def test_session_start_resets_assertion_state_but_keeps_seed_trackable(
70
70
  tmp_path: Path, monkeypatch
71
71
  ) -> None:
72
72
  _init_repo(tmp_path)
@@ -75,7 +75,8 @@ def test_session_start_ignores_assertion_state_but_keeps_seed_trackable(
75
75
  existing_gitignore = "*.pyc\n"
76
76
  (tmp_path / ".gitignore").write_text(existing_gitignore)
77
77
  (tmp_path / ".assertion").mkdir()
78
- (tmp_path / ".assertion" / "base_sha").write_text("local state\n")
78
+ (tmp_path / ".assertion" / "verify_base_sha").write_text("local state\n")
79
+ (tmp_path / ".assertion" / "old_file").write_text("old\n")
79
80
  (tmp_path / ".assertion" / "seed.py").write_text(
80
81
  "from assertion_seed import seed\n"
81
82
  )
@@ -87,8 +88,13 @@ def test_session_start_ignores_assertion_state_but_keeps_seed_trackable(
87
88
  "*.pyc\n\n# Assertion CLI local state\n.assertion/*\n!.assertion/seed.py\n"
88
89
  )
89
90
 
91
+ # Old state is cleared; seed.py is preserved.
92
+ assert not (tmp_path / ".assertion" / "old_file").exists()
93
+ assert (tmp_path / ".assertion" / "seed.py").exists()
94
+ assert (tmp_path / ".assertion" / "verify_base_sha").exists()
95
+
90
96
  ignored_state = subprocess.run(
91
- ["git", "check-ignore", ".assertion/base_sha"],
97
+ ["git", "check-ignore", ".assertion/verify_base_sha"],
92
98
  cwd=tmp_path,
93
99
  capture_output=True,
94
100
  )
@@ -214,14 +220,13 @@ def test_session_start_falls_back_to_head_when_no_upstream(
214
220
 
215
221
  assert result.exit_code == 0
216
222
  assert "falling back to HEAD" in result.stderr
217
- assert (tmp_path / ".assertion" / "base_sha").read_text().strip() == head_sha
223
+ assert (tmp_path / ".assertion" / "verify_base_sha").read_text().strip() == head_sha
218
224
 
219
225
 
220
- def test_session_start_records_merge_base_as_base(tmp_path: Path, monkeypatch) -> None:
221
- """`asrt session start` pins the diff base to the branch merge-base.
226
+ def test_session_start_records_merge_base_as_verify_base(tmp_path: Path, monkeypatch) -> None:
227
+ """`asrt session start` pins the verification base to the branch merge-base.
222
228
 
223
- Every subsequent checkpoint/verify diffs against this SHA, so backend can
224
- check out the upstream-visible base before applying the full diff.
229
+ Every subsequent checkpoint/verify diffs against this SHA.
225
230
  """
226
231
  _init_repo(tmp_path)
227
232
  monkeypatch.chdir(tmp_path)
@@ -237,7 +242,7 @@ def test_session_start_records_merge_base_as_base(tmp_path: Path, monkeypatch) -
237
242
  merge_base = _run_git(tmp_path, "merge-base", "HEAD", upstream)
238
243
  head_sha = _run_git(tmp_path, "rev-parse", "HEAD")
239
244
 
240
- base_sha_path = tmp_path / ".assertion" / "base_sha"
245
+ base_sha_path = tmp_path / ".assertion" / "verify_base_sha"
241
246
  assert base_sha_path.exists()
242
247
  assert base_sha_path.read_text().strip() == merge_base
243
248
  assert merge_base != head_sha
@@ -252,4 +257,33 @@ def test_session_start_errors_when_repo_has_no_commits(
252
257
 
253
258
  result = runner.invoke(main.app, ["session", "start"])
254
259
  assert result.exit_code == 1
255
- assert not (tmp_path / ".assertion" / "base_sha").exists()
260
+ assert not (tmp_path / ".assertion" / "verify_base_sha").exists()
261
+
262
+
263
+ def test_session_start_from_explicit_ref(tmp_path: Path, monkeypatch) -> None:
264
+ """`--from` lets the user choose the verification base explicitly."""
265
+ _init_repo(tmp_path)
266
+ monkeypatch.chdir(tmp_path)
267
+ verify_base = _run_git(tmp_path, "rev-parse", "HEAD")
268
+ _run_git(tmp_path, "commit", "--allow-empty", "-m", "local work")
269
+
270
+ result = runner.invoke(main.app, ["session", "start", "--from", verify_base])
271
+ assert result.exit_code == 0, result.stderr
272
+
273
+ assert (
274
+ tmp_path / ".assertion" / "verify_base_sha"
275
+ ).read_text().strip() == verify_base
276
+
277
+
278
+ def test_session_start_from_ref_rejects_non_ancestor(
279
+ tmp_path: Path, monkeypatch
280
+ ) -> None:
281
+ """`--from` must point to an ancestor of HEAD."""
282
+ _init_repo(tmp_path)
283
+ monkeypatch.chdir(tmp_path)
284
+ _run_git(tmp_path, "commit", "--allow-empty", "-m", "local work")
285
+ orphan = _run_git(tmp_path, "commit-tree", "HEAD^{tree}", "-m", "orphan")
286
+
287
+ result = runner.invoke(main.app, ["session", "start", "--from", orphan])
288
+ assert result.exit_code == 1
289
+ assert "not an ancestor of HEAD" in result.stderr
@@ -1,142 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import subprocess
4
- from pathlib import Path
5
-
6
- from typer.testing import CliRunner
7
-
8
- import main
9
-
10
-
11
- runner = CliRunner()
12
-
13
- _GIT_COMMIT_ENV = {
14
- "GIT_AUTHOR_NAME": "T",
15
- "GIT_AUTHOR_EMAIL": "t@t",
16
- "GIT_COMMITTER_NAME": "T",
17
- "GIT_COMMITTER_EMAIL": "t@t",
18
- "PATH": "/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin",
19
- }
20
-
21
-
22
- def _run_git(repo: Path, *args: str) -> str:
23
- result = subprocess.run(
24
- ["git", *args],
25
- cwd=repo,
26
- capture_output=True,
27
- check=True,
28
- env={**_GIT_COMMIT_ENV, "HOME": str(repo)},
29
- text=True,
30
- )
31
- return result.stdout.strip()
32
-
33
-
34
- def _init_repo(tmp_path: Path) -> str:
35
- _run_git(tmp_path, "init")
36
- _run_git(tmp_path, "commit", "--allow-empty", "-m", "init")
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}")
42
-
43
-
44
- def test_new_clears_run_state_and_records_merge_base(
45
- tmp_path: Path,
46
- monkeypatch,
47
- ) -> None:
48
- merge_base = _init_repo(tmp_path)
49
- monkeypatch.chdir(tmp_path)
50
-
51
- assertion_dir = tmp_path / ".assertion"
52
- (assertion_dir / "screenshots" / "verification-1").mkdir(parents=True)
53
- (assertion_dir / "metadata.json").write_text("{}")
54
- (assertion_dir / "prompts").write_text("old prompt")
55
- (assertion_dir / "checkpoint").write_text("old checkpoint")
56
- (assertion_dir / "link").write_text("old link")
57
- (assertion_dir / "verify_session_id").write_text("old verification")
58
- (assertion_dir / "base_sha").write_text("old-base")
59
- (
60
- assertion_dir / "screenshots" / "verification-1" / "screenshot_000.png"
61
- ).write_bytes(b"png")
62
-
63
- result = runner.invoke(main.app, ["new"])
64
-
65
- assert result.exit_code == 0, result.stderr
66
- assert (assertion_dir / "base_sha").read_text() == merge_base + "\n"
67
- assert not (assertion_dir / "metadata.json").exists()
68
- assert not (assertion_dir / "prompts").exists()
69
- assert not (assertion_dir / "checkpoint").exists()
70
- assert not (assertion_dir / "link").exists()
71
- assert not (assertion_dir / "verify_session_id").exists()
72
- assert not (assertion_dir / "screenshots").exists()
73
- assert "Recorded diff base" in result.stdout
74
-
75
-
76
- def test_new_succeeds_when_assertion_dir_missing(
77
- tmp_path: Path,
78
- monkeypatch,
79
- ) -> None:
80
- merge_base = _init_repo(tmp_path)
81
- monkeypatch.chdir(tmp_path)
82
-
83
- result = runner.invoke(main.app, ["new"])
84
-
85
- assert result.exit_code == 0, result.stderr
86
- assert (tmp_path / ".assertion" / "base_sha").read_text() == merge_base + "\n"
87
-
88
-
89
- def test_new_keeps_merge_base_after_local_head_changes(
90
- tmp_path: Path,
91
- monkeypatch,
92
- ) -> None:
93
- merge_base = _init_repo(tmp_path)
94
- monkeypatch.chdir(tmp_path)
95
-
96
- first_result = runner.invoke(main.app, ["new"])
97
- assert first_result.exit_code == 0, first_result.stderr
98
- assert (tmp_path / ".assertion" / "base_sha").read_text() == merge_base + "\n"
99
-
100
- _run_git(tmp_path, "commit", "--allow-empty", "-m", "second")
101
- second_head = _run_git(tmp_path, "rev-parse", "HEAD")
102
-
103
- second_result = runner.invoke(main.app, ["new"])
104
-
105
- assert second_result.exit_code == 0, second_result.stderr
106
- assert second_head != merge_base
107
- assert (tmp_path / ".assertion" / "base_sha").read_text() == merge_base + "\n"
108
-
109
-
110
- def test_new_leaves_next_checkpoint_requiring_stack(
111
- tmp_path: Path,
112
- monkeypatch,
113
- ) -> None:
114
- _init_repo(tmp_path)
115
- monkeypatch.chdir(tmp_path)
116
-
117
- assertion_dir = tmp_path / ".assertion"
118
- assertion_dir.mkdir()
119
- (assertion_dir / "metadata.json").write_text("{}")
120
- (assertion_dir / "prompts").write_text("old prompt")
121
-
122
- result = runner.invoke(main.app, ["new"])
123
-
124
- assert result.exit_code == 0, result.stderr
125
- assert not (assertion_dir / "metadata.json").exists()
126
-
127
-
128
- def test_new_falls_back_to_head_when_no_upstream(
129
- tmp_path: Path,
130
- monkeypatch,
131
- ) -> None:
132
- """When no upstream tracking branch exists, fall back to HEAD instead of erroring."""
133
- _run_git(tmp_path, "init")
134
- _run_git(tmp_path, "commit", "--allow-empty", "-m", "init")
135
- head_sha = _run_git(tmp_path, "rev-parse", "HEAD")
136
- monkeypatch.chdir(tmp_path)
137
-
138
- result = runner.invoke(main.app, ["new"])
139
-
140
- assert result.exit_code == 0
141
- assert "falling back to HEAD" in result.stderr
142
- assert (tmp_path / ".assertion" / "base_sha").read_text().strip() == head_sha
File without changes
File without changes
File without changes
File without changes