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 by16ef377: - 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:
@@ -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)")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user