assertion-cli 0.5.9__tar.gz → 0.5.11__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.9 → assertion_cli-0.5.11}/PKG-INFO +1 -1
  2. {assertion_cli-0.5.9 → assertion_cli-0.5.11}/assertion_cli.egg-info/PKG-INFO +1 -1
  3. {assertion_cli-0.5.9 → assertion_cli-0.5.11}/git.py +52 -4
  4. {assertion_cli-0.5.9 → assertion_cli-0.5.11}/main.py +23 -11
  5. {assertion_cli-0.5.9 → assertion_cli-0.5.11}/pyproject.toml +1 -1
  6. {assertion_cli-0.5.9 → assertion_cli-0.5.11}/templates/SKILL.md +1 -1
  7. {assertion_cli-0.5.9 → assertion_cli-0.5.11}/tests/test_git.py +181 -1
  8. {assertion_cli-0.5.9 → assertion_cli-0.5.11}/tests/test_new.py +6 -3
  9. {assertion_cli-0.5.9 → assertion_cli-0.5.11}/tests/test_session_start.py +11 -7
  10. {assertion_cli-0.5.9 → assertion_cli-0.5.11}/README.md +0 -0
  11. {assertion_cli-0.5.9 → assertion_cli-0.5.11}/api.py +0 -0
  12. {assertion_cli-0.5.9 → assertion_cli-0.5.11}/assertion_cli.egg-info/SOURCES.txt +0 -0
  13. {assertion_cli-0.5.9 → assertion_cli-0.5.11}/assertion_cli.egg-info/dependency_links.txt +0 -0
  14. {assertion_cli-0.5.9 → assertion_cli-0.5.11}/assertion_cli.egg-info/entry_points.txt +0 -0
  15. {assertion_cli-0.5.9 → assertion_cli-0.5.11}/assertion_cli.egg-info/requires.txt +0 -0
  16. {assertion_cli-0.5.9 → assertion_cli-0.5.11}/assertion_cli.egg-info/top_level.txt +0 -0
  17. {assertion_cli-0.5.9 → assertion_cli-0.5.11}/bundle.py +0 -0
  18. {assertion_cli-0.5.9 → assertion_cli-0.5.11}/link.py +0 -0
  19. {assertion_cli-0.5.9 → assertion_cli-0.5.11}/models.py +0 -0
  20. {assertion_cli-0.5.9 → assertion_cli-0.5.11}/session.py +0 -0
  21. {assertion_cli-0.5.9 → assertion_cli-0.5.11}/setup.cfg +0 -0
  22. {assertion_cli-0.5.9 → assertion_cli-0.5.11}/templates/AGENTS.md +0 -0
  23. {assertion_cli-0.5.9 → assertion_cli-0.5.11}/templates/__init__.py +0 -0
  24. {assertion_cli-0.5.9 → assertion_cli-0.5.11}/tests/test_api.py +0 -0
  25. {assertion_cli-0.5.9 → assertion_cli-0.5.11}/tests/test_bundle.py +0 -0
  26. {assertion_cli-0.5.9 → assertion_cli-0.5.11}/tests/test_decision.py +0 -0
  27. {assertion_cli-0.5.9 → assertion_cli-0.5.11}/tests/test_install.py +0 -0
  28. {assertion_cli-0.5.9 → assertion_cli-0.5.11}/tests/test_link.py +0 -0
  29. {assertion_cli-0.5.9 → assertion_cli-0.5.11}/tests/test_main.py +0 -0
  30. {assertion_cli-0.5.9 → assertion_cli-0.5.11}/tests/test_prompt.py +0 -0
  31. {assertion_cli-0.5.9 → assertion_cli-0.5.11}/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.9
3
+ Version: 0.5.11
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.9
3
+ Version: 0.5.11
4
4
  Summary: CLI for the Assertion API
5
5
  Requires-Python: >=3.13
6
6
  Description-Content-Type: text/markdown
@@ -49,11 +49,14 @@ def get_head_sha(repo_root: Path) -> str:
49
49
  def get_upstream_ref(repo_root: Path) -> str | None:
50
50
  """Return the upstream tracking ref (e.g. ``origin/main``).
51
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).
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.
56
57
  """
58
+
59
+ # 1. Configured upstream (git branch -u).
57
60
  try:
58
61
  return run_git_command(
59
62
  repo_root, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"]
@@ -61,6 +64,7 @@ def get_upstream_ref(repo_root: Path) -> str | None:
61
64
  except RuntimeError:
62
65
  pass
63
66
 
67
+ # 2. Same branch name on remote (has been pushed at least once).
64
68
  head_branch = get_head_branch(repo_root)
65
69
  if head_branch is not None:
66
70
  fallback = f"origin/{head_branch}"
@@ -71,6 +75,50 @@ def get_upstream_ref(repo_root: Path) -> str | None:
71
75
  else:
72
76
  return fallback
73
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
+
74
122
  return None
75
123
 
76
124
 
@@ -16,9 +16,11 @@ from git import (
16
16
  find_git_root,
17
17
  get_full_diff,
18
18
  get_head_branch,
19
+ get_head_sha,
19
20
  get_merge_base,
20
21
  get_uncommitted_diff,
21
22
  get_upstream_ref,
23
+ run_git_command,
22
24
  )
23
25
  from link import load_link, save_link
24
26
  from models import CheckpointResponse, SessionStatus, render_stack_list
@@ -265,18 +267,27 @@ app.add_typer(session_app, name="session")
265
267
  def _resolve_diff_base(repo_root: Path) -> str:
266
268
  """Compute the merge-base with the upstream tracking branch.
267
269
 
268
- Errors out when no upstream can be resolved (detached HEAD, no tracking
269
- branch, branch not pushed to remote).
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
278
  """
279
+ try:
280
+ run_git_command(repo_root, ["fetch", "--no-tags", "origin"])
281
+ except RuntimeError:
282
+ pass # non-fatal: offline or unavailable; proceed with stale refs
283
+
271
284
  upstream = get_upstream_ref(repo_root)
272
285
  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.",
286
+ typer.echo(
287
+ "Warning: no upstream tracking branch found, falling back to HEAD.",
288
+ err=True,
279
289
  )
290
+ return get_head_sha(repo_root)
280
291
  return get_merge_base(repo_root, upstream)
281
292
 
282
293
 
@@ -285,9 +296,10 @@ def session_start() -> None:
285
296
  """Record the merge-base with the upstream tracking branch as the diff base.
286
297
 
287
298
  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.
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.
291
303
 
292
304
  `session start` does not write skill files — that is `asrt install`, the
293
305
  one-time onboarding step. It also never touches user content in `CLAUDE.md` /
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "assertion-cli"
7
- version = "0.5.9"
7
+ version = "0.5.11"
8
8
  description = "CLI for the Assertion API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13"
@@ -15,7 +15,7 @@ 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 current HEAD 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 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.
19
19
 
20
20
  **First command sequence for a new work session:**
21
21
 
@@ -7,7 +7,6 @@ from click.exceptions import Exit as ClickExit
7
7
  from git import (
8
8
  find_git_root,
9
9
  get_full_diff,
10
- get_head_branch,
11
10
  get_head_sha,
12
11
  get_merge_base,
13
12
  get_uncommitted_diff,
@@ -180,6 +179,187 @@ def test_get_upstream_ref_detached_head(tmp_path: Path) -> None:
180
179
  assert ref is None
181
180
 
182
181
 
182
+ def test_get_upstream_ref_fallback_to_origin_head(tmp_path: Path) -> None:
183
+ """HEAD on an orphan commit (no shared history with remote refs)
184
+ returns origin/HEAD."""
185
+ subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True)
186
+ _make_commit(tmp_path)
187
+ subprocess.run(
188
+ ["git", "remote", "add", "origin", "http://example.com/repo.git"],
189
+ cwd=tmp_path, capture_output=True, check=True,
190
+ )
191
+ subprocess.run(
192
+ ["git", "symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/somebranch"],
193
+ cwd=tmp_path, capture_output=True, check=True,
194
+ )
195
+ subprocess.run(
196
+ ["git", "update-ref", "refs/remotes/origin/somebranch", "HEAD"],
197
+ cwd=tmp_path, capture_output=True, check=True,
198
+ )
199
+ # Create an orphan commit so HEAD has no shared history with the
200
+ # remote refs — this makes step 3 (--merged HEAD) produce nothing.
201
+ subprocess.run(
202
+ ["git", "checkout", "--orphan", "orphan-branch"],
203
+ cwd=tmp_path, capture_output=True, check=True,
204
+ )
205
+ (tmp_path / "orphan.txt").write_text("orphan\n")
206
+ subprocess.run(
207
+ ["git", "add", "orphan.txt"], cwd=tmp_path, capture_output=True, check=True
208
+ )
209
+ subprocess.run(
210
+ ["git", "commit", "-m", "orphan"],
211
+ cwd=tmp_path, capture_output=True, check=True,
212
+ env={**GIT_ENV, "HOME": str(tmp_path)},
213
+ )
214
+ ref = get_upstream_ref(tmp_path)
215
+ assert ref == "origin/HEAD"
216
+
217
+
218
+ def test_get_upstream_ref_fallback_to_origin_main(tmp_path: Path) -> None:
219
+ """HEAD on an orphan commit, no origin/HEAD, but origin/main exists."""
220
+ subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True)
221
+ _make_commit(tmp_path)
222
+ subprocess.run(
223
+ ["git", "remote", "add", "origin", "http://example.com/repo.git"],
224
+ cwd=tmp_path, capture_output=True, check=True,
225
+ )
226
+ subprocess.run(
227
+ ["git", "update-ref", "refs/remotes/origin/main", "HEAD"],
228
+ cwd=tmp_path, capture_output=True, check=True,
229
+ )
230
+ subprocess.run(
231
+ ["git", "checkout", "--orphan", "orphan-branch"],
232
+ cwd=tmp_path, capture_output=True, check=True,
233
+ )
234
+ (tmp_path / "orphan.txt").write_text("orphan\n")
235
+ subprocess.run(
236
+ ["git", "add", "orphan.txt"], cwd=tmp_path, capture_output=True, check=True
237
+ )
238
+ subprocess.run(
239
+ ["git", "commit", "-m", "orphan"],
240
+ cwd=tmp_path, capture_output=True, check=True,
241
+ env={**GIT_ENV, "HOME": str(tmp_path)},
242
+ )
243
+ ref = get_upstream_ref(tmp_path)
244
+ assert ref == "origin/main"
245
+
246
+
247
+ def test_get_upstream_ref_fallback_to_origin_master(tmp_path: Path) -> None:
248
+ """HEAD on an orphan commit, no origin/HEAD/main, origin/master exists."""
249
+ subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True)
250
+ _make_commit(tmp_path)
251
+ subprocess.run(
252
+ ["git", "remote", "add", "origin", "http://example.com/repo.git"],
253
+ cwd=tmp_path, capture_output=True, check=True,
254
+ )
255
+ subprocess.run(
256
+ ["git", "update-ref", "refs/remotes/origin/master", "HEAD"],
257
+ cwd=tmp_path, capture_output=True, check=True,
258
+ )
259
+ subprocess.run(
260
+ ["git", "checkout", "--orphan", "orphan-branch"],
261
+ cwd=tmp_path, capture_output=True, check=True,
262
+ )
263
+ (tmp_path / "orphan.txt").write_text("orphan\n")
264
+ subprocess.run(
265
+ ["git", "add", "orphan.txt"], cwd=tmp_path, capture_output=True, check=True
266
+ )
267
+ subprocess.run(
268
+ ["git", "commit", "-m", "orphan"],
269
+ cwd=tmp_path, capture_output=True, check=True,
270
+ env={**GIT_ENV, "HOME": str(tmp_path)},
271
+ )
272
+ ref = get_upstream_ref(tmp_path)
273
+ assert ref == "origin/master"
274
+
275
+
276
+ def test_get_upstream_ref_fallback_to_merged_ancestors(tmp_path: Path) -> None:
277
+ """Branch based on a pushed feature branch finds the closest remote
278
+ ancestor via --merged HEAD + merge-base."""
279
+ _init_repo_with_commit(tmp_path)
280
+ base_sha = run_git_command(tmp_path, ["rev-parse", "HEAD"])
281
+ subprocess.run(
282
+ ["git", "remote", "add", "origin", "http://example.com/repo.git"],
283
+ cwd=tmp_path, capture_output=True, check=True,
284
+ )
285
+
286
+ # Simulate origin/main at the initial commit.
287
+ subprocess.run(
288
+ ["git", "update-ref", "refs/remotes/origin/main", base_sha],
289
+ cwd=tmp_path, capture_output=True, check=True,
290
+ )
291
+
292
+ # Simulate a pushed feature branch (origin/my-feature-base) with a
293
+ # commit on top of main.
294
+ (tmp_path / "base.txt").write_text("base\n")
295
+ subprocess.run(
296
+ ["git", "add", "base.txt"], cwd=tmp_path, capture_output=True, check=True
297
+ )
298
+ subprocess.run(
299
+ ["git", "commit", "-m", "base work"],
300
+ cwd=tmp_path, capture_output=True, check=True,
301
+ env={**GIT_ENV, "HOME": str(tmp_path)},
302
+ )
303
+ feature_base_sha = run_git_command(tmp_path, ["rev-parse", "HEAD"])
304
+ subprocess.run(
305
+ ["git", "update-ref", "refs/remotes/origin/my-feature-base", feature_base_sha],
306
+ cwd=tmp_path, capture_output=True, check=True,
307
+ )
308
+
309
+ # Create a new local branch on top of my-feature-base (simulates a
310
+ # branch-off that hasn't been pushed). Use a branch name that doesn't
311
+ # collide with any remote ref so step 2 is skipped.
312
+ subprocess.run(
313
+ ["git", "checkout", "-b", "my-feature"],
314
+ cwd=tmp_path, capture_output=True, check=True,
315
+ )
316
+ (tmp_path / "new.txt").write_text("new\n")
317
+ subprocess.run(
318
+ ["git", "add", "new.txt"], cwd=tmp_path, capture_output=True, check=True
319
+ )
320
+ subprocess.run(
321
+ ["git", "commit", "-m", "new work"],
322
+ cwd=tmp_path, capture_output=True, check=True,
323
+ env={**GIT_ENV, "HOME": str(tmp_path)},
324
+ )
325
+
326
+ ref = get_upstream_ref(tmp_path)
327
+ assert ref is not None
328
+ # ref was returned by `git merge-base HEAD ...`, it should be the
329
+ # SHA of the feature base, not a named ref.
330
+ assert ref == feature_base_sha
331
+
332
+
333
+ def test_get_upstream_ref_no_fallback(tmp_path: Path) -> None:
334
+ """No upstream, no origin/HEAD, no known default branches returns None."""
335
+ subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True)
336
+ _make_commit(tmp_path)
337
+ subprocess.run(
338
+ ["git", "remote", "add", "origin", "http://example.com/repo.git"],
339
+ cwd=tmp_path, capture_output=True, check=True,
340
+ )
341
+ ref = get_upstream_ref(tmp_path)
342
+ assert ref is None
343
+
344
+
345
+ def _make_commit(tmp_path: Path) -> None:
346
+ """Helper: make an initial commit in a bare init."""
347
+ subprocess.run(
348
+ ["git", "commit", "--allow-empty", "-m", "init"],
349
+ cwd=tmp_path,
350
+ capture_output=True,
351
+ check=True,
352
+ env={
353
+ "GIT_AUTHOR_NAME": "T",
354
+ "GIT_AUTHOR_EMAIL": "t@t",
355
+ "GIT_COMMITTER_NAME": "T",
356
+ "GIT_COMMITTER_EMAIL": "t@t",
357
+ "HOME": str(tmp_path),
358
+ "PATH": "/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin",
359
+ },
360
+ )
361
+
362
+
183
363
  # --- get_merge_base ---
184
364
 
185
365
 
@@ -125,15 +125,18 @@ def test_new_leaves_next_checkpoint_requiring_stack(
125
125
  assert not (assertion_dir / "metadata.json").exists()
126
126
 
127
127
 
128
- def test_new_requires_upstream_tracking_branch(
128
+ def test_new_falls_back_to_head_when_no_upstream(
129
129
  tmp_path: Path,
130
130
  monkeypatch,
131
131
  ) -> None:
132
+ """When no upstream tracking branch exists, fall back to HEAD instead of erroring."""
132
133
  _run_git(tmp_path, "init")
133
134
  _run_git(tmp_path, "commit", "--allow-empty", "-m", "init")
135
+ head_sha = _run_git(tmp_path, "rev-parse", "HEAD")
134
136
  monkeypatch.chdir(tmp_path)
135
137
 
136
138
  result = runner.invoke(main.app, ["new"])
137
139
 
138
- assert result.exit_code == 1
139
- assert "no upstream tracking branch" in result.stderr
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
@@ -197,23 +197,27 @@ def test_session_start_requires_git_repo(tmp_path: Path, monkeypatch) -> None:
197
197
  assert "git repository" in result.stderr
198
198
 
199
199
 
200
- def test_session_start_requires_upstream_tracking_branch(
200
+ def test_session_start_falls_back_to_head_when_no_upstream(
201
201
  tmp_path: Path, monkeypatch
202
202
  ) -> None:
203
+ """When no upstream tracking branch exists, fall back to HEAD instead of erroring.
204
+
205
+ Codex cloud sandboxes may not have upstream tracking configured, but the
206
+ branch was cloned from the remote so HEAD exists on the remote.
207
+ """
203
208
  _run_git(tmp_path, "init")
204
209
  _run_git(tmp_path, "commit", "--allow-empty", "-m", "init")
210
+ head_sha = _run_git(tmp_path, "rev-parse", "HEAD")
205
211
  monkeypatch.chdir(tmp_path)
206
212
 
207
213
  result = runner.invoke(main.app, ["session", "start"])
208
214
 
209
- assert result.exit_code == 1
210
- assert "no upstream tracking branch" in result.stderr
211
- assert not (tmp_path / ".assertion" / "base_sha").exists()
215
+ assert result.exit_code == 0
216
+ assert "falling back to HEAD" in result.stderr
217
+ assert (tmp_path / ".assertion" / "base_sha").read_text().strip() == head_sha
212
218
 
213
219
 
214
- def test_session_start_records_merge_base_as_base(
215
- tmp_path: Path, monkeypatch
216
- ) -> None:
220
+ def test_session_start_records_merge_base_as_base(tmp_path: Path, monkeypatch) -> None:
217
221
  """`asrt session start` pins the diff base to the branch merge-base.
218
222
 
219
223
  Every subsequent checkpoint/verify diffs against this SHA, so backend can
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes