import Foundation import Testing @testable import Kanban /// **The inert-`.git` posture, stated against real bytes on disk** (12-editions.md ▸ The free tier /// and `.git`): "any `.git` is inert — opening a board that has one works normally, but the app /// never reads history, never commits, never touches `.git` in any way." /// /// It is unconditional app behaviour, not a build-time posture: the app that ships is the only app /// there is (12 ▸ The target, re-ruled 2026-07-30), and it runs this way for everyone without a /// subscription and for everyone whose subscription has lapsed — "unsubscribed and lapsed are one /// state". So these tests assert what `Kanban` does, full stop; nothing here is conditioned on a /// tier, and the git provider that will one day touch `.git` composes over its own boards. /// /// Two halves of that posture are already pinned elsewhere and are referenced, not repeated: /// `FolderWatcherTests` ▸ ".git filtering" proves the watcher ignores churn under a `.git` at any /// depth, and `BoardLoaderStrayTests.strayFilesAndHiddenEntriesAreIgnoredWithoutWarning` proves a /// `.git` at the board root loads as an ordinary stray with no warning. Both are *input* claims: /// what the app does with events and entries it is handed. /// /// This file states the **output** claim, which no existing test covers, and which is the one the /// posture has to be able to demonstrate: a full session of ordinary editing leaves every byte and /// every mtime under `.git` exactly as it found them. It is asserted the only way /// that is worth anything — by snapshotting the whole `.git` subtree from the filesystem before /// the edits and re-reading it afterwards, never through the app's own read path /// (`WriterTestSupport.swift`'s standing rule). /// /// Note what "untouched" is worth as an mtime assertion specifically: bytes alone would pass even /// if the app rewrote a file with identical content, and re-writing git's index with identical /// bytes is exactly the kind of thing an accidental git dependency would do. The mtimes are the /// assertion that nothing *opened for writing* down there at all. // MARK: - Subtree snapshots /// One entry under `.git`: its path relative to the board root, its bytes (nil for directories), /// and its on-disk modification date. Directories carry an mtime too — a file created or removed /// inside a directory moves *that directory's* mtime, so including them catches a write the /// per-file comparison would miss because the file it added is not in the "before" set. private struct SubtreeEntry: Equatable, CustomStringConvertible { let relativePath: String let data: Data? let modified: Date var description: String { "\(relativePath) (\(data.map { "\($0.count) bytes" } ?? "directory"), modified \(modified))" } } /// Every entry beneath `root/subtree`, hidden entries included, sorted by path. `.git` is itself /// hidden and everything inside it is reached through it, so `.skipsHiddenFiles` is deliberately /// *not* passed — a snapshot that skipped hidden files would snapshot nothing at all. private func snapshotSubtree(_ root: URL, _ subtree: String) throws -> [SubtreeEntry] { let base = root.appendingPathComponent(subtree, isDirectory: true) let manager = FileManager.default guard let walker = manager.enumerator(atPath: base.path) else { Issue.record("could not enumerate \(subtree)") return [] } var entries: [SubtreeEntry] = [] for case let relative as String in walker { let url = base.appendingPathComponent(relative) let attributes = try manager.attributesOfItem(atPath: url.path) guard let modified = attributes[.modificationDate] as? Date else { Issue.record("no modification date for \(subtree)/\(relative)") continue } let isDirectory = (attributes[.type] as? FileAttributeType) == .typeDirectory entries.append(SubtreeEntry( relativePath: "\(subtree)/\(relative)", data: isDirectory ? nil : try Data(contentsOf: url), modified: modified )) } // The directory the subtree hangs from, which the enumerator above does not yield. let rootAttributes = try manager.attributesOfItem(atPath: base.path) if let modified = rootAttributes[.modificationDate] as? Date { entries.append(SubtreeEntry(relativePath: subtree, data: nil, modified: modified)) } return entries.sorted { $0.relativePath < $1.relativePath } } // MARK: - Fixture construction /// A `.git` directory with the shape a real one has — a ref file, a binary index, a loose object /// two levels down, a packed-refs file, and an empty `objects/pack` — written under `parent`. /// The bytes are sentinels: recognizable, non-UTF-8 in the index's case, and nothing the app has /// any reader for. @discardableResult private func makeGitDirectory(in fixture: WriterFixture, under parent: String) throws -> String { let prefix = parent.isEmpty ? ".git" : "\(parent)/.git" try fixture.file("\(prefix)/HEAD", Data("ref: refs/heads/main\n".utf8)) try fixture.file("\(prefix)/config", Data("[core]\n\trepositoryformatversion = 0\n".utf8)) // Deliberately not valid UTF-8 — a real `.git/index` is binary, and a byte comparison that // only ever sees text is not testing the thing that matters. try fixture.file("\(prefix)/index", Data([0x44, 0x49, 0x52, 0x43, 0x00, 0x00, 0x00, 0x02, 0xFF, 0xFE, 0x00, 0x01])) try fixture.file("\(prefix)/objects/ab/cdef0123456789", Data([0x78, 0x01, 0xCB, 0xC8, 0x4F, 0x00, 0x00])) try fixture.file("\(prefix)/refs/heads/main", Data("0123456789abcdef0123456789abcdef01234567\n".utf8)) try fixture.file("\(prefix)/packed-refs", Data("# pack-refs with: peeled fully-peeled sorted\n".utf8)) try FileManager.default.createDirectory( at: fixture.url("\(prefix)/objects/pack"), withIntermediateDirectories: true ) return prefix } /// The board every test here edits: a root, two lanes, three cards, a `.git` at the board root /// **and** a second one nested inside a card folder (12: "any `.git` is inert", not just the /// root's own — a repo-nested board or a clone dropped inside a card is the same promise). private struct InertGitBoard { let fixture: WriterFixture let laneA: String let laneB: String let card1: String let card2: String /// The card carrying the nested repo — the one the move test drags across lanes. let cardWithRepo: String init() throws { fixture = try WriterFixture() laneA = Ident.lane1 laneB = Ident.lane2 card1 = Ident.card1 card2 = Ident.card2 cardWithRepo = Ident.card3 try fixture.item("", Item.board) try fixture.item(laneA, Item.rich(order: "1024", title: "Doing")) try fixture.item(laneB, Item.rich(order: "2048", title: "Done")) try fixture.item("\(laneA)/\(card1)", Item.rich(order: "1024", title: "First")) try fixture.item("\(laneA)/\(card2)", Item.rich(order: "2048", title: "Second")) try fixture.item("\(laneA)/\(cardWithRepo)", Item.rich(order: "3072", title: "Has a clone")) try makeGitDirectory(in: fixture, under: "") try makeGitDirectory(in: fixture, under: "\(laneA)/\(cardWithRepo)") } var root: URL { fixture.root } func url(_ relativePath: String) -> URL { fixture.url(relativePath) } func tearDown() { fixture.tearDown() } } // MARK: - The posture struct InertGitTests { @Test("A full session of ordinary edits leaves every byte and mtime under .git untouched") func anEditingSessionNeverTouchesGit() throws { let board = try InertGitBoard() defer { board.tearDown() } let before = try snapshotSubtree(board.root, ".git") #expect(before.count == 12, "the fixture's own shape — files, directories and the root") // A session's worth of every write the app can make, in one go. `.git` is at the // board root, so anything that walks or renumbers the root's children walks past it. try BoardWriter.updateIndex(inItemFolder: board.url(""), operation: .rename(title: nil)) { document in document.set(FrontmatterKeys.title, to: .string("Renamed board")) } let newLane = try BoardWriter.createLane(inBoard: board.root, title: "Later") let newCard = try BoardWriter.createCard(inLane: board.url(newLane.rawValue), title: "Fresh") try BoardWriter.writeBody(inItemFolder: board.url("\(newLane.rawValue)/\(newCard.rawValue)"), body: "Body text.\n") try BoardWriter.updateIndex( inItemFolder: board.url("\(board.laneA)/\(board.card1)"), operation: .style(title: nil) ) { document in document.set(FrontmatterKeys.title, to: .string("Retitled")) } _ = try BoardWriter.moveItem( at: board.url("\(board.laneA)/\(board.card2)"), toParent: board.url(board.laneB), sourceBoardRoot: board.root, destinationBoardRoot: board.root, order: nil ) _ = try BoardWriter.copyItem( at: board.url("\(board.laneA)/\(board.card1)"), toParent: board.url(board.laneB), order: nil, stamps: .fork ) // The delete and its restore are both folder moves now (03-board-ui.md § Trash), which is // the pair most likely to notice a `.git` at the root: the first walks into `/.trash/` // and the second walks back out of it. try BoardWriter.deleteCardToTrash( at: board.url("\(board.laneA)/\(board.card1)"), inBoard: board.root ) _ = try BoardWriter.moveItem( at: BoardWriter.trashFolder(inBoard: board.root).appendingPathComponent(board.card1), toParent: board.url(board.laneA), sourceBoardRoot: board.root, destinationBoardRoot: board.root, order: nil ) // The renumbers are the pointed ones: both walk a parent's whole directory listing, which // is where a `.git` entry actually gets looked at. try BoardWriter.renumberVisibleChildren(of: board.root) try BoardWriter.renumberVisibleChildren(of: board.url(board.laneA)) #expect(try snapshotSubtree(board.root, ".git") == before) } @Test("The board loads and renders normally with a .git at its root, without a warning") func aGitBearingBoardLoadsLikeAnyOther() throws { let board = try InertGitBoard() defer { board.tearDown() } let before = try snapshotSubtree(board.root, ".git") let result = try BoardLoader.load(boardRoot: board.root) #expect(result.model.lanes.map(\.id.rawValue) == [board.laneA, board.laneB]) #expect(result.model.lanes[0].cards.map(\.id.rawValue) == [board.card1, board.card2, board.cardWithRepo]) #expect(result.warnings.isEmpty, "a `.git` is a stray like any other — strays are silent") // A load is a read, but a read that opened `.git` would still move its atimes and would // still be the app "touching" history; the mtime equality is what is checkable, and a // loader that decided to *repair* something down there would break it. #expect(try snapshotSubtree(board.root, ".git") == before) } @Test("A nested .git rides along a card move byte- and mtime-verbatim") func aNestedRepositorySurvivesACardMove() throws { let board = try InertGitBoard() defer { board.tearDown() } let nested = "\(board.laneA)/\(board.cardWithRepo)/.git" let before = try snapshotSubtree(board.root, nested) let result = try BoardWriter.moveItem( at: board.url("\(board.laneA)/\(board.cardWithRepo)"), toParent: board.url(board.laneB), sourceBoardRoot: board.root, destinationBoardRoot: board.root, order: nil ) // Same identity, new parent — the folder moved whole, `.git` inside it. let moved = "\(board.laneB)/\(result.id.rawValue)/.git" let after = try snapshotSubtree(board.root, moved) #expect(after.map(\.data) == before.map(\.data)) #expect(after.map(\.modified) == before.map(\.modified)) #expect( after.map { $0.relativePath.replacingOccurrences(of: moved, with: nested) } == before.map(\.relativePath) ) } @Test("A copy of a card carrying a .git reproduces it verbatim and leaves the original alone") func aCopyCarriesTheNestedRepositoryWithoutTouchingTheOriginal() throws { let board = try InertGitBoard() defer { board.tearDown() } let nested = "\(board.laneA)/\(board.cardWithRepo)/.git" let before = try snapshotSubtree(board.root, nested) let copyID = try BoardWriter.copyItem( at: board.url("\(board.laneA)/\(board.cardWithRepo)"), toParent: board.url(board.laneB), order: nil, stamps: .fork ) #expect(try snapshotSubtree(board.root, nested) == before, "the source is never touched") // The copy's `.git` is the same tree with the same bytes (mtimes are a copy's to set — // `FileManager.copyItem` preserves them, but the promise being made here is about content // and shape, not about a copy having been a rename). let copied = try snapshotSubtree(board.root, "\(board.laneB)/\(copyID.rawValue)/.git") #expect(copied.map(\.data) == before.map(\.data)) #expect( copied.map { $0.relativePath.replacingOccurrences(of: "\(board.laneB)/\(copyID.rawValue)/.git", with: nested) } == before.map(\.relativePath) ) } }