feat(git): draft branch mode — commit and push every save, reconcile onto main, sync on request #148
No reviewers
Labels
No labels
bug
enhancement
needs-triage
ready-for-agent
ready-for-human
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Cloonar/deckle!148
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "afk/147"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #147
What
When the checkout's HEAD is on a branch other than
main, the harness runs in branch mode: that branch is the draft branch. With HEAD onmain(WIP mode), behaviour is unchanged: every existing test passes without edits, andrefs/deckle/wipworks as before.git statusis empty, and the branch is lease-pushed with--force-with-lease=<draft>:<observed remote tip>. A plain--forcenever happens, and a lease failure is never overridden. A mutation that changes nothing makes no commit.refs/remotes/<remote>/main. Localmainis never written and may not exist (clone --branch editor).mainand pushes exactly the publish's paths. It then rebases the draft branch onto the newmainwith the policy, resets the checkout and lease-pushes. When no draft remains, the branch collapses ontomain.main, so after a collapse undo answers 409.harness reconcile [--site] [--onto] [--json]: follows the feature commands' JSON contract and exit codes (0, 1 or 2). It touches no remote and refuses a dirty working tree.POST /api/git/sync: commits, fetches, then either adopts the remote tip (when tips or trees are equal) or replays and lease-pushes. It then resets the checkout and collapses if applicable. It answers 409 in WIP mode.serveruns the same routine once before serving, and an unreachable remote means it serves in the unsynced state.ServeHTTP. It covers every mutating route, publish, sync, and the GETs that stamp uids (/api/page,/api/page/elements). Tests assert at runtime that every save runs under it./api/git/state: gainsmode,branch,tip,unsyncedandunsyncedReason./api/response in branch mode carriesX-Deckle-Draft-Tip. It is never in a body, so the tree body stays byte-identical across polls.reconcileentry in the command list.internal/gitrepo/doc.go: new "# Branch mode" section.scaffold/site/AGENTS.mdis left untouched. It documents only the site-agent commands (features,add,remove,sidecar,serve), andreconcileis host-only. No schema, flag or rule it documents changed.Verification
go build ./...,go vet ./...andgo test -race -count=1 ./...all pass.internal/gitrepotests:branch_test.go,reconcile_test.go.internal/servertests:branch_test.go. It covers each mutation producing one pushed commit, an unreachable remote, sync adopting a moved remote, publish/collapse/undo with a developer push tomain, the tip header, the lock, and a concurrent save + sync stress test.cmd/harnesstests:reconcile_test.go, covering the acceptance cases plus startup sync.web:tsc --noEmitis clean, all 1578 tests pass innpm test(including the newDraftTip.test.tsx), andnpm run buildsucceeds.main, and a--branch editorclone with a draft. After a layout pushed tomain,harness reconcile --jsonreturnedreconciledwith"resolved":[], the layout came frommain, the draft was kept, and the status was clean. Running it again returnedunchanged. A run in WIP mode exited 1 with JSON, and an unknown flag exited 2 with no JSON.Notes / limits
stubHugohelper; the real-Hugo tests skip.--forceor a+refspec./api/pageand/api/page/elementstake the mutation lock because they can stamp uids. Opening a page therefore waits behind a running publish or sync.🤖 Generated with Claude Code
[autoland] verdict: pass
Verdict: PASS
Signal relied on: Forgejo Actions
ci / build-and-testpassed on the head (run 135): frontendnpm ci && npm run build && npm test, thengo vet ./...,go test ./...(with real Hugo 0.163.3, so the real-Hugo tests the PR notes as skipped locally did run here) andgo build. Not re-run.Conventions: Conventional Commits title.
Closes #147is present. The branch is up to date withmain, so there is no conflict.scaffold/site/AGENTS.mdis correctly left untouched: it documents only the site-agent commands, andreconcileand the git behaviour are host-side.Diff review: Checked against #147.
gitrepo.goOpen,openBranchMode). WIP mode paths are unchanged apart fromrefs/heads/main→mainRef(), which is identical in WIP mode.CommitDraftfollows the spec. It does a CASupdate-refand a mixed reset. The push is--force-with-lease=<draft>:<observed>. It never uses a plain--force, and a push failure never fails the save.Synccommits first, then fetches. It adopts on equal tree, otherwise doesrebase --onto new oldwith the policy in a temp worktree, then resets and collapses.merge-base --is-ancestoragainst main.reconcilefollows the--jsoncontract and exit codes 0/1/2, and refuses a dirty tree.ServeHTTPand covers every write route. Publish is synchronous, so the lock covers the actual git work. Tests assert the lock at runtime.X-Deckle-Draft-Tipheader, andsyncDraftsetstrackDraftTip: false.Non-blocking observations (below the CONCERNS bar):
GET /api/page/elements(link picker) can stamp uids and so move the tip. The SPA does not count it as one of its own requests, so the next poll may see a "foreign" tip and re-fetch the open page. That is harmless: nothing is swapped in unless the content differs.syncWithRemotefallback (merge-base when the old observed tip is no longer in local history after a publish rewrite) may replay already-published commits onto the remote draft. The policy resolves this deterministically.