fix: three defects found by codex review of today's merged work
No P1s. All three confirmed in the code before fixing. [P2] Invites were destroyed by a transient forge error. apply_invites() popped the whole pending list BEFORE attempting the collaborator PUT, so a 502 or a timeout while someone first signed in meant they got no access and re-linking never retried -- the promise was gone. Now it peeks, and consumes each grant only after that grant actually lands. A partial failure keeps exactly the grants that failed. [P2] Snapshots lost staged-only work. build_snapshot() read HEAD into a scratch index and staged the WORKTREE, so a hunk you staged and then edited further survived only in its later worktree form. git keeps index and worktree as separate states and the backup now does too: the real index is read without being touched, and when it differs from both HEAD and the worktree it rides along as a second parent. [P3] A truncated repo listing could resolve a bare name to the WRONG repo. resolve_granted() discarded the truncation flag, so a name whose only match sat beyond the 2000-repo cap fell back to <login>/<name> and would clone that instead. Truncation now means "unknown", not "absent": it refuses and asks for the owner. A complete listing still falls back, because absence is then real. 209 tests (was 204).
This commit is contained in:
@@ -104,6 +104,10 @@ FORGE_MAX_PAGES = 40 # 2000 repos; a guard against an unbounded paging loop
|
||||
# Under refs/heads a machine taking a snapshot every 30s would bury the
|
||||
# user's real branches.
|
||||
BACKUP_NS = "refs/granthi-backup"
|
||||
_SNAPSHOT_IDENT = {"GIT_AUTHOR_NAME": "granthi-sync",
|
||||
"GIT_AUTHOR_EMAIL": "[email protected]",
|
||||
"GIT_COMMITTER_NAME": "granthi-sync",
|
||||
"GIT_COMMITTER_EMAIL": "[email protected]"}
|
||||
SNAPSHOT_TS_FMT = "%Y%m%dT%H%M%SZ"
|
||||
|
||||
# Retention. Unbounded snapshots are a disk leak with no way to use them, so
|
||||
@@ -301,15 +305,33 @@ def build_snapshot(folder):
|
||||
args = ["commit-tree", tree, "-m", f"granthi snapshot: {ts}"]
|
||||
if head:
|
||||
args += ["-p", head]
|
||||
# The worktree tree alone loses STAGED-ONLY work. Stage a hunk, edit the
|
||||
# file further, lose the laptop, and the snapshot holds only the later
|
||||
# worktree version -- the carefully staged one is gone. git itself keeps
|
||||
# index and worktree as separate states, so the backup must too.
|
||||
# The user's real index is read WITHOUT touching it, and when it differs
|
||||
# from both HEAD and the worktree it rides along as a second parent, so
|
||||
# it is reachable from the snapshot. (codex review, P2.)
|
||||
rc_idx, index_tree = git(folder, "write-tree", check=False)
|
||||
index_tree = index_tree.strip()
|
||||
if rc_idx == 0 and index_tree and index_tree != tree:
|
||||
head_tree_now = ""
|
||||
if head:
|
||||
head_tree_now = git(folder, "rev-parse", f"{head}^{{tree}}")[1].strip()
|
||||
if index_tree != head_tree_now:
|
||||
icommit_args = ["commit-tree", index_tree, "-m",
|
||||
f"granthi snapshot (staged): {ts}"]
|
||||
if head:
|
||||
icommit_args += ["-p", head]
|
||||
rc_ic, icommit = git(folder, *icommit_args, check=False,
|
||||
env=_SNAPSHOT_IDENT)
|
||||
if rc_ic == 0 and icommit.strip():
|
||||
args += ["-p", icommit.strip()]
|
||||
# Snapshots are parented on HEAD and nothing else -- deliberately NOT
|
||||
# chained to the previous snapshot. Chaining would keep every old
|
||||
# snapshot reachable from the newest one, so pruning a ref would free
|
||||
# nothing and retention would be decorative.
|
||||
_, commit = git(folder, *args,
|
||||
env={"GIT_AUTHOR_NAME": "granthi-sync",
|
||||
"GIT_AUTHOR_EMAIL": "[email protected]",
|
||||
"GIT_COMMITTER_NAME": "granthi-sync",
|
||||
"GIT_COMMITTER_EMAIL": "[email protected]"})
|
||||
_, commit = git(folder, *args, env=_SNAPSHOT_IDENT)
|
||||
return commit.strip(), tree
|
||||
finally:
|
||||
if os.path.exists(tmp_index):
|
||||
@@ -838,7 +860,7 @@ def clone_one(cfg, full_name, dest, mode=None):
|
||||
return meta
|
||||
|
||||
|
||||
def resolve_granted(cfg, want, repos=None):
|
||||
def resolve_granted(cfg, want, repos=None, truncated=False):
|
||||
"""Turn what the user typed into the repo the forge actually grants them.
|
||||
|
||||
`get notes` used to mean `<your-login>/notes` and nothing else. On a real
|
||||
@@ -857,7 +879,7 @@ def resolve_granted(cfg, want, repos=None):
|
||||
return parse_repo_arg(want, cfg["login"])
|
||||
if repos is None:
|
||||
try:
|
||||
repos, _truncated = list_repos(cfg)
|
||||
repos, truncated = list_repos(cfg)
|
||||
except (SystemExit, ValueError, OSError) as e:
|
||||
# Best effort. If the listing is unreachable, a name the user
|
||||
# typed in full must still clone, and a bare name should degrade
|
||||
@@ -876,8 +898,18 @@ def resolve_granted(cfg, want, repos=None):
|
||||
f"{want!r} is ambiguous — {len(matches)} repos you can see have "
|
||||
f"that name:\n{listed}\nRe-run with the owner, e.g. "
|
||||
f"granthi-sync get {sorted(matches)[0]}")
|
||||
# Nothing granted by that name. Fall back to the caller's own namespace so
|
||||
# the message names a concrete repo instead of a guess.
|
||||
# Nothing granted by that name. If the listing was TRUNCATED the answer is
|
||||
# unknown rather than absent -- falling back to <login>/<name> could clone a
|
||||
# different repo that happens to exist under your own account. Refuse and
|
||||
# ask for the owner. (codex review, P3.)
|
||||
if truncated:
|
||||
raise SystemExit(
|
||||
f"could not confirm {want!r}: your repo list was truncated at "
|
||||
f"{FORGE_MAX_PAGES * FORGE_PAGE_LIMIT} repos, so a match may exist "
|
||||
f"beyond it. Re-run with the owner, e.g. "
|
||||
f"granthi-sync get <owner>/{want}")
|
||||
# Otherwise the listing was complete and simply has no such repo; name a
|
||||
# concrete one so the clone error is precise.
|
||||
return parse_repo_arg(want, cfg["login"])
|
||||
|
||||
|
||||
@@ -1341,13 +1373,14 @@ def cmd_bootstrap(args):
|
||||
into = os.path.abspath(args.into or ".")
|
||||
print(f"workspace: {name}\n")
|
||||
|
||||
granted_repos, _trunc = list_repos(cfg)
|
||||
granted_repos, granted_truncated = list_repos(cfg)
|
||||
granted = {r.get("full_name") for r in granted_repos}
|
||||
pulled = skipped = denied = 0
|
||||
for repo in ws["repos"]:
|
||||
want = repo["name"]
|
||||
try:
|
||||
full = resolve_granted(cfg, want, repos=granted_repos)
|
||||
full = resolve_granted(cfg, want, repos=granted_repos,
|
||||
truncated=granted_truncated)
|
||||
except SystemExit as e:
|
||||
log(f"AMBIGUOUS {want}: {e}")
|
||||
denied += 1
|
||||
|
||||
Reference in New Issue
Block a user