Draft branch: commit and push every save, reconcile onto main, sync on request #147
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#147
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
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?
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.mainstays the publish target. Two new entry points let a host keep the draft branch in step withmain:harness reconcilerebases the draft branch ontomainwith the publish conflict policy, andPOST /api/git/syncmakes a running editor adopt the remote draft branch. A checkout with HEAD onmainkeeps today's behaviour unchanged.Motivation: deckle-hosted keeps each site's editor worktree on
mainwith uncommitted drafts. Those drafts exist nowhere else, so nothing may touch the worktree while they exist: a developer's push tomainreaches the editor only at a cold start with no drafts, a publish from a worktree behindmainmoves its commit without writing the skipped files (they then show as modified), and any host-side change tomain(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
main. deckle-hosted useseditor.main, uncommitted drafts,refs/deckle/wip).content/andassets/media/, as ininternal/gitrepotoday.rebaseOntoRemote: the draft side wins inside the publish roots, themainside wins everywhere else; a deletion on the winning side is applied; empty commits are skipped.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.git push --force-with-lease=<draft>:<observed remote tip>, the compare-and-swap for the draft branch. A plain--forcenever happens.internal/serverthat every mutating handler (page, element, media, undo, discard, settings), publish and sync hold for their whole duration. Thegitrepo.Repomutex 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
internal/server/git.gosnapshotWIP, called from page, element, media, undo and discard handlers)SnapshotWIPcommits the whole working tree with a throwaway index torefs/deckle/wip(internal/gitrepo/snapshot.go). The ref is never pushed. Undo restores paths from the snapshot's parent.StatusForPaths,ChangedPublishFiles, discard and the pre-flight export all compare the working tree with localrefs/heads/main(status.go,restore.go,export.go).gitrepo/publish.go) fetches remotemain, reconciles localmainwith 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, pushestip:refs/heads/main, and only then advancesrefs/heads/main,refs/remotes/origin/mainand (only when HEAD is onmain) the real index entries of the published paths.gitrepo.Openderives its capability state from localrefs/heads/main; the harness never fetches or resets its checkout outside publish, and runs no sync at startup. The branch namemainis fixed (gitrepo/doc.go)./api/git/statereportsstate,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.remove --purgeandaddnever consult git; committing is the owner's business.Desired behavior
Mode selection
gitrepo.Openrecords the checked-out branch. HEAD onmain, detached, or not a repository: WIP mode, nothing changes. HEAD on any other branch: branch mode./api/git/statereports 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/mainmay not exist (deckle-hosted clones with--branch editor) and is never written. The "has main" capability (StateNoMainBranchvs. the higher states) is derived fromrefs/remotes/<remote>/mainafter 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
snapshotWIPtoday, the harness instead:SnapshotWIPdoes (throwaway index,add -A,write-tree,.gitignorerespected). If the tree equals the draft branch tip's tree, nothing is committed.update-refand the observed old value (compare-and-swap), identity as today.git statusis empty.<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/statereports 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
StateNoRemotethe 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/wipis not written in branch mode.Undo is available (
undoAvailable, and the 409 gate ofPOST /api/page/undo) only when the draft tip is not an ancestor of, and not equal to, the remotemaintip: onegit merge-base --is-ancestorcall. The parent of a tip that is onmainis 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/mainis read today (badges, status, changed files, discard, export, draft-link scan), branch mode readsrefs/remotes/<remote>/main. It is refreshed by a fetch at startup, before every publish, and by the sync endpoint. Localrefs/heads/mainmay not exist and is never written in branch mode.Publish in branch mode
As today, except: there is no local
mainto reconcile, so the base is the freshly fetched remotemain; the publish's paths are taken from HEAD's tree (the working tree equals it). After the push tomainsucceeds, the harness rebases the draft branch onto the newmainwith the policy (locally, in the checkout's repository, through a temporary worktree asrebaseOntoRemotedoes), 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 onmainleaves 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 tomain'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 reconcileRuns in any clone with the draft branch checked out; a host runs it in a clean clone. It rebases the draft branch onto
<ref>(defaultrefs/remotes/<remote>/main, falling back torefs/heads/mainwhen 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 withformatVersion,status(reconciled,unchanged,collapsed), the old and new tips, themaintip used, and the paths resolved by the policy with the side that won, following the--jsoncontract conventions of the feature commands (cmd/harness/features.go: one object,formatVersion, arrays never null, usage errors to stderr). Exit0on success,1with JSON when the rebase cannot complete (the temporary worktree is removed and the branch untouched),2on usage. In WIP mode it exits1and says so.POST /api/git/syncFor a running editor in branch mode. Under the mutation lock:
mainand 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.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.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
init,features,add,remove,sidecar: they write files and never commit.Decisions
mainis untouched.mainstays the publish target and the only thing that is ever built. The draft branch is private working state that happens to be in git.internal/gitrepo; a host calls the command rather than reimplementing it.--force-with-leaseon 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 afterharness reconcileuses the same lease; its branch guard refuses by credential kind, so forced updates from the instance token and from hosted pass.)main. A tip onmainhas a published parent; offering undo there would create a reverting draft. Collapse therefore drops the undo of the last published step.mainin 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: trueis not part of this. A public preview of unpublished pages would be a pipeline feature on top of the draft branch, later.Key interfaces
For deckle-hosted (the consumer):
--branch editorin the editor worktree; the harness does the rest.main: clean clone witheditorchecked out,harness reconcile --json, lease-pusheditor(retry from a fresh clone when the lease fails), thenPOST /api/git/syncon a running instance.harness remove <feature> --purge --json, commit oneditor, lease-push./api/git/statefields to read: mode, branch, tip, unsynced, reason (existing fields stay).Acceptance criteria
main, every existing test passes unchanged andrefs/deckle/wipbehaves as today.editor: after a page save, element edit, media upload, rename, move, reorder, delete, undo and discard,git status --porcelainis empty, HEAD advanced by exactly one commit whose tree is the working tree, and the remote'seditorequals HEAD./api/git/statereports unsynced with a reason, the next save pushes both commits, and the state clears.editormoved 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.editorrewritten to a different history whose tree equals the local tree (a host collapse), sync adopts the remote tip and pushes nothing.origin/mainin branch mode; amainthat moved on the remote is seen after sync or publish without any localmainbranch existing (clone made with--branch editor).editor:mainreceives exactly the publish's paths; afterwards the published page showspublished, other drafts are unchanged, HEAD is the rebasededitor, the remoteeditorequals HEAD, and when no draft remainseditorequalsmain.maintip; unavailable (andPOST /api/page/undoanswers 409) after a collapse and after a developer push tomainthat leaves the collapsed tip behind.harness reconcile --jsonin a clone wheremainchanged outside the publish roots (a layout) andeditorhas drafts: the drafts are kept, the layout ismain's, the output lists nothing resolved by policy. Where both sides changed one file insidecontent/: the draft wins and the path is reported. Where both sides changedhugo.toml:mainwins and the path is reported.harness reconcileon an unchangedmainreportsunchanged; on aneditorwhose tree equalsmain's after the rebase reportscollapsedandeditorequalsmain. In WIP mode it exits1with JSON saying so; a usage error exits2with no JSON.POST /api/git/syncon a running editor after the remoteeditormoved: 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.GET /api/treecarries 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 --purgeon aneditorcheckout followed by the owner's commit leavesgit statusempty; the command itself still never runs git.reconcile, collapse, the lease push and the unsynced state, and states that WIP mode is unchanged.internal/gitrepo/doc.godocuments the same.scaffold/site/AGENTS.mdis updated if any harness command or flag it documents changes (per CLAUDE.md); otherwise untouched.How to verify
go test ./...andgo vet ./...; newinternal/gitrepotests use the hermetic helpers inhelpers_test.go(local bare remote, fixed identity) with a checkout made byclone --branch editor.npm testinweb/for the tip-header reload and the unsynced indicator.--branch editoragainst a local bare repo; edit, inspectgit log editorand the bare repo'seditor; push a layout change to the bare repo'smain, runharness reconcile --jsonin a second clone, lease-push,POST /api/git/sync, and watch the running editor adopt it.Unverified assumptions
git rebasein the temporary worktree with the policy behaves the same when the base is a draft branch tip rather thanmain(rebaseOntoRemotewas 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.Out of scope
--branch editorclones, the reconcile call, the push guard, the purge flow): #145 and its follow-ups in deckle-hosted.draft: true).mainconfigurable.