Draft branch: commit and push every save, reconcile onto main, sync on request #147

Closed
opened 2026-10-05 12:48:54 +02:00 by dominik.polakovics · 0 comments

This was generated by AI during triage.

Summary

When the harness runs on a checkout whose HEAD is a branch other than main, that branch is the site's draft branch: every mutation becomes a commit on it and is pushed to the remote, so the working tree is always clean and the remote holds every draft. main stays the publish target. Two new entry points let a host keep the draft branch in step with main: harness reconcile rebases the draft branch onto main with the publish conflict policy, and POST /api/git/sync makes a running editor adopt the remote draft branch. A checkout with HEAD on main keeps today's behaviour unchanged.

Motivation: deckle-hosted keeps each site's editor worktree on main with uncommitted drafts. Those drafts exist nowhere else, so nothing may touch the worktree while they exist: a developer's push to main reaches the editor only at a cold start with no drafts, a publish from a worktree behind main moves its commit without writing the skipped files (they then show as modified), and any host-side change to main (deckle-hosted's feature add/remove, #145) needs copies, backups and purges of the worktree. With drafts as commits on a pushed branch, all of that becomes git.

Terms

  • Draft branch: the branch HEAD points at when it is not main. deckle-hosted uses editor.
  • Branch mode: the harness behaviour described here, active when a draft branch is checked out. WIP mode is today's behaviour (HEAD on main, uncommitted drafts, refs/deckle/wip).
  • Publish roots: content/ and assets/media/, as in internal/gitrepo today.
  • The policy: the publish conflict policy that exists in rebaseOntoRemote: the draft side wins inside the publish roots, the main side wins everywhere else; a deletion on the winning side is applied; empty commits are skipped.
  • Observed remote tip: the commit the harness last knew the remote draft branch to be at, kept in refs/remotes/<remote>/<draft>. It is the lease value of every draft-branch push and the base from which "commits added locally since" are computed.
  • Lease push: git push --force-with-lease=<draft>:<observed remote tip>, the compare-and-swap for the draft branch. A plain --force never happens.
  • Mutation lock: one handler-level lock in internal/server that every mutating handler (page, element, media, undo, discard, settings), publish and sync hold for their whole duration. The gitrepo.Repo mutex stays as it is underneath; it serializes git calls but not the file write that precedes a snapshot, so it cannot protect a save from a concurrent sync reset.

Current behavior

  • After every successful mutation (internal/server/git.go snapshotWIP, called from page, element, media, undo and discard handlers) SnapshotWIP commits the whole working tree with a throwaway index to refs/deckle/wip (internal/gitrepo/snapshot.go). The ref is never pushed. Undo restores paths from the snapshot's parent.
  • Badges, StatusForPaths, ChangedPublishFiles, discard and the pre-flight export all compare the working tree with local refs/heads/main (status.go, restore.go, export.go).
  • Publish (gitrepo/publish.go) fetches remote main, reconciles local main with it (fast-forward either way, or a rebase in a temporary worktree with the policy), builds a commit of exactly the publish's paths on top, pushes tip:refs/heads/main, and only then advances refs/heads/main, refs/remotes/origin/main and (only when HEAD is on main) the real index entries of the published paths.
  • gitrepo.Open derives its capability state from local refs/heads/main; the harness never fetches or resets its checkout outside publish, and runs no sync at startup. The branch name main is fixed (gitrepo/doc.go).
  • /api/git/state reports state, reason, lastSnapshotError, undoAvailable. The SPA refetches it only after its own mutations; the page tree and badges are polled (web/src/stores/pageTree.ts) and both bodies are byte-compared so an unchanged response writes no signal.
  • No handler-level mutation lock exists; v1 relies on being single-user.
  • remove --purge and add never consult git; committing is the owner's business.

Desired behavior

Mode selection

gitrepo.Open records the checked-out branch. HEAD on main, detached, or not a repository: WIP mode, nothing changes. HEAD on any other branch: branch mode. /api/git/state reports the mode and the draft branch name. The mode is fixed for the process; a checkout that changes branch under a running harness is not supported.

In branch mode local refs/heads/main may not exist (deckle-hosted clones with --branch editor) and is never written. The "has main" capability (StateNoMainBranch vs. the higher states) is derived from refs/remotes/<remote>/main after the startup fetch; a stale tracking ref left by a failed fetch is still a usable base.

Saving in branch mode

After every mutation that calls snapshotWIP today, the harness instead:

  1. Builds the tree exactly as SnapshotWIP does (throwaway index, add -A, write-tree, .gitignore respected). If the tree equals the draft branch tip's tree, nothing is committed.
  2. Creates the commit with parent = the draft branch tip and installs it with update-ref and the observed old value (compare-and-swap), identity as today.
  3. Refreshes the real index to HEAD without touching files, so git status is empty.
  4. Lease-pushes <draft>:refs/heads/<draft> and, on success, advances the observed remote tip.

The commit never fails because of the push. A failed or rejected push leaves the commit in place, sets an unsynced state (reason text included) that /api/git/state reports and the frontend shows next to the publish chip, and is retried at the next mutation and by the sync endpoint. A lease failure (the remote draft branch moved) is resolved as in the sync routine below, never by overriding the lease.

In StateNoRemote the commit still happens and the state says why the push does not.

Undo in branch mode

Undo restores the page's paths from the draft branch tip's first parent and records the result as a new commit (the same "redo by undo" semantics as today). refs/deckle/wip is not written in branch mode.

Undo is available (undoAvailable, and the 409 gate of POST /api/page/undo) only when the draft tip is not an ancestor of, and not equal to, the remote main tip: one git merge-base --is-ancestor call. The parent of a tip that is on main is a published commit, and restoring from it would create a draft that reverts published content. Consequently a collapse drops the undo of the last published step; that is the price of collapse.

Comparisons in branch mode

Everywhere refs/heads/main is read today (badges, status, changed files, discard, export, draft-link scan), branch mode reads refs/remotes/<remote>/main. It is refreshed by a fetch at startup, before every publish, and by the sync endpoint. Local refs/heads/main may not exist and is never written in branch mode.

Publish in branch mode

As today, except: there is no local main to reconcile, so the base is the freshly fetched remote main; the publish's paths are taken from HEAD's tree (the working tree equals it). After the push to main succeeds, the harness rebases the draft branch onto the new main with the policy (locally, in the checkout's repository, through a temporary worktree as rebaseOntoRemote does), resets the checkout to the result, lease-pushes the draft branch, and collapses (below). The real-index refresh of published paths is replaced by this reset. A push rejection on main leaves everything as before, as today; a lease failure on the draft branch leaves the rebased local branch in place and the state unsynced, to be resolved by the sync routine.

Collapse

Whenever, after a publish, a reconcile or a sync, the draft branch's tree is identical to main's tree, the draft branch is reset to main's commit and lease-pushed. A fully published site has no draft history. Draft history is otherwise never rewritten except by the rebases this issue describes.

harness reconcile

harness reconcile [--site <dir>] [--onto <ref>] --json

Runs in any clone with the draft branch checked out; a host runs it in a clean clone. It rebases the draft branch onto <ref> (default refs/remotes/<remote>/main, falling back to refs/heads/main when the remote ref is absent) with the policy, in a temporary worktree, then resets the checkout to the result and collapses when the trees are identical. It touches no remote: the caller fetches before and lease-pushes after. Output is one JSON object with formatVersion, status (reconciled, unchanged, collapsed), the old and new tips, the main tip used, and the paths resolved by the policy with the side that won, following the --json contract conventions of the feature commands (cmd/harness/features.go: one object, formatVersion, arrays never null, usage errors to stderr). Exit 0 on success, 1 with JSON when the rebase cannot complete (the temporary worktree is removed and the branch untouched), 2 on usage. In WIP mode it exits 1 and says so.

POST /api/git/sync

For a running editor in branch mode. Under the mutation lock:

  1. Fetch main and the draft branch from the remote. The fetched draft tip is the new remote tip; the observed remote tip from before the fetch is the old remote tip.
  2. Reconcile the local draft branch with the new remote tip:
    • If the new remote tip equals the local tip, or its tree equals the local tip's tree: adopt the new remote tip (reset the local branch to it). This is how a host-side collapse or rebase, whose history differs but whose content does not, is accepted without pushing stale history back.
    • Otherwise, rebase only the commits the local branch added since the old remote tip onto the new remote tip (git rebase --onto <new> <old> semantics, with the policy, in a temporary worktree) and lease-push the result. Commit count or "ahead/behind" against the new remote tip is never used: once the remote was rewritten it means nothing.
    • If the push still fails, keep the unsynced state and continue with the local tip.
  3. Reset the checkout (index and files) to the draft branch tip.
  4. Collapse if applicable.

The response says whether the tree changed and gives the new tip. In WIP mode the endpoint answers 409.

Startup in branch mode runs the same routine once before serving. A failed startup fetch does not prevent serving: the harness serves in the unsynced state with the reason, using the stale tracking refs as its base.

Noticing a sync in the browser

The draft tip is sent as a response header on GET /api/tree (the poll the SPA already runs), not in the tree or badges body: both bodies are byte-compared to avoid re-renders, and a tip that changes on every edit would defeat that. The SPA remembers the tip of its own last mutation; when a polled tip differs from it, the open page document is reloaded with a notice and git state is refetched. The tree and badge polls pick up content changes on their own.

What does not change

  • WIP mode, in every detail.
  • init, features, add, remove, sidecar: they write files and never commit.
  • The publish pre-flight (export, media check, Hugo build) and publish feedback polling.
  • The policy itself and the publish roots.

Decisions

  • The branch is the mode switch. No flag or variable can disagree with the actual checkout, and a developer's clone on main is untouched.
  • Every save is a pushed commit. The point is that the remote always holds the drafts; batching would reopen the window this closes.
  • A save never fails because of the push. Browser-side edits must not be lost to a bridge hiccup or a site lock. Unsynced is a visible state with retry.
  • main stays the publish target and the only thing that is ever built. The draft branch is private working state that happens to be in git.
  • Reconcile lives in the harness. The conflict policy exists once, in internal/gitrepo; a host calls the command rather than reimplementing it.
  • Every draft-branch push is a lease push, never a plain force. Publish, reconcile and collapse all rewrite the draft branch, so no push of it is a fast-forward; --force-with-lease on the observed remote tip is the compare-and-swap. A lease failure is resolved by fetch and rebase onto the remote draft branch, never by overriding. (Hosted's push after harness reconcile uses the same lease; its branch guard refuses by credential kind, so forced updates from the instance token and from hosted pass.)
  • Sync reconciles by content and by "commits since the observed tip", not by ancestry against the new remote tip. The remote draft branch is legitimately rewritten by hosts; tree equality means "adopt", and only the commits added locally since the old remote tip are replayed.
  • One handler-level mutation lock. Every mutating handler, publish and sync hold it; the gitrepo mutex alone cannot protect a page write that precedes its snapshot from a sync reset.
  • Undo is gated by ancestry against remote main. A tip on main has a published parent; offering undo there would create a reverting draft. Collapse therefore drops the undo of the last published step.
  • The tip travels in a response header of the tree poll. The tree and badges bodies are byte-compared; a per-edit tip in either body would force a re-render on every poll.
  • No local main in branch mode. Hosted clones with --branch editor; the "has main" capability comes from the remote-tracking ref after the startup fetch, and a failed fetch degrades to serving unsynced on the stale ref.
  • Draft history is rewritten only by the rebases and collapses described here, never otherwise. Undo keeps working across publishes; a fully published site carries no history of its drafts.
  • The sync endpoint resets, it does not merge. The checkout is always clean in branch mode, so a reset between mutations loses nothing; whatever was ahead is pushed first.
  • Hugo's draft: true is not part of this. A public preview of unpublished pages would be a pipeline feature on top of the draft branch, later.
  • One issue. The sync routine, publish's draft rebase and reconcile share the lease, observed-tip and rebase plumbing, and the hosted consumer needs all of it together.

Key interfaces

For deckle-hosted (the consumer):

  • Clone with --branch editor in the editor worktree; the harness does the rest.
  • After any change to main: clean clone with editor checked out, harness reconcile --json, lease-push editor (retry from a fresh clone when the lease fails), then POST /api/git/sync on a running instance.
  • To purge drafts of a removed feature: in that same clone, after reconcile, harness remove <feature> --purge --json, commit on editor, lease-push.
  • /api/git/state fields to read: mode, branch, tip, unsynced, reason (existing fields stay).
  • Tree poll response header carrying the tip.

Acceptance criteria

  • With HEAD on main, every existing test passes unchanged and refs/deckle/wip behaves as today.
  • With HEAD on editor: after a page save, element edit, media upload, rename, move, reorder, delete, undo and discard, git status --porcelain is empty, HEAD advanced by exactly one commit whose tree is the working tree, and the remote's editor equals HEAD.
  • A mutation that changes nothing creates no commit.
  • With the remote unreachable, a save succeeds, /api/git/state reports unsynced with a reason, the next save pushes both commits, and the state clears.
  • With the remote editor moved by someone else, the next save or sync rebases the commits added since the observed remote tip onto it with the policy and lease-pushes; no push of the draft branch is ever a plain --force, and a lease failure is never overridden.
  • With the remote editor rewritten to a different history whose tree equals the local tree (a host collapse), sync adopts the remote tip and pushes nothing.
  • Badges, status, discard and the pre-flight compare with origin/main in branch mode; a main that moved on the remote is seen after sync or publish without any local main branch existing (clone made with --branch editor).
  • Publish from editor: main receives exactly the publish's paths; afterwards the published page shows published, other drafts are unchanged, HEAD is the rebased editor, the remote editor equals HEAD, and when no draft remains editor equals main.
  • Undo: available while the draft tip is neither equal to nor an ancestor of the remote main tip; unavailable (and POST /api/page/undo answers 409) after a collapse and after a developer push to main that leaves the collapsed tip behind.
  • A save that runs concurrently with a sync is not lost: the handler-level lock serializes them.
  • harness reconcile --json in a clone where main changed outside the publish roots (a layout) and editor has drafts: the drafts are kept, the layout is main's, the output lists nothing resolved by policy. Where both sides changed one file inside content/: the draft wins and the path is reported. Where both sides changed hugo.toml: main wins and the path is reported.
  • harness reconcile on an unchanged main reports unchanged; on an editor whose tree equals main's after the rebase reports collapsed and editor equals main. In WIP mode it exits 1 with JSON saying so; a usage error exits 2 with no JSON.
  • POST /api/git/sync on a running editor after the remote editor moved: the tree matches the new tip, an unrelated unsaved browser edit saved afterwards lands on top of it, and the open page is reloaded with a notice when it changed. In WIP mode the endpoint answers 409.
  • Startup in branch mode with local commits ahead of the remote pushes them before serving; startup with an unreachable remote serves in the unsynced state.
  • GET /api/tree carries the draft tip in a response header in branch mode, and the tree body is byte-identical across polls when the tree did not change.
  • remove --purge on an editor checkout followed by the owner's commit leaves git status empty; the command itself still never runs git.
  • The README's "Git backbone" section describes branch mode, the sync endpoint, reconcile, collapse, the lease push and the unsynced state, and states that WIP mode is unchanged. internal/gitrepo/doc.go documents the same.
  • scaffold/site/AGENTS.md is updated if any harness command or flag it documents changes (per CLAUDE.md); otherwise untouched.

How to verify

  • go test ./... and go vet ./...; new internal/gitrepo tests use the hermetic helpers in helpers_test.go (local bare remote, fixed identity) with a checkout made by clone --branch editor.
  • Frontend: npm test in web/ for the tip-header reload and the unsynced indicator.
  • Manual: a site cloned with --branch editor against a local bare repo; edit, inspect git log editor and the bare repo's editor; push a layout change to the bare repo's main, run harness reconcile --json in a second clone, lease-push, POST /api/git/sync, and watch the running editor adopt it.

Unverified assumptions

  • git rebase in the temporary worktree with the policy behaves the same when the base is a draft branch tip rather than main (rebaseOntoRemote was only ever exercised main-onto-main).
  • --force-with-lease=<ref>:<sha> is honoured by the hosted git bridge and by Forgejo's smart HTTP the same way as by a local bare repository; the hosted-side guard is out of scope here but the harness must not depend on anything beyond standard git push semantics.
  • The SPA's own-mutation tip bookkeeping covers every mutation path (including media and settings), so a tip that changed only through the client's own save never triggers a reload.

Out of scope

  • Any change to WIP mode.
  • Hosted-side work (--branch editor clones, the reconcile call, the push guard, the purge flow): #145 and its follow-ups in deckle-hosted.
  • A public preview of unpublished pages (Hugo draft: true).
  • Making the branch name main configurable.
  • Multi-user conflict handling beyond the policy.
> *This was generated by AI during triage.* ## Summary When the harness runs on a checkout whose HEAD is a branch other than `main`, that branch is the site's **draft branch**: every mutation becomes a commit on it and is pushed to the remote, so the working tree is always clean and the remote holds every draft. `main` stays the publish target. Two new entry points let a host keep the draft branch in step with `main`: `harness reconcile` rebases the draft branch onto `main` with the publish conflict policy, and `POST /api/git/sync` makes a running editor adopt the remote draft branch. A checkout with HEAD on `main` keeps today's behaviour unchanged. Motivation: deckle-hosted keeps each site's editor worktree on `main` with uncommitted drafts. Those drafts exist nowhere else, so nothing may touch the worktree while they exist: a developer's push to `main` reaches the editor only at a cold start with no drafts, a publish from a worktree behind `main` moves its commit without writing the skipped files (they then show as modified), and any host-side change to `main` (deckle-hosted's feature add/remove, #145) needs copies, backups and purges of the worktree. With drafts as commits on a pushed branch, all of that becomes git. ## Terms - **Draft branch**: the branch HEAD points at when it is not `main`. deckle-hosted uses `editor`. - **Branch mode**: the harness behaviour described here, active when a draft branch is checked out. **WIP mode** is today's behaviour (HEAD on `main`, uncommitted drafts, `refs/deckle/wip`). - **Publish roots**: `content/` and `assets/media/`, as in `internal/gitrepo` today. - **The policy**: the publish conflict policy that exists in `rebaseOntoRemote`: the draft side wins inside the publish roots, the `main` side wins everywhere else; a deletion on the winning side is applied; empty commits are skipped. - **Observed remote tip**: the commit the harness last knew the remote draft branch to be at, kept in `refs/remotes/<remote>/<draft>`. It is the lease value of every draft-branch push and the base from which "commits added locally since" are computed. - **Lease push**: `git push --force-with-lease=<draft>:<observed remote tip>`, the compare-and-swap for the draft branch. A plain `--force` never happens. - **Mutation lock**: one handler-level lock in `internal/server` that every mutating handler (page, element, media, undo, discard, settings), publish and sync hold for their whole duration. The `gitrepo.Repo` mutex stays as it is underneath; it serializes git calls but not the file write that precedes a snapshot, so it cannot protect a save from a concurrent sync reset. ## Current behavior - After every successful mutation (`internal/server/git.go` `snapshotWIP`, called from page, element, media, undo and discard handlers) `SnapshotWIP` commits the whole working tree with a throwaway index to `refs/deckle/wip` (`internal/gitrepo/snapshot.go`). The ref is never pushed. Undo restores paths from the snapshot's parent. - Badges, `StatusForPaths`, `ChangedPublishFiles`, discard and the pre-flight export all compare the working tree with local `refs/heads/main` (`status.go`, `restore.go`, `export.go`). - Publish (`gitrepo/publish.go`) fetches remote `main`, reconciles local `main` with it (fast-forward either way, or a rebase in a temporary worktree with the policy), builds a commit of exactly the publish's paths on top, pushes `tip:refs/heads/main`, and only then advances `refs/heads/main`, `refs/remotes/origin/main` and (only when HEAD is on `main`) the real index entries of the published paths. - `gitrepo.Open` derives its capability state from local `refs/heads/main`; the harness never fetches or resets its checkout outside publish, and runs no sync at startup. The branch name `main` is fixed (`gitrepo/doc.go`). - `/api/git/state` reports `state`, `reason`, `lastSnapshotError`, `undoAvailable`. The SPA refetches it only after its own mutations; the page tree and badges are polled (`web/src/stores/pageTree.ts`) and both bodies are byte-compared so an unchanged response writes no signal. - No handler-level mutation lock exists; v1 relies on being single-user. - `remove --purge` and `add` never consult git; committing is the owner's business. ## Desired behavior ### Mode selection `gitrepo.Open` records the checked-out branch. HEAD on `main`, detached, or not a repository: WIP mode, nothing changes. HEAD on any other branch: branch mode. `/api/git/state` reports the mode and the draft branch name. The mode is fixed for the process; a checkout that changes branch under a running harness is not supported. In branch mode local `refs/heads/main` may not exist (deckle-hosted clones with `--branch editor`) and is never written. The "has main" capability (`StateNoMainBranch` vs. the higher states) is derived from `refs/remotes/<remote>/main` after the startup fetch; a stale tracking ref left by a failed fetch is still a usable base. ### Saving in branch mode After every mutation that calls `snapshotWIP` today, the harness instead: 1. Builds the tree exactly as `SnapshotWIP` does (throwaway index, `add -A`, `write-tree`, `.gitignore` respected). If the tree equals the draft branch tip's tree, nothing is committed. 2. Creates the commit with parent = the draft branch tip and installs it with `update-ref` and the observed old value (compare-and-swap), identity as today. 3. Refreshes the real index to HEAD without touching files, so `git status` is empty. 4. Lease-pushes `<draft>:refs/heads/<draft>` and, on success, advances the observed remote tip. The commit never fails because of the push. A failed or rejected push leaves the commit in place, sets an **unsynced** state (reason text included) that `/api/git/state` reports and the frontend shows next to the publish chip, and is retried at the next mutation and by the sync endpoint. A lease failure (the remote draft branch moved) is resolved as in the sync routine below, never by overriding the lease. In `StateNoRemote` the commit still happens and the state says why the push does not. ### Undo in branch mode Undo restores the page's paths from the draft branch tip's first parent and records the result as a new commit (the same "redo by undo" semantics as today). `refs/deckle/wip` is not written in branch mode. Undo is available (`undoAvailable`, and the 409 gate of `POST /api/page/undo`) only when the draft tip is **not an ancestor of, and not equal to**, the remote `main` tip: one `git merge-base --is-ancestor` call. The parent of a tip that is on `main` is a published commit, and restoring from it would create a draft that reverts published content. Consequently a collapse drops the undo of the last published step; that is the price of collapse. ### Comparisons in branch mode Everywhere `refs/heads/main` is read today (badges, status, changed files, discard, export, draft-link scan), branch mode reads `refs/remotes/<remote>/main`. It is refreshed by a fetch at startup, before every publish, and by the sync endpoint. Local `refs/heads/main` may not exist and is never written in branch mode. ### Publish in branch mode As today, except: there is no local `main` to reconcile, so the base is the freshly fetched remote `main`; the publish's paths are taken from HEAD's tree (the working tree equals it). After the push to `main` succeeds, the harness rebases the draft branch onto the new `main` with the policy (locally, in the checkout's repository, through a temporary worktree as `rebaseOntoRemote` does), resets the checkout to the result, lease-pushes the draft branch, and collapses (below). The real-index refresh of published paths is replaced by this reset. A push rejection on `main` leaves everything as before, as today; a lease failure on the draft branch leaves the rebased local branch in place and the state unsynced, to be resolved by the sync routine. ### Collapse Whenever, after a publish, a reconcile or a sync, the draft branch's tree is identical to `main`'s tree, the draft branch is reset to `main`'s commit and lease-pushed. A fully published site has no draft history. Draft history is otherwise never rewritten except by the rebases this issue describes. ### `harness reconcile` ``` harness reconcile [--site <dir>] [--onto <ref>] --json ``` Runs in any clone with the draft branch checked out; a host runs it in a clean clone. It rebases the draft branch onto `<ref>` (default `refs/remotes/<remote>/main`, falling back to `refs/heads/main` when the remote ref is absent) with the policy, in a temporary worktree, then resets the checkout to the result and collapses when the trees are identical. It touches no remote: the caller fetches before and lease-pushes after. Output is one JSON object with `formatVersion`, `status` (`reconciled`, `unchanged`, `collapsed`), the old and new tips, the `main` tip used, and the paths resolved by the policy with the side that won, following the `--json` contract conventions of the feature commands (`cmd/harness/features.go`: one object, `formatVersion`, arrays never null, usage errors to stderr). Exit `0` on success, `1` with JSON when the rebase cannot complete (the temporary worktree is removed and the branch untouched), `2` on usage. In WIP mode it exits `1` and says so. ### `POST /api/git/sync` For a running editor in branch mode. Under the mutation lock: 1. Fetch `main` and the draft branch from the remote. The fetched draft tip is the **new remote tip**; the observed remote tip from before the fetch is the **old remote tip**. 2. Reconcile the local draft branch with the new remote tip: - If the new remote tip equals the local tip, or its tree equals the local tip's tree: adopt the new remote tip (reset the local branch to it). This is how a host-side collapse or rebase, whose history differs but whose content does not, is accepted without pushing stale history back. - Otherwise, rebase only the commits the local branch added since the old remote tip onto the new remote tip (`git rebase --onto <new> <old>` semantics, with the policy, in a temporary worktree) and lease-push the result. Commit count or "ahead/behind" against the new remote tip is never used: once the remote was rewritten it means nothing. - If the push still fails, keep the unsynced state and continue with the local tip. 3. Reset the checkout (index and files) to the draft branch tip. 4. Collapse if applicable. The response says whether the tree changed and gives the new tip. In WIP mode the endpoint answers 409. Startup in branch mode runs the same routine once before serving. A failed startup fetch does not prevent serving: the harness serves in the unsynced state with the reason, using the stale tracking refs as its base. ### Noticing a sync in the browser The draft tip is sent as a response header on `GET /api/tree` (the poll the SPA already runs), not in the tree or badges body: both bodies are byte-compared to avoid re-renders, and a tip that changes on every edit would defeat that. The SPA remembers the tip of its own last mutation; when a polled tip differs from it, the open page document is reloaded with a notice and git state is refetched. The tree and badge polls pick up content changes on their own. ### What does not change - WIP mode, in every detail. - `init`, `features`, `add`, `remove`, `sidecar`: they write files and never commit. - The publish pre-flight (export, media check, Hugo build) and publish feedback polling. - The policy itself and the publish roots. ## Decisions - **The branch is the mode switch.** No flag or variable can disagree with the actual checkout, and a developer's clone on `main` is untouched. - **Every save is a pushed commit.** The point is that the remote always holds the drafts; batching would reopen the window this closes. - **A save never fails because of the push.** Browser-side edits must not be lost to a bridge hiccup or a site lock. Unsynced is a visible state with retry. - **`main` stays the publish target and the only thing that is ever built.** The draft branch is private working state that happens to be in git. - **Reconcile lives in the harness.** The conflict policy exists once, in `internal/gitrepo`; a host calls the command rather than reimplementing it. - **Every draft-branch push is a lease push, never a plain force.** Publish, reconcile and collapse all rewrite the draft branch, so no push of it is a fast-forward; `--force-with-lease` on the observed remote tip is the compare-and-swap. A lease failure is resolved by fetch and rebase onto the remote draft branch, never by overriding. (Hosted's push after `harness reconcile` uses the same lease; its branch guard refuses by credential kind, so forced updates from the instance token and from hosted pass.) - **Sync reconciles by content and by "commits since the observed tip", not by ancestry against the new remote tip.** The remote draft branch is legitimately rewritten by hosts; tree equality means "adopt", and only the commits added locally since the old remote tip are replayed. - **One handler-level mutation lock.** Every mutating handler, publish and sync hold it; the gitrepo mutex alone cannot protect a page write that precedes its snapshot from a sync reset. - **Undo is gated by ancestry against remote `main`.** A tip on `main` has a published parent; offering undo there would create a reverting draft. Collapse therefore drops the undo of the last published step. - **The tip travels in a response header of the tree poll.** The tree and badges bodies are byte-compared; a per-edit tip in either body would force a re-render on every poll. - **No local `main` in branch mode.** Hosted clones with `--branch editor`; the "has main" capability comes from the remote-tracking ref after the startup fetch, and a failed fetch degrades to serving unsynced on the stale ref. - **Draft history is rewritten only by the rebases and collapses described here, never otherwise.** Undo keeps working across publishes; a fully published site carries no history of its drafts. - **The sync endpoint resets, it does not merge.** The checkout is always clean in branch mode, so a reset between mutations loses nothing; whatever was ahead is pushed first. - **Hugo's `draft: true` is not part of this.** A public preview of unpublished pages would be a pipeline feature on top of the draft branch, later. - **One issue.** The sync routine, publish's draft rebase and reconcile share the lease, observed-tip and rebase plumbing, and the hosted consumer needs all of it together. ## Key interfaces For deckle-hosted (the consumer): - Clone with `--branch editor` in the editor worktree; the harness does the rest. - After any change to `main`: clean clone with `editor` checked out, `harness reconcile --json`, lease-push `editor` (retry from a fresh clone when the lease fails), then `POST /api/git/sync` on a running instance. - To purge drafts of a removed feature: in that same clone, after reconcile, `harness remove <feature> --purge --json`, commit on `editor`, lease-push. - `/api/git/state` fields to read: mode, branch, tip, unsynced, reason (existing fields stay). - Tree poll response header carrying the tip. ## Acceptance criteria - [ ] With HEAD on `main`, every existing test passes unchanged and `refs/deckle/wip` behaves as today. - [ ] With HEAD on `editor`: after a page save, element edit, media upload, rename, move, reorder, delete, undo and discard, `git status --porcelain` is empty, HEAD advanced by exactly one commit whose tree is the working tree, and the remote's `editor` equals HEAD. - [ ] A mutation that changes nothing creates no commit. - [ ] With the remote unreachable, a save succeeds, `/api/git/state` reports unsynced with a reason, the next save pushes both commits, and the state clears. - [ ] With the remote `editor` moved by someone else, the next save or sync rebases the commits added since the observed remote tip onto it with the policy and lease-pushes; no push of the draft branch is ever a plain `--force`, and a lease failure is never overridden. - [ ] With the remote `editor` rewritten to a different history whose tree equals the local tree (a host collapse), sync adopts the remote tip and pushes nothing. - [ ] Badges, status, discard and the pre-flight compare with `origin/main` in branch mode; a `main` that moved on the remote is seen after sync or publish without any local `main` branch existing (clone made with `--branch editor`). - [ ] Publish from `editor`: `main` receives exactly the publish's paths; afterwards the published page shows `published`, other drafts are unchanged, HEAD is the rebased `editor`, the remote `editor` equals HEAD, and when no draft remains `editor` equals `main`. - [ ] Undo: available while the draft tip is neither equal to nor an ancestor of the remote `main` tip; unavailable (and `POST /api/page/undo` answers 409) after a collapse and after a developer push to `main` that leaves the collapsed tip behind. - [ ] A save that runs concurrently with a sync is not lost: the handler-level lock serializes them. - [ ] `harness reconcile --json` in a clone where `main` changed outside the publish roots (a layout) and `editor` has drafts: the drafts are kept, the layout is `main`'s, the output lists nothing resolved by policy. Where both sides changed one file inside `content/`: the draft wins and the path is reported. Where both sides changed `hugo.toml`: `main` wins and the path is reported. - [ ] `harness reconcile` on an unchanged `main` reports `unchanged`; on an `editor` whose tree equals `main`'s after the rebase reports `collapsed` and `editor` equals `main`. In WIP mode it exits `1` with JSON saying so; a usage error exits `2` with no JSON. - [ ] `POST /api/git/sync` on a running editor after the remote `editor` moved: the tree matches the new tip, an unrelated unsaved browser edit saved afterwards lands on top of it, and the open page is reloaded with a notice when it changed. In WIP mode the endpoint answers 409. - [ ] Startup in branch mode with local commits ahead of the remote pushes them before serving; startup with an unreachable remote serves in the unsynced state. - [ ] `GET /api/tree` carries the draft tip in a response header in branch mode, and the tree body is byte-identical across polls when the tree did not change. - [ ] `remove --purge` on an `editor` checkout followed by the owner's commit leaves `git status` empty; the command itself still never runs git. - [ ] The README's "Git backbone" section describes branch mode, the sync endpoint, `reconcile`, collapse, the lease push and the unsynced state, and states that WIP mode is unchanged. `internal/gitrepo/doc.go` documents the same. - [ ] `scaffold/site/AGENTS.md` is updated if any harness command or flag it documents changes (per CLAUDE.md); otherwise untouched. ## How to verify - `go test ./...` and `go vet ./...`; new `internal/gitrepo` tests use the hermetic helpers in `helpers_test.go` (local bare remote, fixed identity) with a checkout made by `clone --branch editor`. - Frontend: `npm test` in `web/` for the tip-header reload and the unsynced indicator. - Manual: a site cloned with `--branch editor` against a local bare repo; edit, inspect `git log editor` and the bare repo's `editor`; push a layout change to the bare repo's `main`, run `harness reconcile --json` in a second clone, lease-push, `POST /api/git/sync`, and watch the running editor adopt it. ## Unverified assumptions - `git rebase` in the temporary worktree with the policy behaves the same when the base is a draft branch tip rather than `main` (`rebaseOntoRemote` was only ever exercised main-onto-main). - `--force-with-lease=<ref>:<sha>` is honoured by the hosted git bridge and by Forgejo's smart HTTP the same way as by a local bare repository; the hosted-side guard is out of scope here but the harness must not depend on anything beyond standard git push semantics. - The SPA's own-mutation tip bookkeeping covers every mutation path (including media and settings), so a tip that changed only through the client's own save never triggers a reload. ## Out of scope - Any change to WIP mode. - Hosted-side work (`--branch editor` clones, the reconcile call, the push guard, the purge flow): #145 and its follow-ups in deckle-hosted. - A public preview of unpublished pages (Hugo `draft: true`). - Making the branch name `main` configurable. - Multi-user conflict handling beyond the policy.
Sign in to join this conversation.
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#147
No description provided.