assertion-cli 0.5.9__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 (31) hide show
  1. {assertion_cli-0.5.9 → assertion_cli-0.5.10}/PKG-INFO +1 -1
  2. {assertion_cli-0.5.9 → assertion_cli-0.5.10}/assertion_cli.egg-info/PKG-INFO +1 -1
  3. {assertion_cli-0.5.9 → assertion_cli-0.5.10}/git.py +52 -4
  4. {assertion_cli-0.5.9 → assertion_cli-0.5.10}/main.py +11 -0
  5. {assertion_cli-0.5.9 → assertion_cli-0.5.10}/pyproject.toml +1 -1
  6. {assertion_cli-0.5.9 → assertion_cli-0.5.10}/tests/test_git.py +181 -0
  7. {assertion_cli-0.5.9 → assertion_cli-0.5.10}/README.md +0 -0
  8. {assertion_cli-0.5.9 → assertion_cli-0.5.10}/api.py +0 -0
  9. {assertion_cli-0.5.9 → assertion_cli-0.5.10}/assertion_cli.egg-info/SOURCES.txt +0 -0
  10. {assertion_cli-0.5.9 → assertion_cli-0.5.10}/assertion_cli.egg-info/dependency_links.txt +0 -0
  11. {assertion_cli-0.5.9 → assertion_cli-0.5.10}/assertion_cli.egg-info/entry_points.txt +0 -0
  12. {assertion_cli-0.5.9 → assertion_cli-0.5.10}/assertion_cli.egg-info/requires.txt +0 -0
  13. {assertion_cli-0.5.9 → assertion_cli-0.5.10}/assertion_cli.egg-info/top_level.txt +0 -0
  14. {assertion_cli-0.5.9 → assertion_cli-0.5.10}/bundle.py +0 -0
  15. {assertion_cli-0.5.9 → assertion_cli-0.5.10}/link.py +0 -0
  16. {assertion_cli-0.5.9 → assertion_cli-0.5.10}/models.py +0 -0
  17. {assertion_cli-0.5.9 → assertion_cli-0.5.10}/session.py +0 -0
  18. {assertion_cli-0.5.9 → assertion_cli-0.5.10}/setup.cfg +0 -0
  19. {assertion_cli-0.5.9 → assertion_cli-0.5.10}/templates/AGENTS.md +0 -0
  20. {assertion_cli-0.5.9 → assertion_cli-0.5.10}/templates/SKILL.md +0 -0
  21. {assertion_cli-0.5.9 → assertion_cli-0.5.10}/templates/__init__.py +0 -0
  22. {assertion_cli-0.5.9 → assertion_cli-0.5.10}/tests/test_api.py +0 -0
  23. {assertion_cli-0.5.9 → assertion_cli-0.5.10}/tests/test_bundle.py +0 -0
  24. {assertion_cli-0.5.9 → assertion_cli-0.5.10}/tests/test_decision.py +0 -0
  25. {assertion_cli-0.5.9 → assertion_cli-0.5.10}/tests/test_install.py +0 -0
  26. {assertion_cli-0.5.9 → assertion_cli-0.5.10}/tests/test_link.py +0 -0
  27. {assertion_cli-0.5.9 → assertion_cli-0.5.10}/tests/test_main.py +0 -0
  28. {assertion_cli-0.5.9 → assertion_cli-0.5.10}/tests/test_new.py +0 -0
  29. {assertion_cli-0.5.9 → assertion_cli-0.5.10}/tests/test_prompt.py +0 -0
  30. {assertion_cli-0.5.9 → assertion_cli-0.5.10}/tests/test_session.py +0 -0
  31. {assertion_cli-0.5.9 → assertion_cli-0.5.10}/tests/test_session_start.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.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.9
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
@@ -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
 
@@ -19,6 +19,7 @@ from git import (
19
19
  get_merge_base,
20
20
  get_uncommitted_diff,
21
21
  get_upstream_ref,
22
+ run_git_command,
22
23
  )
23
24
  from link import load_link, save_link
24
25
  from models import CheckpointResponse, SessionStatus, render_stack_list
@@ -265,9 +266,19 @@ app.add_typer(session_app, name="session")
265
266
  def _resolve_diff_base(repo_root: Path) -> str:
266
267
  """Compute the merge-base with the upstream tracking branch.
267
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
+
268
274
  Errors out when no upstream can be resolved (detached HEAD, no tracking
269
275
  branch, branch not pushed to remote).
270
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
+
271
282
  upstream = get_upstream_ref(repo_root)
272
283
  if upstream is None:
273
284
  exit_with_error(
@@ -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.10"
8
8
  description = "CLI for the Assertion API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13"
@@ -180,6 +180,187 @@ def test_get_upstream_ref_detached_head(tmp_path: Path) -> None:
180
180
  assert ref is None
181
181
 
182
182
 
183
+ def test_get_upstream_ref_fallback_to_origin_head(tmp_path: Path) -> None:
184
+ """HEAD on an orphan commit (no shared history with remote refs)
185
+ returns origin/HEAD."""
186
+ subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True)
187
+ _make_commit(tmp_path)
188
+ subprocess.run(
189
+ ["git", "remote", "add", "origin", "http://example.com/repo.git"],
190
+ cwd=tmp_path, capture_output=True, check=True,
191
+ )
192
+ subprocess.run(
193
+ ["git", "symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/somebranch"],
194
+ cwd=tmp_path, capture_output=True, check=True,
195
+ )
196
+ subprocess.run(
197
+ ["git", "update-ref", "refs/remotes/origin/somebranch", "HEAD"],
198
+ cwd=tmp_path, capture_output=True, check=True,
199
+ )
200
+ # Create an orphan commit so HEAD has no shared history with the
201
+ # remote refs — this makes step 3 (--merged HEAD) produce nothing.
202
+ subprocess.run(
203
+ ["git", "checkout", "--orphan", "orphan-branch"],
204
+ cwd=tmp_path, capture_output=True, check=True,
205
+ )
206
+ (tmp_path / "orphan.txt").write_text("orphan\n")
207
+ subprocess.run(
208
+ ["git", "add", "orphan.txt"], cwd=tmp_path, capture_output=True, check=True
209
+ )
210
+ subprocess.run(
211
+ ["git", "commit", "-m", "orphan"],
212
+ cwd=tmp_path, capture_output=True, check=True,
213
+ env={**GIT_ENV, "HOME": str(tmp_path)},
214
+ )
215
+ ref = get_upstream_ref(tmp_path)
216
+ assert ref == "origin/HEAD"
217
+
218
+
219
+ def test_get_upstream_ref_fallback_to_origin_main(tmp_path: Path) -> None:
220
+ """HEAD on an orphan commit, no origin/HEAD, but origin/main exists."""
221
+ subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True)
222
+ _make_commit(tmp_path)
223
+ subprocess.run(
224
+ ["git", "remote", "add", "origin", "http://example.com/repo.git"],
225
+ cwd=tmp_path, capture_output=True, check=True,
226
+ )
227
+ subprocess.run(
228
+ ["git", "update-ref", "refs/remotes/origin/main", "HEAD"],
229
+ cwd=tmp_path, capture_output=True, check=True,
230
+ )
231
+ subprocess.run(
232
+ ["git", "checkout", "--orphan", "orphan-branch"],
233
+ cwd=tmp_path, capture_output=True, check=True,
234
+ )
235
+ (tmp_path / "orphan.txt").write_text("orphan\n")
236
+ subprocess.run(
237
+ ["git", "add", "orphan.txt"], cwd=tmp_path, capture_output=True, check=True
238
+ )
239
+ subprocess.run(
240
+ ["git", "commit", "-m", "orphan"],
241
+ cwd=tmp_path, capture_output=True, check=True,
242
+ env={**GIT_ENV, "HOME": str(tmp_path)},
243
+ )
244
+ ref = get_upstream_ref(tmp_path)
245
+ assert ref == "origin/main"
246
+
247
+
248
+ def test_get_upstream_ref_fallback_to_origin_master(tmp_path: Path) -> None:
249
+ """HEAD on an orphan commit, no origin/HEAD/main, origin/master exists."""
250
+ subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True)
251
+ _make_commit(tmp_path)
252
+ subprocess.run(
253
+ ["git", "remote", "add", "origin", "http://example.com/repo.git"],
254
+ cwd=tmp_path, capture_output=True, check=True,
255
+ )
256
+ subprocess.run(
257
+ ["git", "update-ref", "refs/remotes/origin/master", "HEAD"],
258
+ cwd=tmp_path, capture_output=True, check=True,
259
+ )
260
+ subprocess.run(
261
+ ["git", "checkout", "--orphan", "orphan-branch"],
262
+ cwd=tmp_path, capture_output=True, check=True,
263
+ )
264
+ (tmp_path / "orphan.txt").write_text("orphan\n")
265
+ subprocess.run(
266
+ ["git", "add", "orphan.txt"], cwd=tmp_path, capture_output=True, check=True
267
+ )
268
+ subprocess.run(
269
+ ["git", "commit", "-m", "orphan"],
270
+ cwd=tmp_path, capture_output=True, check=True,
271
+ env={**GIT_ENV, "HOME": str(tmp_path)},
272
+ )
273
+ ref = get_upstream_ref(tmp_path)
274
+ assert ref == "origin/master"
275
+
276
+
277
+ def test_get_upstream_ref_fallback_to_merged_ancestors(tmp_path: Path) -> None:
278
+ """Branch based on a pushed feature branch finds the closest remote
279
+ ancestor via --merged HEAD + merge-base."""
280
+ _init_repo_with_commit(tmp_path)
281
+ base_sha = run_git_command(tmp_path, ["rev-parse", "HEAD"])
282
+ subprocess.run(
283
+ ["git", "remote", "add", "origin", "http://example.com/repo.git"],
284
+ cwd=tmp_path, capture_output=True, check=True,
285
+ )
286
+
287
+ # Simulate origin/main at the initial commit.
288
+ subprocess.run(
289
+ ["git", "update-ref", "refs/remotes/origin/main", base_sha],
290
+ cwd=tmp_path, capture_output=True, check=True,
291
+ )
292
+
293
+ # Simulate a pushed feature branch (origin/my-feature-base) with a
294
+ # commit on top of main.
295
+ (tmp_path / "base.txt").write_text("base\n")
296
+ subprocess.run(
297
+ ["git", "add", "base.txt"], cwd=tmp_path, capture_output=True, check=True
298
+ )
299
+ subprocess.run(
300
+ ["git", "commit", "-m", "base work"],
301
+ cwd=tmp_path, capture_output=True, check=True,
302
+ env={**GIT_ENV, "HOME": str(tmp_path)},
303
+ )
304
+ feature_base_sha = run_git_command(tmp_path, ["rev-parse", "HEAD"])
305
+ subprocess.run(
306
+ ["git", "update-ref", "refs/remotes/origin/my-feature-base", feature_base_sha],
307
+ cwd=tmp_path, capture_output=True, check=True,
308
+ )
309
+
310
+ # Create a new local branch on top of my-feature-base (simulates a
311
+ # branch-off that hasn't been pushed). Use a branch name that doesn't
312
+ # collide with any remote ref so step 2 is skipped.
313
+ subprocess.run(
314
+ ["git", "checkout", "-b", "my-feature"],
315
+ cwd=tmp_path, capture_output=True, check=True,
316
+ )
317
+ (tmp_path / "new.txt").write_text("new\n")
318
+ subprocess.run(
319
+ ["git", "add", "new.txt"], cwd=tmp_path, capture_output=True, check=True
320
+ )
321
+ subprocess.run(
322
+ ["git", "commit", "-m", "new work"],
323
+ cwd=tmp_path, capture_output=True, check=True,
324
+ env={**GIT_ENV, "HOME": str(tmp_path)},
325
+ )
326
+
327
+ ref = get_upstream_ref(tmp_path)
328
+ assert ref is not None
329
+ # ref was returned by `git merge-base HEAD ...`, it should be the
330
+ # SHA of the feature base, not a named ref.
331
+ assert ref == feature_base_sha
332
+
333
+
334
+ def test_get_upstream_ref_no_fallback(tmp_path: Path) -> None:
335
+ """No upstream, no origin/HEAD, no known default branches returns None."""
336
+ subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True)
337
+ _make_commit(tmp_path)
338
+ subprocess.run(
339
+ ["git", "remote", "add", "origin", "http://example.com/repo.git"],
340
+ cwd=tmp_path, capture_output=True, check=True,
341
+ )
342
+ ref = get_upstream_ref(tmp_path)
343
+ assert ref is None
344
+
345
+
346
+ def _make_commit(tmp_path: Path) -> None:
347
+ """Helper: make an initial commit in a bare init."""
348
+ subprocess.run(
349
+ ["git", "commit", "--allow-empty", "-m", "init"],
350
+ cwd=tmp_path,
351
+ capture_output=True,
352
+ check=True,
353
+ env={
354
+ "GIT_AUTHOR_NAME": "T",
355
+ "GIT_AUTHOR_EMAIL": "t@t",
356
+ "GIT_COMMITTER_NAME": "T",
357
+ "GIT_COMMITTER_EMAIL": "t@t",
358
+ "HOME": str(tmp_path),
359
+ "PATH": "/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin",
360
+ },
361
+ )
362
+
363
+
183
364
  # --- get_merge_base ---
184
365
 
185
366
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes