Make the card window the commit unit on Pro boards
Phase C of the two-level undo card: the committer stages around the
whole open card folder — comments included — so gestures in an open
window never land in interim commits; window close flushes the session
as one semantically-named commit ("Edit card 'X'" with the thread as
body bullets, "Mixed update — N changes to card 'X'" when events mix),
with the two-commit foreign/user split preserved and the
comments/.trash purge riding the same bracket. Comment gestures lose
their per-gesture commits structurally (they write inside the held
folder). Branch-switch settle releases every window's staging before
checkout and re-arms on resume.
Fixes two latent pro-m1 defects: the committer was composed without
the store's EchoLedger, so every production commit classified foreign
and was authored Lanework External; and interim flushes dropped
harvest receipts they had not spent, unvouching the session's own
writes at close. Also lands 06's mixed-subject re-ruling (the retired
"Update board" fallback) and phase B's two files missed by the
previous commit's pathspec.
2444 tests in 422 suites green.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
@@ -35,9 +35,35 @@ import Foundation
|
||||
/// window still commits with its strays named.
|
||||
enum CommitMessageEngine {
|
||||
|
||||
/// **06's own mixed-window fallback**: "genuinely mixed windows fall back to 'Update board' —
|
||||
/// always with a bulleted body naming every event".
|
||||
static let mixedSubject = "Update board"
|
||||
/// **A genuinely mixed window says so** (06 ▸ Commit messages, re-ruled 2026-07-31 — "retiring
|
||||
/// the bare 'Update board' fallback"):
|
||||
///
|
||||
/// > **"Mixed update — N changes"**, always with a bulleted body naming every event, so the
|
||||
/// > oneline log stays scannable and never dresses a grab-bag as one thing; when every event in
|
||||
/// > the window shares one item — the card-window session flush's usual shape — the subject keeps
|
||||
/// > the name: **"Mixed update — N changes to card '⟨title⟩'"**.
|
||||
///
|
||||
/// The named case is the reason this exists: a card window's close flush is *by construction* a
|
||||
/// window of one card's changes, and "Update board" was the one subject that could not say which
|
||||
/// card a whole session belonged to.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - count: how many events the body will list — the subject counts changes, not commits.
|
||||
/// - item: the rendered noun phrase every event shares ("card 'Fix login'"), or `nil` when they
|
||||
/// genuinely span the board.
|
||||
static func mixedSubject(_ count: Int, item: String? = nil) -> String {
|
||||
let changes = count == 1 ? "1 change" : "\(count) changes"
|
||||
guard let item else { return "Mixed update — \(changes)" }
|
||||
return "Mixed update — \(changes) to \(item)"
|
||||
}
|
||||
|
||||
/// The subject a window with no describable event at all falls to.
|
||||
///
|
||||
/// Vanishingly rare and deliberately kept: the tree's commit condition is the *tree*, not the
|
||||
/// snapshot diff, so a window whose every path composed nothing still commits rather than leaving
|
||||
/// the tree dirty (06 ▸ Commit messages ▸ Non-snapshot files commit too). "0 changes" would be a
|
||||
/// lie about a commit that does contain something; this says what it honestly is.
|
||||
static let unnamedSubject = "Update board"
|
||||
|
||||
/// **An untitled item reads "(untitled)" — never a bare `""`** (06 ▸ Commit messages: "a
|
||||
/// pathfinder edge fixed, not carried").
|
||||
@@ -65,7 +91,7 @@ enum CommitMessageEngine {
|
||||
// commits the whole tree as *Initial board state*, never a folded diff-from-empty: there is
|
||||
// no last-committed snapshot to diff against."
|
||||
guard !request.isRootCommit else { return GitRepository.initialCommitSubject }
|
||||
return assemble(events(for: request))
|
||||
return assemble(events(for: request), request: request)
|
||||
}
|
||||
|
||||
/// Every event this commit is composed of — model events in board reading order, then the
|
||||
@@ -91,14 +117,16 @@ enum CommitMessageEngine {
|
||||
/// - One event: its own subject, plus its detail line where it has one.
|
||||
/// - Several: a subject chosen from the **headline** events, and a bullet naming *every* event —
|
||||
/// "so the oneline log stays scannable and the full message stays complete".
|
||||
private static func assemble(_ events: [Event]) -> String {
|
||||
guard !events.isEmpty else { return mixedSubject }
|
||||
private static func assemble(_ events: [Event], request: CommitMessageRequest) -> String {
|
||||
guard !events.isEmpty else { return unnamedSubject }
|
||||
if events.count == 1 {
|
||||
guard let detail = events[0].detail else { return events[0].subject }
|
||||
return "\(events[0].subject)\n\n\(detail)"
|
||||
}
|
||||
let body = events.map { "- \($0.bullet)" }.joined(separator: "\n")
|
||||
return "\(subject(for: headline(of: events)))\n\n\(body)"
|
||||
let subject = subject(for: headline(of: events))
|
||||
?? mixedSubject(events.count, item: sharedItem(of: events, request: request))
|
||||
return "\(subject)\n\n\(body)"
|
||||
}
|
||||
|
||||
/// The events allowed to *choose* the subject, in the order 06 ranks them.
|
||||
@@ -116,15 +144,52 @@ enum CommitMessageEngine {
|
||||
}
|
||||
|
||||
/// "A single event is the subject; several events of one kind fold into a plural subject, with
|
||||
/// shared destinations preserved; genuinely mixed windows fall back to 'Update board'."
|
||||
private static func subject(for headline: [Event]) -> String {
|
||||
guard let first = headline.first else { return mixedSubject }
|
||||
/// shared destinations preserved" — or `nil`, which is the genuinely mixed window the caller names
|
||||
/// with `mixedSubject(_:item:)`.
|
||||
private static func subject(for headline: [Event]) -> String? {
|
||||
guard let first = headline.first else { return nil }
|
||||
if headline.count == 1 { return first.subject }
|
||||
guard Set(headline.map(\.kind)).count == 1 else { return mixedSubject }
|
||||
guard Set(headline.map(\.kind)).count == 1 else { return nil }
|
||||
let destinations = Set(headline.compactMap(\.destination))
|
||||
return first.kind.plural(headline.count, destination: destinations.count == 1 ? destinations.first : nil)
|
||||
}
|
||||
|
||||
/// **The one item every event in this window belongs to, rendered** — "card 'Fix login'" — or
|
||||
/// `nil` when they span more than one.
|
||||
///
|
||||
/// Read from the events' own **paths** rather than from a field each event would have to remember
|
||||
/// to carry: an event's paths are the only thing in this engine that is always true about where it
|
||||
/// came from, and a card's folder is the one component that survives a lane move. A window with a
|
||||
/// path that belongs to no card at all — the board's own `index.md`, a lane, a stray, the agent
|
||||
/// guide — is by definition not one card's window, so a single `nil` answer decides the whole
|
||||
/// question.
|
||||
private static func sharedItem(of events: [Event], request: CommitMessageRequest) -> String? {
|
||||
var folder: String?
|
||||
for event in events {
|
||||
for path in event.paths {
|
||||
guard let card = cardFolder(of: path) else { return nil }
|
||||
if let folder, folder != card { return nil }
|
||||
folder = card
|
||||
}
|
||||
}
|
||||
guard let folder else { return nil }
|
||||
let title = cardTitlesByPath(request)[folder] ?? untitledPlaceholder
|
||||
return "card \(quotedSubject(title))"
|
||||
}
|
||||
|
||||
/// The `<lane>/<card>` (or `.trash/<card>`) folder a board-root-relative path belongs to, or `nil`
|
||||
/// when it belongs to no card — the board's `index.md`, a lane's, a root stray.
|
||||
///
|
||||
/// Comment paths answer through `CommentPath`, which already knows the thread's two containers, so
|
||||
/// the one rule about where a card lives is not spelled twice.
|
||||
private static func cardFolder(of path: String) -> String? {
|
||||
if let comment = CommentPath.classify(path) { return comment.cardPath }
|
||||
let components = path.split(separator: "/", omittingEmptySubsequences: true).map(String.init)
|
||||
guard components.count >= 2, BoardLoader.isUUIDShaped(components[1]) else { return nil }
|
||||
guard components[0] == Paths.trashFolder || BoardLoader.isUUIDShaped(components[0]) else { return nil }
|
||||
return "\(components[0])/\(components[1])"
|
||||
}
|
||||
|
||||
// MARK: - The structural diff
|
||||
|
||||
private static func modelEvents(
|
||||
@@ -983,7 +1048,7 @@ enum CommitMessageEngine {
|
||||
case .relabelBoard: return "Relabel board"
|
||||
case .assignBoard: return "Assign board"
|
||||
case .dueBoard: return "Set due date on board"
|
||||
case .updateBoard: return CommitMessageEngine.mixedSubject
|
||||
case .updateBoard: return CommitMessageEngine.unnamedSubject
|
||||
|
||||
case .agentGuide: return "Update agent guide"
|
||||
case .updatePath: return "Update \(count) files"
|
||||
|
||||
@@ -208,13 +208,13 @@ public final class GitAutoCommitter {
|
||||
@ObservationIgnored
|
||||
private var holdsForeignChanges = false
|
||||
|
||||
/// Open Edit sessions, each answering with the folder to stage around *right now*.
|
||||
/// Open **card-window sessions**, each answering with the folder to stage around *right now*.
|
||||
///
|
||||
/// A closure per session rather than a stored URL, because a card can move lane, or into the
|
||||
/// trash, in the middle of a session — its folder is a fact about the current snapshot, not
|
||||
/// about when Edit was entered.
|
||||
/// about when the window opened.
|
||||
@ObservationIgnored
|
||||
private var editSessions: [UUID: @MainActor () -> URL?] = [:]
|
||||
private var cardSessions: [UUID: @MainActor () -> URL?] = [:]
|
||||
|
||||
@ObservationIgnored
|
||||
private var pending: Task<Void, Never>?
|
||||
@@ -317,32 +317,46 @@ public final class GitAutoCommitter {
|
||||
apply(result.outcome, healPaths: result.healPaths)
|
||||
}
|
||||
|
||||
// MARK: - Edit sessions
|
||||
// MARK: - Card-window sessions
|
||||
|
||||
/// **Registers an open Edit session's card folder** (06 ▸ Rules ▸ Auto-commit: "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").
|
||||
/// **Registers an open card window's folder** (06 ▸ Rules ▸ Auto-commit, widened 2026-07-31 —
|
||||
/// "Board history sees **card-window sessions, not gestures**"):
|
||||
///
|
||||
/// > while a card's window is open, everything happening inside it — the body editor's ~700 ms
|
||||
/// > crash-safe disk saves, comment posts and deletes, draft-save cadence, sidebar changes —
|
||||
/// > stays **uncommitted**, and the committer **stages around the whole open card folder** (the
|
||||
/// > former Edit-session stage-around, widened; comments included).
|
||||
///
|
||||
/// So the unit is the **window**, not the body's Edit session: the token is minted when the
|
||||
/// window joins its board and released when its session ends, and everything the window writes in
|
||||
/// between — body saves, comment posts and deletes, inline comment edits, the composer's draft,
|
||||
/// the `comments/.trash/` purge — is inside one folder that no interim flush can see.
|
||||
///
|
||||
/// The exclusion is absolute where it applies: "whole-root staging widening *what* commits, never
|
||||
/// overriding the exclusion" (06 ▸ Commit messages ▸ Non-snapshot files commit too). A stray
|
||||
/// dropped inside the session card's folder therefore waits for the session to end, along with
|
||||
/// the body.
|
||||
/// everything else under it — a foreign write to the same card included, which is what makes the
|
||||
/// close flush's two-commit split the *first* moment that change can land (06 ▸ Rules ▸
|
||||
/// Auto-commit: "The EchoLedger's two-commit split still applies at close when the held window
|
||||
/// mixes foreign changes to that card with the app's own").
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - token: the window's identity, so ending twice is idempotent.
|
||||
/// - cardFolder: asked at every flush rather than stored, so a card moved mid-session is staged
|
||||
/// around at wherever it now is.
|
||||
public func beginEditSession(_ token: UUID, cardFolder: @escaping @MainActor () -> URL?) {
|
||||
editSessions[token] = cardFolder
|
||||
public func beginCardSession(_ token: UUID, cardFolder: @escaping @MainActor () -> URL?) {
|
||||
cardSessions[token] = cardFolder
|
||||
}
|
||||
|
||||
/// Ends one, and **nudges** — which is what makes "exactly one body commit per session" true:
|
||||
/// the session's debounced saves committed nothing while it was open, and this is the moment its
|
||||
/// whole diff becomes committable (06 ▸ Rules ▸ Auto-commit: the Edit→Preview flip is "the
|
||||
/// effective Save button"; raw-source entry and window close end the session too).
|
||||
public func endEditSession(_ token: UUID) {
|
||||
guard editSessions.removeValue(forKey: token) != nil else { return }
|
||||
/// Ends one, and **nudges** — which is what makes "window close flushes the session as one
|
||||
/// commit" true: the session's writes committed nothing while the window stood, and this is the
|
||||
/// moment its whole diff becomes committable (06 ▸ Rules ▸ Auto-commit).
|
||||
///
|
||||
/// Called after the session's own last writes have landed (`CardWindowSession.endSession()` runs
|
||||
/// to completion first — `AppModel.unregisterCardWindow`), so the diff this arms over is the
|
||||
/// session's *final* state rather than its second-to-last.
|
||||
public func endCardSession(_ token: UUID) {
|
||||
guard cardSessions.removeValue(forKey: token) != nil else { return }
|
||||
arm()
|
||||
}
|
||||
|
||||
@@ -370,7 +384,7 @@ public final class GitAutoCommitter {
|
||||
/// Whether a card window's folder is currently staged around — the stage-around rule, made
|
||||
/// assertable without reaching into private state.
|
||||
public var stagedAroundFolders: [URL] {
|
||||
editSessions.values.compactMap { $0() }
|
||||
cardSessions.values.compactMap { $0() }
|
||||
}
|
||||
|
||||
// MARK: - Flushing
|
||||
@@ -436,7 +450,7 @@ public final class GitAutoCommitter {
|
||||
private func makeInput() -> FlushInput? {
|
||||
FlushInput(
|
||||
boardRoot: boardRoot,
|
||||
excludedFolders: editSessions.values.compactMap { $0() }.map(EchoLedger.key),
|
||||
excludedFolders: stagedAroundKeys,
|
||||
receipts: harvested,
|
||||
composer: composer,
|
||||
snapshot: currentSnapshot?()
|
||||
@@ -639,7 +653,7 @@ public final class GitAutoCommitter {
|
||||
reportLanded?(GitLandedWindow(commits: landed, healPaths: healPaths))
|
||||
// The window is over: its receipts have said everything they can say, and keeping them
|
||||
// would let them vouch for the *next* window's changes to the same paths.
|
||||
harvested.removeAll()
|
||||
dropHarvestOutsideOpenSessions()
|
||||
holdsForeignChanges = false
|
||||
reportRecovery?()
|
||||
Self.logger.debug("auto-commit landed \(oids.count, privacy: .public) commit(s)")
|
||||
@@ -649,7 +663,7 @@ public final class GitAutoCommitter {
|
||||
// whole window was staged around. Silent, and the window closes either way.
|
||||
pause = nil
|
||||
lastFailure = nil
|
||||
harvested.removeAll()
|
||||
dropHarvestOutsideOpenSessions()
|
||||
holdsForeignChanges = false
|
||||
reportRecovery?()
|
||||
|
||||
@@ -688,4 +702,34 @@ public final class GitAutoCommitter {
|
||||
harvested[path] = entry
|
||||
}
|
||||
}
|
||||
|
||||
/// **Forgets the receipts a flush has spent — and keeps the ones it could not** (06 ▸ Interaction
|
||||
/// with external writers: attribution "per file", off the ledger).
|
||||
///
|
||||
/// A receipt is cleared because the commit it described has landed. Under the widened
|
||||
/// stage-around (`beginCardSession`) a flush routinely lands *without* the session folder, so its
|
||||
/// receipts have not been spent at all: they describe writes still sitting uncommitted on disk,
|
||||
/// waiting for the close flush. Clearing them wholesale is what would make the two-commit split at
|
||||
/// close wrong in exactly the case it exists for — the app's own body save and comment posts would
|
||||
/// arrive at the close unvouched-for and commit as `Lanework External`, blaming the outside world
|
||||
/// for the user's own session.
|
||||
///
|
||||
/// So the drop is scoped to what the flush could see: everything outside every open session's
|
||||
/// folder goes, everything inside one stays until that session's own commit spends it.
|
||||
private func dropHarvestOutsideOpenSessions() {
|
||||
let open = stagedAroundKeys
|
||||
guard !open.isEmpty else {
|
||||
harvested.removeAll()
|
||||
return
|
||||
}
|
||||
harvested = harvested.filter { key, _ in
|
||||
open.contains { key == $0 || key.hasPrefix($0 + "/") }
|
||||
}
|
||||
}
|
||||
|
||||
/// The open sessions' folders as `EchoLedger` keys — what both the staging exclusion and the
|
||||
/// harvest's scoped drop compare against, resolved in one place so they cannot disagree.
|
||||
private var stagedAroundKeys: [String] {
|
||||
cardSessions.values.compactMap { $0() }.map(EchoLedger.key)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user