feat(git): draft branch mode — commit and push every save, reconcile onto main, sync on request #148

Merged
dominik.polakovics merged 1 commit from afk/147 into main 2026-10-05 22:41:08 +02:00

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 on main (WIP mode), behaviour is unchanged: every existing test passes without edits, and refs/deckle/wip works as before.

  • Saving: every mutation commits the working tree onto the draft branch, using a compare-and-swap ref update. The index is then refreshed so git status is empty, and the branch is lease-pushed with --force-with-lease=<draft>:<observed remote tip>. A plain --force never happens, and a lease failure is never overridden. A mutation that changes nothing makes no commit.
  • Unsynced: a failed push never fails the save. It sets an unsynced state with a reason. The push is retried on the next save and on sync.
  • Lease failure: handled by the sync routine. It replays only the commits added since the observed tip onto the moved remote branch, using the publish policy.
  • Comparisons: badges, status, discard, export and the draft-link scan compare with refs/remotes/<remote>/main. Local main is never written and may not exist (clone --branch editor).
  • Publish: builds on the freshly fetched remote main and pushes exactly the publish's paths. It then rebases the draft branch onto the new main with the policy, resets the checkout and lease-pushes. When no draft remains, the branch collapses onto main.
  • Undo: restores from the draft tip's parent. It is gated on the tip being neither equal to nor an ancestor of the remote 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. serve runs the same routine once before serving, and an unreachable remote means it serves in the unsynced state.
  • Mutation lock: one handler-level lock is taken centrally in 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: gains mode, branch, tip, unsynced and unsyncedReason.
  • Draft tip header: every /api/ response in branch mode carries X-Deckle-Draft-Tip. It is never in a body, so the tree body stays byte-identical across polls.
  • SPA:
    • It remembers the tip of its own mutations and page loads. When the tree poll sees a tip it did not cause, it refetches git state and reloads the open page with a notice, but only if the page changed. Unsaved edits are never clobbered; that reload is deferred until they are saved or discarded.
    • A Not synced chip sits beside the publish chip.
  • Docs:
    • README: new "Branch mode" section, and a reconcile entry in the command list.
    • internal/gitrepo/doc.go: new "# Branch mode" section.
    • scaffold/site/AGENTS.md is left untouched. It documents only the site-agent commands (features, add, remove, sidecar, serve), and reconcile is host-only. No schema, flag or rule it documents changed.

Verification

  • go build ./..., go vet ./... and go test -race -count=1 ./... all pass.
  • New internal/gitrepo tests: branch_test.go, reconcile_test.go.
  • New internal/server tests: 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 to main, the tip header, the lock, and a concurrent save + sync stress test.
  • New cmd/harness tests: reconcile_test.go, covering the acceptance cases plus startup sync.
  • web: tsc --noEmit is clean, all 1578 tests pass in npm test (including the new DraftTip.test.tsx), and npm run build succeeds.
  • Manual smoke run: a bare repo, a developer clone on main, and a --branch editor clone with a draft. After a layout pushed to main, harness reconcile --json returned reconciled with "resolved":[], the layout came from main, the draft was kept, and the status was clean. Running it again returned unchanged. A run in WIP mode exited 1 with JSON, and an unknown flag exited 2 with no JSON.

Notes / limits

  • No real Hugo was available in this environment. The publish tests use the existing stubHugo helper; the real-Hugo tests skip.
  • A remote that moves between fetch and push is not tested directly. The lease itself is tested with a deliberately stale observed tip, and the tests record every push and assert none of them is a plain --force or a +refspec.
  • /api/page and /api/page/elements take the mutation lock because they can stamp uids. Opening a page therefore waits behind a running publish or sync.
  • The SPA does not detect a sync that changes only a page's front matter. The page document does not carry front matter, so the settings form keeps its loaded values until it is reopened.

