Files
lanework/DESIGN/06-history-undo.md
T
rzen a131399c02 Minor doc fixes across the corpus (ten review findings)
- 10 cites 11 for Add Attachment and the lane-width menu items
- 04 uses ⌘/⇧ symbols for click modifiers; card moves scoped to the
  ⌥⌘ chord; ../WISHLIST.md path fixed
- 03 says stepper (plus the menu pair) in the editing-surfaces table
- 06 says one commit per debounce window
- 03/10 Reduce Motion lists aligned at five items; rubber-band
  disambiguated as the lane-resize feedback
- 11 requires the trash shown for Empty Trash
- 02 rewords and-peers

Claude-Session: https://claude.ai/code/session_01HJ7PhFNmQ19bvy9RMD6GSb
2026-07-26 16:30:58 -04:00

26 KiB

History & Undo

Git is the undo substrate — on boards that have git. Git is opt-in per board (a pivot from the pathfinder, which auto-initialized every board): a board may be created without git, and git can be added later (via the board popover; see 07-sync-collab.md's mode progression). A board without git has no undo/redo (board history, that is — text editors keep their standard typing undo everywhere; see Undo routing below) — consistent with the settled no-undo stance for repo-nested boards; deletes are the exception, recoverable on every board via the tombstone trash (03-board-ui.md). On git-enabled boards, every settled change auto-commits; those mechanics are carried over from the pathfinder with their hard rules intact.

Rules

  • Opt-in init: adding git to a board initializes a local repo at the board root. Bundled libgit2 — no git install required. No silent auto-init, ever.
  • Adoption: a board whose root already contains .git opens in git mode, silently — adoption is not init. The no-silent-auto-init rule forbids creating a repository the user didn't ask for; recognizing one that exists is the opposite of that: the repo's presence is the opt-in (someone ran git init or git clone), and this is the primary way a second machine joins a shared board — clone in a terminal, open in the app (07-sync-collab.md's second entry arrow). All git-mode behavior applies from the first open: auto-commit, undo reseeded from the existing HEAD's first-parent ancestry, remote tracking if a remote is configured.
  • Detection is nearest-.git-wins, checked at every board open: .git at the board root → git mode (adoption above); no .git at the root but one at any ancestor → repo-nested (below); neither → mode none. A board can therefore change mode between opens (e.g. the user ran git init in a terminal) — the app just reflects what it finds. Open-time only, deliberately: a git init under an open mode-none board takes effect at the next open — the running session keeps its mode, and the watcher does not scan for .git appearing (no mid-session mode flips; stated here so it isn't rediscovered as a bug).
  • Abnormal repo states (settled; adoption never assumes a tidy clone): an unborn HEAD (git init, no commits yet) is normal git mode — the first auto-commit creates the root commit on the branch HEAD names, and the undo trail simply starts empty. A detached HEAD, or an in-progress merge/rebase/cherry-pick left by outside-the-app git (MERGE_HEAD, rebase-merge/rebase-apply, CHERRY_PICK_HEAD — pause states that load fine on a clean tree and are otherwise invisible), instead pauses the git surface honestly: auto-commit holds (the auth-pause posture, 07-sync-collab.md — pause, badge, explain, never hammer), Undo/Redo and the branch controls disable, and the popover's git section names the state plainly ("HEAD is detached — commits would belong to no branch"; "a merge is in progress") and says resolving it belongs to the tool that created it. Edits keep landing on disk — files are the board — and commit as one settled batch when the state clears. The app never mutates repo state it didn't create (no auto branch-at-HEAD, no merge --abort); the check runs at open and again before every flush, so finishing the operation in a terminal resumes the pipeline without ceremony. The one exemption is the app's own leftovers (settled): every bracketed operation stamps its intent app-side (per-board registry) before touching the repo, so an interrupted app-run rebase or checkout is recognizable as Lanework's — finding a pause state with a matching stamp, the app aborts its own unfinished operation to restore the pre-operation state and says so via banner ("a branch switch was interrupted — the previous state is restored"), then clears the stamp. Abort discards nothing: fetched commits stay fetched, local commits are restored — the rebase's own no-loss accounting. Without a matching stamp the leftover is outside git's, and the pause-and-defer stance above holds unchanged.
  • Boards nested inside an existing repository are left strictly alone — git cannot be added to them (no nested repo, no commits into the user's repo), so they get no undo (settled; no app-managed undo journal, which would violate self-containment). The board popover's git section must say so honestly: not a hidden "add git" but a short explanation ("this board lives inside a repository; Lanework leaves it to that repository") — the option is absent because it can't apply, and the UI should teach that rather than look broken.
  • Auto-commit: every settled change (debounced past drag/typing churn) commits with a descriptive message ("Move card 'Fix login' to Doing"). Board undo sees Edit sessions, not save ticks (resettled from typing-settle granularity): the body editor's ~700 ms disk saves (05-card-window.md) keep the file crash-safe throughout a session but stay uncommitted — the body commit lands when the session ends, the Edit→Preview flip being the effective Save button (raw-source entry and window close end the session too). The committer stages around open Edit sessions: a board change committing mid-session excludes the session card's folder from staging, so a lane move never sweeps half-typed body text into its commit. Settled-tree events that cannot wait — a pull's flush-before-overwrite — commit the session's on-disk saves as-is (a mechanical exception; 07-sync-collab.md's same-card signpost covers the visible half); branch switch instead gates on explicit save-or-discard (Branch switching below). The cadence constraint below demands batching at least this coarse. Board-window close and app quit flush the pipeline — any pending editor save (05-card-window.md), then the pending auto-commit — before teardown; nothing settled is ever left unsaved or uncommitted by closing.
  • Undo routing is by focus — the platform's first-responder rule, stated here because two undo systems coexist. While a text-editing surface is focused (card title field, body Edit mode, raw source, board inline rename), ⌘Z/⇧⌘Z are that editor's own text undo — standard, transient, session-scoped: leaving the editor (mode flip, focus loss, close) ends the session, and from then on that content's undo story is the git trail. Text undo works on every board — no-git and repo-nested included; "no undo/redo" above means board history, not typing. With focus anywhere else, Edit ▸ Undo/Redo are git undo (and are disabled on boards without it). No fall-through: exhausting a focused editor's stack beeps; it never reaches board history.
  • Flush-before-overwrite: before an app write overwrites on-disk state that differs from the last-loaded snapshot (an uncommitted external change — e.g. an agent's body rewrite racing the card editor's debounced save, 05-card-window.md), the pending auto-commit is flushed so the external version enters history first. "Both versions exist as commits" is thereby a guarantee, not a likelihood. The same flush settles the tree before a pull runs (07-sync-collab.md).
  • Cadence constraint (agreed): the auto-commit cadence must not make the history of a remote-shared board unbearable — one commit per drag is fine for a local undo trail but noisy as a shared log. The debounce/batching design here must serve both consumers; the push/pull side is settled in 07-sync-collab.md (optional push-on-commit, automatic fetch-rebase-push on rejected pushes).
  • Undo never rewrites history. Undo (⌘Z) and redo (⇧⌘Z) restore earlier states as new forward commits — never reset, never force. The whole trail stays inspectable in any git client. The one deliberate rewrite anywhere in the app is pull's rebase of unpushed local commits (07-sync-collab.md); published history is never touched.
  • Undo restore vs open Edit sessions (settled): a restore materializes only the diff between the current tree and the target state, so a card whose open Edit session the diff doesn't touch is simply unaffected — its uncommitted ~700 ms saves and the stage-around rule continue undisturbed, and most undos never meet an editor at all. When the diff does touch a session card, the restore gates on the branch-switch save-or-discard step (Branch switching below — Save All / Discard / Cancel, same machinery, same rationale): silently flushing would commit a tree the user deliberately hasn't saved, a checkout over uncommitted on-disk saves would destroy text no commit protects (the one place "both versions exist as commits" could otherwise fail), and a surviving dirty buffer's next debounced save would write pre-undo text over the restored card — a ⌘Z that visibly doesn't happen. With sessions settled the restore runs on a settled tree. Redo is symmetric. Open raw-source buffers get the branch-switch settle treatment too (Branch switching below).
  • The stack is HEAD's first-parent ancestry, live (settled): foreign commits — watcher-auto-committed agent work and agents' self-commits alike — push onto the in-session undo stack as ordinary steps as they land. The stack re-syncs its top to HEAD before every undo/redo (self-commits move HEAD outside the app's committer; the pre-flight sync is how the stack learns), so ⌘Z always steps back exactly one commit — it can never silently revert twenty minutes of agent work landed since the user's last operation. Any commit arriving from anywhere clears the redo stack (classic behavior; redo also starts empty on the relaunch reseed below). In-session and post-relaunch behavior are thereby one rule — the reseed is the same ancestry walk from scratch.
  • Undo survives relaunch: the undo stack reseeds from HEAD's first-parent ancestry on load; redo starts empty. In-session it behaves as classic dual stacks; after relaunch, past restore commits reappear as ordinary undoable steps. Deliberate: no sidecar state, nothing ever lost. Interaction with pull (07-sync-collab.md): a pull rebases unpushed local commits, so the in-session stack must remap onto the rewritten commits — the pre-rebase hashes are orphaned. A pleasant consequence of the reseed rule: the fetched remote commits sit in HEAD's first-parent ancestry, so after the next relaunch remote work becomes ordinary undoable steps too.
  • Undo is board-local. A cross-board move-out undone at the source resurrects the card even though it lives on in the destination — per-board histories cannot and must not mutate other boards. The resulting same-UUID fork across boards is legitimate (boards are independent identity namespaces); if the two ever meet through a move-in, the import boundary remints the arrival (01-storage-format.md's identity lifecycle).
  • The git surface lives in the board popover (03-board-ui.md): branch/source display, branch switching and creation, and the commit-identity name/email fields (see Interaction with external writers below) — alongside board rename and styling.

Commit messages

The pathfinder's message engine carries over as the model — it is what earns the "semantic" in semantic commit messages, and it stays a pure, testable function:

  • Pure snapshot diff, no write-site tagging. Messages compose at commit time from a structural diff of two board snapshots (last-committed vs. current) — never by intercepting operations. Items match by id across the whole board, so a lane change is distinguishable from delete+add and a cross-lane move reads as a move. Bookkeeping — order changes that preserve sibling sequence (a renumber's rescale — 01-storage-format.md), modified/created — produces no events: a diff touching only those composes nothing. Sequence is what the diff compares, not raw order values: an order change that repositions an item among its siblings composes Reorder, so a foreign writer's single-file reorder still reads as one. A midpoint-exhaustion renumber batches with the insert or move that triggered it, so its commit reads as that event.
  • Vocabulary: Add / Delete / Move / Rename / Edit / Restyle / Resize / Reorder over cards, lanes, and the board, plus Attach / Remove for attachment files ("Move card 'Fix login' to Doing", "Rename lane 'Todo' → 'Doing'"). One commit per debounce window: a single event is the subject (with a detail body where one helps); several events of one kind fold into a plural subject, with shared destinations preserved ("Move 3 cards to Done"); genuinely mixed windows fall back to "Update board" — always with a bulleted body naming every event, so the oneline log stays scannable and the full message stays complete.
  • Implied events don't steal the subject: deleting a lane with five cards reads "Delete lane 'X'" with the card deletions as body bullets — not "Update board".
  • Titles truncate in subjects only (~40 chars, keeping git log --oneline sane); body lines carry full titles. An untitled item reads "(untitled)" — never a bare "" (a pathfinder edge fixed, not carried). Undo/redo restores commit as "Undo: ⟨subject⟩" / "Redo: ⟨subject⟩"; the undo-menu labels are the crossed commit's subject, so labels never nest.

The external gap, closed (the pathfinder weakness this section exists to fix): the composer is origin-agnostic, but external writers routinely touch what the pathfinder's diff never modeled — labels, assignees, due, custom frontmatter keys — so their commits degraded to a generic fallback even though the attribution machinery knew plenty. The rewrite:

  • The diff models the full schema-1 surface — plus the reserved metadata trio, deliberately: label, assignee, and due changes compose ("Relabel card 'X'", "Assign card 'X'", "Set due date on card 'X'") even though 01-storage-format.md reserves those keys out of this version's UI — external writers (pathfinder-era boards, agents) are exactly who touches them, and naming three known keys in a pure diff function costs nothing. A change to any other unmodeled or custom key composes a named generic ("Update card 'X'") — never a board-level shrug when the touched item is identifiable.
  • Foreign commits speak the same vocabulary. Origin lives in the author field (structural attribution below), not in message prose — a foreign move reads "Move card …" exactly like an app-mediated one, and any git client filters by author.
  • The launch catch-up commit composes too: changes found pending at board open diff HEAD's tree against the working tree through the same composer, instead of committing blind.

Branch switching

Switching (or creating-and-switching) a branch from the board popover:

  • Settle the editors first — explicitly, never silently. Branch switch neither silently commits nor silently abandons an open card-body Edit session: if any open card window has one (unsaved keystrokes, or on-disk ~700 ms saves the session hasn't committed — the mid-session state Auto-commit above deliberately leaves uncommitted), the switch presents a save-or-discard step: Save All ends every session with its normal commit (each card's Edit→Preview flip), Discard reverts buffers and uncommitted saves to HEAD, Cancel keeps the current branch and the sessions. Open raw-source buffers are settled by the same step (settled — an unsettled raw buffer is the worse hazard: its Apply later writes the entire pre-switch index.md byte-for-byte onto the new branch's card): Save All applies each raw buffer — and since Apply validates, a buffer that fails validation cancels the whole switch with focus on the offending window, nothing half-switched; Discard exits raw source without writing; Cancel keeps everything. External checkouts the app can't gate are the accepted last-writer-wins case, same as the Edit buffer (05-card-window.md's dirty-buffer rule; on git boards the overwritten version is a commit, one revert away). Silently flushing the commit alone would be wrong twice over: the tree can be clean precisely because a save hasn't landed, and a later debounced save would write old-branch text onto the new branch's card. With sessions settled, the pending auto-commit flushes (flush-before-overwrite above) and checkout runs on a truly settled tree: it cannot fail dirty, and no in-flight work is lost or dragged across branches. (Inline title editors need no step of their own: reaching the popover's branch controls commits them — click-away commits, and board-scoped commands disable while one is focused — 04-interactions.md ▸ Grammar.)
  • The undo/redo stack does not survive a switch. It is discarded and reseeded from the new HEAD's first-parent ancestry — the relaunch rule applied at switch time; redo starts empty. (Replaying a restore commit from the previous branch onto the new one would be wrong.)
  • Everything remote-facing tracks the current branch: ahead/behind, Pull/Push, and push-on-commit all operate against the current branch's upstream. On a branch with no upstream yet, the first push — manual or push-on-commit — creates it on the remote quietly (push -u semantics): creating a remote branch is non-destructive, and quiet is consistent with push-failures-never-nag (07-sync-collab.md). Genuine failures queue with the badge as usual.
  • The switch itself is bracketed (02-architecture.md): watcher suspended, one full reload at the end. If that final reload fails, the board locks read-only until a successful reload — see 02's live-reload resilience; the on-screen snapshot is from the previous branch and must not be edited over the new one.

Interaction with external writers

Agent and hand edits arrive through the watcher like any change and get auto-committed on the same debounce — so agent work is undoable, attributed, and described in the same trail: foreign changes compose through the same message engine as app-mediated ones (Commit messages above — the pathfinder's generic "External edit: 2 cards changed" fallback is gone), with origin carried by the author field. One attribution exception: the app's own agent-guide writes (08-agent-integration.md) are the app's own Writer operations — app-mediated by the echo machinery, carrying the guide's version-marker first line — and committed as "Update agent guide (vN)", not "External edit". Known quirk, not a bug: undoing an "Update agent guide (vN)" commit restores an older guide that the app immediately re-upgrades — a one-bounce no-op undo (restore commit + fresh upgrade commit). Harmless; the guide is app-owned and self-healing by design.

Commit attribution is structural, not just a message convention. The Writer/echo machinery lets the auto-committer classify every observed change as app-mediated (the user acting through the app) or foreign (anything else). User-driven commits carry the user's git identity; foreign changes are committed under the pinned synthetic author Lanework External <[email protected]> — so any git client can filter, log, and blame by origin. The strings are API (users script against them; the .invalid TLD honestly marks a non-routable synthetic identity) — they change with the deliberateness of a schema change.

Where the user's git identity comes from (no git install is assumed, and the sandbox doesn't read ~/.gitconfig — honest limits, not bugs): repo-local .git/config wins when present — standard git semantics, readable in-sandbox because it lives under the board root, and the natural state of adopted/cloned boards. The board popover's git section exposes name/email fields that write that repo-local config — the setting is the file, portable to any git client, per-board by nature (work and personal boards can differ). Absent repo config, the derived default applies: the macOS account's full name plus shortname@hostname — git's own no-config fallback shape, zero ceremony. Commits pushed to a forge under the derived email won't link to a forge account; the popover fields are the fix when that matters. A debounce window containing both kinds is split into two commits, never mixed (flush-before-overwrite already orders them: foreign first, then the user's overwrite). Honest limit: the app distinguishes app-mediated from foreign, not human from agent — a hand edit in a text editor and an agent write look identical unless the writer says otherwise via modified-by (below). Agents wanting precise attribution are encouraged (via the agent guide, 08-agent-integration.md) to commit their own changes; the app follows along.

modified-by refines foreign attribution (the self-reported provenance key — 01-storage-format.md): when every file changed in a foreign debounce window carries the same modified-by: X, that commit is authored as X with the synthetic email <slug>@agents.lanework.invalid (display name verbatim, email local part slugified; the domain marks self-reported identity, distinct from both the user and the generic external author). Any disagreement between stamps, any unstamped changed file, or any true deletion in the window falls back to Lanework External — a deletion leaves no file to stamp. A folder move is not a deletion: items match by id across the whole board (Commit messages above — the same matching that reads a move as a move, not delete+add), so a moved card attributes by its stamp like any changed file. But a bare mv rewrites nothing — the moved index.md still carries whatever the app last wrote (no stamp) and demotes the window under the unstamped-file rule — so the agent guide teaches re-stamping on move (08-agent-integration.md). Same trust level as self-committing — it's what the writer claims, accepted as such; the stale-stamp hand-edit case (01) is the known misattribution edge. Self-committing remains the precise path; the stamp is the lightweight middle.

Two writers, one repository — the designed situation, not an edge case. Self-committing agents mean the auto-committer shares the repo with concurrent git processes, and it must be graceful about it:

  • index.lock contention is never an error. If the auto-committer finds the index locked (an agent's commit in flight), it backs off briefly and retries; if the lock persists, it simply re-debounces — the pending changes are still pending, and the next quiet moment commits them. No banner, no log-worthy failure: a held lock is another writer doing its job. (Genuine commit failures — disk full, repo corruption — are different: files stay safe on disk but history stops advancing; surfaced per 02-architecture.md ▸ Write-failure surfacing, retried on the next debounce.) The same posture covers every app-initiated operation (settled): pull, push, branch switch, and undo restore meeting a held lock wait and retry briefly, silently; contention outlasting the brief retry surfaces as a waiting state in the operation's in-progress banner row ("waiting for another writer's git lock"), retrying on its cadence — never an error dialog, never a hammer — and a wait that persists implausibly long names the lock path (a crashed writer's leftover is the user's to clear; the never-mutate rule's one exemption is the app's own leftovers, Abnormal repo states above). An operation that fails cleanly — disk error, refused checkout; network and auth are 07-sync-collab.md's pause-and-badge story — surfaces as a one-shot banner failure naming the operation and the error, the tree left as it was; failure after the tree changed wholesale is instead 02-architecture.md's failed-final-reload lock.
  • A clean tree is the happy path, not a malfunction. When the debounce fires and the tree has nothing to commit — the agent already committed its own work — the auto-committer no-ops silently. The agent's commit, under the agent's own authorship, is the record; that is precisely what the self-commit recommendation is for.
  • An agent's git add -A can sweep up the user's not-yet-committed app-mediated changes under the agent's authorship, muddying structural attribution for that window. Accepted limit — the app cannot police another process's staging; the agent guide (08-agent-integration.md) tells agents to commit only their own paths, which keeps well-behaved agents honest.

Repository hygiene

  • .gitignore seeded at init, never touched after. Adding git to a board writes a minimal .gitignore (.DS_Store) if none exists; the app never edits an existing one and never manages the file afterward — it's the user's from then on. (Repo-nested boards have no app-managed git, so no app .gitignore either.)
  • Repo growth is accepted. Unbounded history is the price of never-rewrite, and every attachment version lives in the repo forever. The app may run safe libgit2 housekeeping (repacking loose objects) periodically — it rewrites nothing. Content-removing compaction is explicitly out (it would rewrite history); size tooling joins the wishlist if growth ever bites in practice.
  • Deleting never forgets. On a git board, deleting a card removes it from the board but never from history — every version of its content and attachments stays reachable in any git client, and even the future tombstone purge (01-storage-format.md) only cleans the working tree. This is part of the design; users should learn it here, not from a repo browser.

Changes from Kanban

  • Commit granularity resettled to Edit sessions: body commits land at the Edit→Preview flip (the effective Save button), not at typing-settle; the committer stages around open sessions (Rules ▸ Auto-commit).
  • Branch switch gates on explicit save-or-discard for open Edit sessions instead of silently flushing (Branch switching).
  • Commit messages upgraded for external writers: full schema-1 diff surface, foreign commits in the same vocabulary (no "External edit" prose), semantic launch catch-up, untitled-item rendering (Commit messages).
  • The rewrite should extract the old repo's AI-ANALYSIS-git-operations.md conclusions (forward-restore model, C3/C8) into a short normative doc rather than re-deriving them.

Out of scope

  • Board-wide history browser / timeline view (the old C8 direction): decided — this is a tracker-integration feature, belonging to the out-of-scope integration story (07-sync-collab.md). The commit trail remains its natural substrate whenever that feature happens; nothing here needs to prepare for it beyond the existing undo-never-rewrites rule. One deliberate carve-in: the card window's read-only per-card History section (05-card-window.md) — a scoped log view, not a browser; per-row restore and lane history stay on the wishlist.

Open questions

None currently — the remote-cadence concern is promoted to a design constraint (see Rules above); the push/pull mechanics are settled in 07-sync-collab.md.