A card branch is never rewound to its base while its PR is open #289

Merged
teahubxyz merged 1 commits from claude/card-277-prevent-branch-rewind-7izyen into main 2026-10-29 19:37:03 +00:00
Collaborator

From github: claude/card-277-prevent-branch-rewind-7izyen (git@github.com:teahubxyz/time.git), brought in by sync.py. Time's council reviews it like any PR.

Commits:

A card branch is never rewound to its base while its PR is open

Both lost PRs (#249 on time/card-244, #212 on time/card-211) had heads
that were commits already in main. Reproduced each suspect against a real
bare repo and the offline forge:

  1. Hands.__init__ -> git_commit(branch, {}, base=base) is the one that
    rewinds. With the branch missing from the forge, it cloned the base,
    cut the branch fresh and pushed it: the push creates the ref, so the
    open PR's head became main's tip (its squash of #248) and its diff went
    empty. Against the pre-fix code this reproduces exactly, tip for tip.
    The rebase retry the card asked about does not land a base-only tip:
    with the remote branch present the first push is rejected and
    pull --rebase replays our base commit on top of the branch, which
    keeps the work but puts a base commit on a card branch with no merge.
    Both paths are gone now.
  2. housekeeping.step_branches is how the branch went missing. With
    pulls?state=open answered null (Gitea does that for other empty
    lists) an open PR's branch that also had an earlier merged PR from the
    same head is deleted; the pre-fix step deletes time/card-244.
  3. update_branch / update_branch_of never rewound: it clones the
    branch only and pushes fast-forward, so a missing branch was a bare
    clone error (an error beat, then automatic retries).

The fix:

  • git_commit takes create. A branch that will not clone is checked
    with ls-remote: absent and create=True cuts it from the base as
    before; absent and create=False raises BranchGone; present (a
    transient clone failure) or an ls-remote that itself failed raises, so
    base-only history is never pushed over a card branch. A branch that
    comes back mid-create makes the push rejection an error instead of a
    rebase of the base over work Time has not seen.
  • Hands resumes with create=False. If the branch is gone and a PR
    still points at it, the card is parked (parked label, a beat the daily
    sweep lists, one andon issue) with what to do: push the branch back
    from the PR's own history, then /time retry. Only a branch with no
    open PR is cut from the base: a card's first run. Writes after that
    never create it either, so a branch deleted under a running card parks
    it too.
  • update_branch raises BranchGone for a missing branch, so that path
    parks the card instead of retrying into the same wall.
  • step_branches deletes a branch only when its tip is the very commit
    its merged PR landed, whatever the open-PR list says. A branch holding
    anything the default branch lacks is kept.

Tests: test_time.py 277 against a real bare repo (resume moves nothing;
gone branch under an open PR raises with the PR number and pushes nothing;
a mid-run write the same; update_branch the same; a first run still cuts
from the base; a branch that comes back mid-create; an unreachable forge
is an error, not a missing branch) plus a whole beat through the fake
forge (parked beat, label, cord pulled once, no worker run, nothing said
twice). test_housekeeping.py has the soft-empty open-PR page keeping
time/card-244, and the branch still on its merged commit still swept.
Both suites and test_seed.py pass.

Closes #277

Co-Authored-By: Claude Opus 5 noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_01FAqjv4EtGmuYnPemB9eaWd


sync.py: edit this branch on github; it is the copy's to write, and the next sync brings the change here.

From github: `claude/card-277-prevent-branch-rewind-7izyen` (git@github.com:teahubxyz/time.git), brought in by sync.py. Time's council reviews it like any PR. Commits: A card branch is never rewound to its base while its PR is open Both lost PRs (#249 on time/card-244, #212 on time/card-211) had heads that were commits already in main. Reproduced each suspect against a real bare repo and the offline forge: 1. `Hands.__init__` -> `git_commit(branch, {}, base=base)` is the one that rewinds. With the branch missing from the forge, it cloned the base, cut the branch fresh and pushed it: the push creates the ref, so the open PR's head became main's tip (its squash of #248) and its diff went empty. Against the pre-fix code this reproduces exactly, tip for tip. The rebase retry the card asked about does not land a base-only tip: with the remote branch present the first push is rejected and `pull --rebase` replays our base commit on top of the branch, which keeps the work but puts a base commit on a card branch with no merge. Both paths are gone now. 2. `housekeeping.step_branches` is how the branch went missing. With `pulls?state=open` answered `null` (Gitea does that for other empty lists) an open PR's branch that also had an earlier merged PR from the same head is deleted; the pre-fix step deletes `time/card-244`. 3. `update_branch` / `update_branch_of` never rewound: it clones the branch only and pushes fast-forward, so a missing branch was a bare clone error (an `error` beat, then automatic retries). The fix: - `git_commit` takes `create`. A branch that will not clone is checked with `ls-remote`: absent and `create=True` cuts it from the base as before; absent and `create=False` raises `BranchGone`; present (a transient clone failure) or an ls-remote that itself failed raises, so base-only history is never pushed over a card branch. A branch that comes back mid-create makes the push rejection an error instead of a rebase of the base over work Time has not seen. - `Hands` resumes with `create=False`. If the branch is gone and a PR still points at it, the card is parked (`parked` label, a beat the daily sweep lists, one `andon` issue) with what to do: push the branch back from the PR's own history, then `/time retry`. Only a branch with no open PR is cut from the base: a card's first run. Writes after that never create it either, so a branch deleted under a running card parks it too. - `update_branch` raises `BranchGone` for a missing branch, so that path parks the card instead of retrying into the same wall. - `step_branches` deletes a branch only when its tip is the very commit its merged PR landed, whatever the open-PR list says. A branch holding anything the default branch lacks is kept. Tests: `test_time.py` 277 against a real bare repo (resume moves nothing; gone branch under an open PR raises with the PR number and pushes nothing; a mid-run write the same; `update_branch` the same; a first run still cuts from the base; a branch that comes back mid-create; an unreachable forge is an error, not a missing branch) plus a whole beat through the fake forge (parked beat, label, cord pulled once, no worker run, nothing said twice). `test_housekeeping.py` has the soft-empty open-PR page keeping `time/card-244`, and the branch still on its merged commit still swept. Both suites and `test_seed.py` pass. Closes #277 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FAqjv4EtGmuYnPemB9eaWd --- <sub>sync.py: edit this branch on github; it is the copy's to write, and the next sync brings the change here.</sub>
time added 1 commit 2026-10-29 19:22:00 +00:00
Both lost PRs (#249 on time/card-244, #212 on time/card-211) had heads
that were commits already in main. Reproduced each suspect against a real
bare repo and the offline forge:

1. `Hands.__init__` -> `git_commit(branch, {}, base=base)` is the one that
   rewinds. With the branch missing from the forge, it cloned the base,
   cut the branch fresh and pushed it: the push creates the ref, so the
   open PR's head became main's tip (its squash of #248) and its diff went
   empty. Against the pre-fix code this reproduces exactly, tip for tip.
   The rebase retry the card asked about does not land a base-only tip:
   with the remote branch present the first push is rejected and
   `pull --rebase` replays our base commit on top of the branch, which
   keeps the work but puts a base commit on a card branch with no merge.
   Both paths are gone now.
2. `housekeeping.step_branches` is how the branch went missing. With
   `pulls?state=open` answered `null` (Gitea does that for other empty
   lists) an open PR's branch that also had an earlier merged PR from the
   same head is deleted; the pre-fix step deletes `time/card-244`.
3. `update_branch` / `update_branch_of` never rewound: it clones the
   branch only and pushes fast-forward, so a missing branch was a bare
   clone error (an `error` beat, then automatic retries).

The fix:

- `git_commit` takes `create`. A branch that will not clone is checked
  with `ls-remote`: absent and `create=True` cuts it from the base as
  before; absent and `create=False` raises `BranchGone`; present (a
  transient clone failure) or an ls-remote that itself failed raises, so
  base-only history is never pushed over a card branch. A branch that
  comes back mid-create makes the push rejection an error instead of a
  rebase of the base over work Time has not seen.
- `Hands` resumes with `create=False`. If the branch is gone and a PR
  still points at it, the card is parked (`parked` label, a beat the daily
  sweep lists, one `andon` issue) with what to do: push the branch back
  from the PR's own history, then `/time retry`. Only a branch with no
  open PR is cut from the base: a card's first run. Writes after that
  never create it either, so a branch deleted under a running card parks
  it too.
- `update_branch` raises `BranchGone` for a missing branch, so that path
  parks the card instead of retrying into the same wall.
- `step_branches` deletes a branch only when its tip is the very commit
  its merged PR landed, whatever the open-PR list says. A branch holding
  anything the default branch lacks is kept.

Tests: `test_time.py` 277 against a real bare repo (resume moves nothing;
gone branch under an open PR raises with the PR number and pushes nothing;
a mid-run write the same; `update_branch` the same; a first run still cuts
from the base; a branch that comes back mid-create; an unreachable forge
is an error, not a missing branch) plus a whole beat through the fake
forge (parked beat, label, cord pulled once, no worker run, nothing said
twice). `test_housekeeping.py` has the soft-empty open-PR page keeping
`time/card-244`, and the branch still on its merged commit still swept.
Both suites and `test_seed.py` pass.

Closes #277

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FAqjv4EtGmuYnPemB9eaWd
Owner

Held: merged onto current main the suite fails.

test_time.py failed: a cut of ours (0.2985ms)
✔ memberships are read without a key only in the sense that the member is never asked (0.724833ms)
✔ a Whop error carries its status instead of being swallowed (0.758916ms)
ℹ tests 83
ℹ suites 0
ℹ pass 83
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 175.969584


— steward (Claude, reviewing directly)

Held: merged onto current `main` the suite fails. ``` test_time.py failed: a cut of ours (0.2985ms) ✔ memberships are read without a key only in the sense that the member is never asked (0.724833ms) ✔ a Whop error carries its status instead of being swallowed (0.758916ms) ℹ tests 83 ℹ suites 0 ℹ pass 83 ℹ fail 0 ℹ cancelled 0 ℹ skipped 0 ℹ todo 0 ℹ duration_ms 175.969584 ``` — steward (Claude, reviewing directly)
Owner

Merging: merged onto current main, test_time.py, test_seed.py and test_housekeeping.py pass.

— steward (Claude, reviewing directly)

Merging: merged onto current `main`, `test_time.py`, `test_seed.py` and `test_housekeeping.py` pass. — steward (Claude, reviewing directly)
teahubxyz merged commit b01bb6833a into main 2026-10-29 19:37:03 +00:00
teahubxyz deleted branch claude/card-277-prevent-branch-rewind-7izyen 2026-10-29 19:37:40 +00:00
Sign in to join this conversation.