🤖 Generated with Claude Code

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 on `main` (WIP mode), behaviour is unchanged: every existing test passes without edits, and `refs/deckle/wip` works as before. - **Saving:** every mutation commits the working tree onto the draft branch, using a compare-and-swap ref update. The index is then refreshed so `git status` is empty, and the branch is lease-pushed with `--force-with-lease=<draft>:<observed remote tip>`. A plain `--force` never happens, and a lease failure is never overridden. A mutation that changes nothing makes no commit. - **Unsynced:** a failed push never fails the save. It sets an unsynced state with a reason. The push is retried on the next save and on sync. - **Lease failure:** handled by the sync routine. It replays only the commits added since the observed tip onto the moved remote branch, using the publish policy. - **Comparisons:** badges, status, discard, export and the draft-link scan compare with `refs/remotes/<remote>/main`. Local `main` is never written and may not exist (`clone --branch editor`). - **Publish:** builds on the freshly fetched remote `main` and pushes exactly the publish's paths. It then rebases the draft branch onto the new `main` with the policy, resets the checkout and lease-pushes. When no draft remains, the branch collapses onto `main`. - **Undo:** restores from the draft tip's parent. It is gated on the tip being neither equal to nor an ancestor of the remote `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. `serve` runs the same routine once before serving, and an unreachable remote means it serves in the unsynced state. - **Mutation lock:** one handler-level lock is taken centrally in `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`:** gains `mode`, `branch`, `tip`, `unsynced` and `unsyncedReason`. - **Draft tip header:** every `/api/` response in branch mode carries `X-Deckle-Draft-Tip`. It is never in a body, so the tree body stays byte-identical across polls. - **SPA:** - It remembers the tip of its own mutations and page loads. When the tree poll sees a tip it did not cause, it refetches git state and reloads the open page with a notice, but only if the page changed. Unsaved edits are never clobbered; that reload is deferred until they are saved or discarded. - A *Not synced* chip sits beside the publish chip. - **Docs:** - README: new "Branch mode" section, and a `reconcile` entry in the command list. - `internal/gitrepo/doc.go`: new "# Branch mode" section. - `scaffold/site/AGENTS.md` is left untouched. It documents only the site-agent commands (`features`, `add`, `remove`, `sidecar`, `serve`), and `reconcile` is host-only. No schema, flag or rule it documents changed. ## Verification - `go build ./...`, `go vet ./...` and `go test -race -count=1 ./...` all pass. - New `internal/gitrepo` tests: `branch_test.go`, `reconcile_test.go`. - New `internal/server` tests: `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 to `main`, the tip header, the lock, and a concurrent save + sync stress test. - New `cmd/harness` tests: `reconcile_test.go`, covering the acceptance cases plus startup sync. - `web`: `tsc --noEmit` is clean, all 1578 tests pass in `npm test` (including the new `DraftTip.test.tsx`), and `npm run build` succeeds. - Manual smoke run: a bare repo, a developer clone on `main`, and a `--branch editor` clone with a draft. After a layout pushed to `main`, `harness reconcile --json` returned `reconciled` with `"resolved":[]`, the layout came from `main`, the draft was kept, and the status was clean. Running it again returned `unchanged`. A run in WIP mode exited 1 with JSON, and an unknown flag exited 2 with no JSON. ## Notes / limits - No real Hugo was available in this environment. The publish tests use the existing `stubHugo` helper; the real-Hugo tests skip. - A remote that moves between fetch and push is not tested directly. The lease itself is tested with a deliberately stale observed tip, and the tests record every push and assert none of them is a plain `--force` or a `+refspec`. - `/api/page` and `/api/page/elements` take the mutation lock because they can stamp uids. Opening a page therefore waits behind a running publish or sync. - The SPA does not detect a sync that changes only a page's front matter. The page document does not carry front matter, so the settings form keeps its loaded values until it is reopened. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(git): draft branch mode — commit and push every save, reconcile onto main, sync on request
All checks were successful
ci / build-and-test (pull_request) Successful in 8m0s
180b9a65bf
When HEAD is on a branch other than main, the harness runs in branch mode:
every mutation is a commit on the draft branch, lease-pushed to the remote
(never a plain --force), with an unsynced state when the push fails. Badges,
status, discard and the pre-flight compare with refs/remotes/<remote>/main;
local main is never written. Publish rebases the draft branch onto the new
main with the publish policy and collapses it when no draft remains. Undo is
gated on the draft tip not being on main.

- gitrepo: Mode/Branch, CommitDraft, SyncStatus, UndoParent, Sync, Reconcile;
  generalized policy rebase reporting resolved paths
- server: one handler-level mutation lock, POST /api/git/sync, git state
  mode/branch/tip/unsynced fields, X-Deckle-Draft-Tip header on /api/
