Realign code with the 2026-07-31 findings-resolution rulings

The full bullet list from Implementation card bf080d9a — both ruling
batches, including the three appended mid-session by 16ef377:

- Restore subjects compose the inverse, never nest: crossing "Undo: S"
  emits "Redo: S" and vice versa; parity, not stack depth, reads a
  legacy double prefix (GitHistoryProvider.restoreSubject).
- Git-operation failures join the one-shot failure banner tier:
  BannerCenter.GitFailureBanner (undo/redo/branchSwitch/addGit), error
  tone at failure rank merged with write one-shots by recency; the
  postLoss compromise is retired at both AppModel wirings.
- order/schema optional below the board root: append-at-end reading
  (ordered siblings first, folder-name tie-break among the order-less),
  schema reads 1, both coerce-tier logged; the root keeps its
  requirements. Ranks.resolvedOrders materializes finite ranks so
  models and placement math stay untouched; first Writer rewrite
  stamps a real rank on touch, placement against an order-less sibling
  stamps that sibling inline in the same bracket. Agent guide v10
  teaches optional keys and zero-read filing. Hostile-YAML order
  shapes become coercion tests; Fixtures/Valid/optional-keys.kanban
  replaces the four retired Malformed boards.
- .gitignore is the relocation-heal noise gate: GitignoreRules pure
  matcher (standard semantics, board-root file only), loader consults
  it once per walk so matched loose files keep the stray posture;
  seeded (.DS_Store + .*.lanework-*) at board creation and template
  instantiation, healed in when missing at open — repo-nested
  included; empty file honored, existing files never edited; the
  committer's obedience via libgit2 status is pinned by test.
- Comments crash-residue sweep gates on step ownership: HistoryStep
  derives backing from its own undo expectations, backedContent unions
  both stacks, the sweep purges per-entry only what no live step owns.
- Skip-purge decoupled (16ef377): a stale-skipped coarse step strands
  whole in NativeHistoryProvider.strandedSteps — still backing, retired
  only at session end; clean exits purge as before.
- Coarse close step named "Changes to '<card>'"; the fine body-edit
  wording never leaks onto the board menu.
- Branch-switch settle clears every open card window's fine stack on
  Save All and Discard alike; the empty fold registers no coarse step.
- Close flush awaits its covering snapshot (quiesce + one generation
  bump, 1s bound), and an explicit flush now queues behind an
  in-flight one instead of skipping — the audit-caught interleaving
  could lose a close flush permanently when the debounce fired inside
  the close sequence; regression tests force both races.
- Commit comment bullets sort chronologically by created, not UUID.
- The production-unwired CardBodyEditSession.editSessionDidChange seam
  is deleted with its seam-only tests.
- Composition-root pins: beginSession composes the committer with the
  store's own EchoLedger and binds the announcer (the miswire class).
- Deliberate 06 conformance pass over every 2026-07-31-tagged
  sentence: fixed Change-custom-key subjects (the retired named
  generic was the only producer), the unbuilt Replace attachment
  vocabulary, heal commits now authored Lanework Integrity, the config
  reader scopes identity to plain [user] sections, add-git re-runs
  detection at create (a stale mode-none could initialize inside the
  user's repo), and add-git failures answer at the form or the banner.
  Structural residue filed on the Redesign board.

2554 tests / 439 suites green.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
2026-08-01 07:43:45 -04:00
parent 16ef3779e8
commit 274ccd9ff5
75 changed files with 5619 additions and 791 deletions
+50 -44
View File
@@ -50,9 +50,10 @@ public struct GitOperationFailure: Error, Sendable, Equatable, CustomStringConve
///
/// This is the pathfinder's `GitSource` shape, kept because it was right, with the pathfinder's
/// *policy* deliberately left behind: nothing here auto-initializes anything and nothing commits on
/// its own schedule. The one seed it does write a `.gitignore`, at init and never again
/// (06 Repository hygiene) is the app's last word on that file rather than the start of a
/// relationship with it.
/// its own schedule. It writes no seed of its own any more: the `.gitignore` outgrew git on
/// 2026-07-31 and belongs to the board now (`BoardWriter.gitignoreSeed`, seeded at creation and
/// healed in at open), so all that survives here is a last-chance check that the file exists before
/// the initial commit freezes the tree see `seedGitignoreIfAbsent(at:)`.
enum GitRepository {
/// **The root commit's own subject** (06-history-undo.md Rules Abnormal repo states,
@@ -72,20 +73,11 @@ enum GitRepository {
/// writing it is exactly `git symbolic-ref HEAD refs/heads/main` before anything else touches
/// the repo.
///
/// DESIGN is silent on the name; `main` is git's own modern default and the pathfinder's choice.
/// **The initial branch is `main`** (06 Rules Opt-in init, blessed 2026-07-31): "the host's
/// `init.defaultBranch` lives in config layers the sandbox can't read, so add-git sets it
/// deterministically git's modern default, the pathfinder's choice."
static let initialBranchName = "main"
/// **The whole of the seeded `.gitignore`** (06-history-undo.md Repository hygiene: "Adding git
/// to a board writes a minimal `.gitignore` (`.DS_Store`) if none exists").
///
/// One line, because one line is what the rule says and because every additional entry would be
/// the app deciding something about a file it is about to stop having opinions on. `.DS_Store` is
/// the entry that earns its place: the Finder writes one into every folder a user looks at, and
/// on a board that means one per lane and one per card, each churning as icons and window
/// positions move noise that would otherwise be committed by the whole-tree stage, forever,
/// under the user's own name.
static let seededGitignore = ".DS_Store\n"
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git")
// MARK: Opt-in init
@@ -97,24 +89,39 @@ enum GitRepository {
/// is protected from the moment git exists" so the two halves are one operation and a failure
/// in either is one failure.
///
/// Between them sits the one seed the app ever writes: a minimal `.gitignore`, if the board has
/// none, in the initial commit rather than after it (`seedGitignoreIfAbsent`).
/// Between them sits a last-chance `.gitignore` check the file is the board's rather than
/// git's since 2026-07-31, so it is almost always already there; when it is not, seeding it here
/// puts it *in* the initial commit rather than after it (`seedGitignoreIfAbsent`).
///
/// **It refuses a board that already has a `.git`.** The app "never mutates repo state it didn't
/// create" (06), and `git_repository_init` over an existing repository is a re-initialization
/// harmless in the common case and precisely the kind of thing that rule exists to forbid. The
/// caller (`HistoryStore.addGit`) has already established mode `none`; this is the check that
/// makes it impossible rather than merely unlikely.
/// **Create re-runs full detection and refuses anything but mode none** (06 Rules Detection,
/// ruled 2026-07-31): "as hardening, add-git's create re-runs full detection and refuses unless it
/// reads clean none, so the forbidden nested init is impossible even on a raced or stale read."
///
/// The caller (`HistoryStore.addGit`) has already established mode `none` from the mode it
/// detected at board open, which can be minutes old a `git init` in a terminal at the board root
/// *or anywhere above it* between the two would otherwise slip past a root-only check and
/// initialize a repository inside the user's, which is the one init 06 forbids outright. The whole
/// walk runs again here, at the moment of the write, so the refusal is structural rather than
/// probable. (Detection has no *unverifiable* answer yet 06's denial-is-not-absence distinction
/// is not built so "clean none" is spelled `.none` for now.)
///
/// Returns the branch the root commit landed on, which is the popover's display line.
nonisolated static func create(at boardRoot: URL) -> Result<String, GitOperationFailure> {
let operation = "Adding git to this board"
guard !BoardGitMode.hasGitEntry(at: boardRoot) else {
switch BoardGitMode.detect(boardRoot: boardRoot) {
case .none:
break
case .git:
return .failure(GitOperationFailure(
operation: operation,
message: "this board already has a git repository"
))
case .repoNested:
return .failure(GitOperationFailure(
operation: operation,
message: "this board lives inside a repository; Lanework leaves it to that repository"
))
}
let gitDirectory: URL
@@ -189,33 +196,32 @@ enum GitRepository {
}
}
/// **The `.gitignore` seed, written at init and never again** (06-history-undo.md Repository
/// hygiene: "the app never edits an existing one and never manages the file afterward it's the
/// user's from then on").
/// **The last-chance `.gitignore` seed, immediately before the initial commit.**
///
/// Three properties, and they are the feature:
/// The seed itself stopped being git's on 2026-07-31 (06-history-undo.md Repository hygiene,
/// re-ruled: "`.gitignore` seeded on every board, never touched after git or not"). Every board
/// the app creates is born with one, and every board it opens is healed into having one
/// (`BoardStore.seedGitignore`) and add-git can only run on a board that is *open* and writable,
/// so by the time this line is reached the file is essentially always already there and this call
/// writes nothing.
///
/// - **Only when absent.** A board that already carries a `.gitignore` from a template, from a
/// clone, from the user is left byte for byte alone. `fileExists` rather than a read, so a
/// *directory* wearing the name is left alone too (`IntegrityRules.claimedRootNames` marks
/// `.gitignore` as one of the two claimed names whose squatters are never displaced, precisely
/// because nothing in the app reads this file).
/// - **Only here.** This is the one call site, on the one path that creates a repository. Nothing
/// re-checks it, no heal restores it, no later version of the app appends to it: a user who
/// deletes the seeded line has deleted it.
/// - **Only on the app's own init.** Adoption seeds nothing an adopted repository is somebody
/// else's init, and 06's rule is about what the app writes when *it* creates one. A repo-nested
/// board seeds nothing either, and structurally cannot: `HistoryStore.addGit` refuses any mode
/// but `none`, so this function is unreachable from there.
/// **It stays anyway, and stays here before the stage below.** The one case it still answers is
/// the one that cannot be fixed afterwards: if the board's seed heal has not landed (a transient
/// failure that armed its memo, a picture that has not changed since), the initial commit would
/// otherwise capture every `.DS_Store` the Finder has left under the board *into history*, where
/// this app has no operation that could ever remove it (06 Deleting never forgets). One
/// `lstat` on the one path that mints a repository is a cheap insurance policy against a
/// permanent record.
///
/// Seeding is `BoardWriter.seedGitignoreIfAbsent`'s one seed text, one write-only-when-free
/// rule, `lstat` semantics so this cannot drift from what board creation and the heal write.
///
/// A write that fails is not a failure of add-git. The repository exists, the commit that follows
/// simply will not carry a `.gitignore`, and a board with none is an ordinary board surfacing a
/// banner about a courtesy file would be louder than the thing it reports.
/// simply will not carry a `.gitignore`, and the board's own heal will try again at the next
/// open surfacing a banner about a courtesy file would be louder than the thing it reports.
private static func seedGitignoreIfAbsent(at boardRoot: URL) {
let url = boardRoot.appendingPathComponent(".gitignore")
guard !FileManager.default.fileExists(atPath: url.path) else { return }
do {
try Data(seededGitignore.utf8).write(to: url, options: .atomic)
try BoardWriter.seedGitignoreIfAbsent(atBoardRoot: boardRoot)
} catch {
logger.notice("could not seed .gitignore at \(boardRoot.path, privacy: .public): \(String(describing: error), privacy: .public)")
}