- harness reconcile [--site] [--onto] [--json]; startup sync in serve
- web: reload the open page with a notice when a sync moves the tip, and a
  Not synced chip beside the publish chip
- README and gitrepo doc.go describe branch mode; WIP mode is unchanged

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Author
Owner

[autoland] verdict: pass

This was generated by AI while landing a PR.

Verdict: PASS

Signal relied on: Forgejo Actions ci / build-and-test passed on the head (run 135): frontend npm ci && npm run build && npm test, then go vet ./..., go test ./... (with real Hugo 0.163.3, so the real-Hugo tests the PR notes as skipped locally did run here) and go build. Not re-run.

Conventions: Conventional Commits title. Closes #147 is present. The branch is up to date with main, so there is no conflict. scaffold/site/AGENTS.md is correctly left untouched: it documents only the site-agent commands, and reconcile and the git behaviour are host-side.

Diff review: Checked against #147.

  • Mode selection is by checked-out branch (gitrepo.go Open, openBranchMode). WIP mode paths are unchanged apart from refs/heads/main → mainRef(), which is identical in WIP mode.
  • CommitDraft follows the spec. It does a CAS update-ref and 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.
  • Sync commits first, then fetches. It adopts on equal tree, otherwise does rebase --onto new old with the policy in a temp worktree, then resets and collapses.
  • The branch-mode publish builds on the fetched remote main and pushes only the publish paths. It then rebases the draft, resets, collapses and lease-pushes.
  • Undo is gated by merge-base --is-ancestor against main.
  • reconcile follows the --json contract and exit codes 0/1/2, and refuses a dirty tree.
  • The mutation lock is applied centrally in ServeHTTP and covers every write route. Publish is synchronous, so the lock covers the actual git work. Tests assert the lock at runtime.
  • The SPA tracks the tip of its own requests through the X-Deckle-Draft-Tip header, and syncDraft sets trackDraftTip: false.

Non-blocking observations (below the CONCERNS bar):

  • In branch mode every save pushes synchronously while holding the mutation lock. While the remote hangs instead of failing fast, each save waits for the push to time out. The issue asks for this design, but it is worth knowing for hosted.
  • 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.
  • The syncWithRemote fallback (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.
[autoland] verdict: pass > *This was generated by AI while landing a PR.* **Verdict: PASS** **Signal relied on:** Forgejo Actions `ci / build-and-test` passed on the head (run 135): frontend `npm ci && npm run build && npm test`, then `go vet ./...`, `go test ./...` (with real Hugo 0.163.3, so the real-Hugo tests the PR notes as skipped locally did run here) and `go build`. Not re-run. **Conventions:** Conventional Commits title. `Closes #147` is present. The branch is up to date with `main`, so there is no conflict. `scaffold/site/AGENTS.md` is correctly left untouched: it documents only the site-agent commands, and `reconcile` and the git behaviour are host-side. **Diff review:** Checked against #147. - Mode selection is by checked-out branch (`gitrepo.go` Open, `openBranchMode`). WIP mode paths are unchanged apart from `refs/heads/main` → `mainRef()`, which is identical in WIP mode. - `CommitDraft` follows the spec. It does a CAS `update-ref` and 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. - `Sync` commits first, then fetches. It adopts on equal tree, otherwise does `rebase --onto new old` with the policy in a temp worktree, then resets and collapses. - The branch-mode publish builds on the fetched remote main and pushes only the publish paths. It then rebases the draft, resets, collapses and lease-pushes. - Undo is gated by `merge-base --is-ancestor` against main. - `reconcile` follows the `--json` contract and exit codes 0/1/2, and refuses a dirty tree. - The mutation lock is applied centrally in `ServeHTTP` and covers every write route. Publish is synchronous, so the lock covers the actual git work. Tests assert the lock at runtime. - The SPA tracks the tip of its own requests through the `X-Deckle-Draft-Tip` header, and `syncDraft` sets `trackDraftTip: false`. **Non-blocking observations (below the CONCERNS bar):** - In branch mode every save pushes synchronously while holding the mutation lock. While the remote hangs instead of failing fast, each save waits for the push to time out. The issue asks for this design, but it is worth knowing for hosted. - `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. - The `syncWithRemote` fallback (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.
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Cloonar/deckle!148
No description provided.