From d7e0692e2bb9bab6563eea871ab56d6b3d91d10d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 02:44:39 +0000 Subject: [PATCH] Fix P0 correctness issues in jail enforcement, merge, and mutation log - ReadWriteFilesystem: reject writes whose destination's final component is a symlink resolving outside the root. The parent-only containment check let appends follow a pre-existing symlink out of the jail. - Workspace.merge: record the parent's mutation sequence at branch time and refuse to merge over tracked-but-uncheckpointed parent edits with the new WorkspaceError.mergeUncheckpointedChanges, instead of silently clobbering them. The checkpoint-head comparison alone cannot see writes that never advanced the head. - FileCheckpointStore: tolerate a torn trailing mutations.jsonl line left by a crashed append (skip on read, truncate before the next append) instead of failing every subsequent load. Mid-file corruption still surfaces as an error. - FileCheckpointStore: derive the next mutation sequence from the log's final record rather than re-reading the entire log on every append, which was O(n^2) across a session. Adds regression tests for all four fixes. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01G29SXqzHqDycqXGa4dRr4x --- README.md | 4 +- Sources/Workspace/CheckpointStore.swift | 97 +++++++++++++++++-- Sources/Workspace/FS/FileSystem.swift | 5 + .../Workspace/FS/ReadWriteFilesystem.swift | 16 +++ Sources/Workspace/Workspace+Branching.swift | 27 +++++- Sources/Workspace/Workspace.swift | 7 +- .../WorkspaceTests/CheckpointStoreTests.swift | 90 +++++++++++++++++ Tests/WorkspaceTests/FilesystemTests.swift | 46 +++++++++ .../WorkspaceCheckpointTests.swift | 46 +++++++++ 9 files changed, 327 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index f5f4ffe..05d119e 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ let workspace = Workspace( ) ``` -`Storage.directory(at:)` writes checkpoint and snapshot JSON plus a `mutations.jsonl` append log (one JSON record per line) under `//`. A legacy `mutations.json` array is migrated to JSONL on first access. The store assigns monotonic `sequence` numbers while holding `mutations.lock` (advisory `flock` where the OS supports it), so multiple `Workspace` instances that share a `workspaceId` and store do not collide on mutation sequence. The current checkpoint head is derived from the parent-id graph (unparented tips), not only from `createdAt` ordering, which reduces surprises when wall clocks differ between processes. Listing or loading mutations still reads the full log; very long histories may need application-level rotation. Coordinating multiple hosts or network disks that do not honor `flock` may still require extra synchronization. +`Storage.directory(at:)` writes checkpoint and snapshot JSON plus a `mutations.jsonl` append log (one JSON record per line) under `//`. A legacy `mutations.json` array is migrated to JSONL on first access. The store assigns monotonic `sequence` numbers while holding `mutations.lock` (advisory `flock` where the OS supports it), so multiple `Workspace` instances that share a `workspaceId` and store do not collide on mutation sequence. Appends read only the log's final record to derive the next sequence, and a partial trailing line left by a crashed append is skipped on read and truncated before the next append. The current checkpoint head is derived from the parent-id graph (unparented tips), not only from `createdAt` ordering, which reduces surprises when wall clocks differ between processes. Listing mutations still reads the full log; very long histories may need application-level rotation. Coordinating multiple hosts or network disks that do not honor `flock` may still require extra synchronization. ### Reads @@ -227,7 +227,7 @@ print(merged.mergedFromWorkspaceId == branch.workspaceId) // true print(merged.mergedFromCheckpointId == branchHead.id) // true ``` -`merge(_:)` is optimistic. If the parent workspace head changed after `branch()` was created, merge throws `WorkspaceError.mergeConflict(parentWorkspaceId:expectedBase:actualHead:)`. +`merge(_:)` is optimistic. If the parent workspace head changed after `branch()` was created, merge throws `WorkspaceError.mergeConflict(parentWorkspaceId:expectedBase:actualHead:)`. If the parent made tracked writes after `branch()` without creating a checkpoint, merge throws `WorkspaceError.mergeUncheckpointedChanges(parentWorkspaceId:baseMutationCursor:currentMutationCursor:)` instead of silently overwriting those edits; create a checkpoint or roll the parent back first. ## Common Filesystem Patterns diff --git a/Sources/Workspace/CheckpointStore.swift b/Sources/Workspace/CheckpointStore.swift index 4ab9609..3ae133b 100644 --- a/Sources/Workspace/CheckpointStore.swift +++ b/Sources/Workspace/CheckpointStore.swift @@ -80,7 +80,9 @@ actor InMemoryCheckpointStore: CheckpointStore { /// A JSON file-backed checkpoint store. /// /// Mutations are stored as one JSON line per record in `mutations.jsonl` (append-friendly under -/// the lock) so each append rewrites a tiny tail instead of the entire log. A legacy +/// the lock). Appends derive the next sequence from the log's final record instead of re-reading +/// the whole log, so appends stay cheap as histories grow. A partial trailing line left by a +/// crashed append is skipped when reading and truncated before the next append. A legacy /// `mutations.json` array is migrated to JSONL on the next read or append. Writes are /// synchronized through a persistent sidecar lockfile (`mutations.lock`) with an advisory /// exclusive lock when the platform supports it (`flock`), so concurrent ``FileCheckpointStore`` @@ -172,10 +174,19 @@ actor FileCheckpointStore: CheckpointStore { let legacy = legacyMutationsArrayURL(workspaceId: mutation.workspaceId) let lockURL = mutationsLockURL(workspaceId: mutation.workspaceId) return try Self.withMutationsExclusiveLock(at: lockURL) { - let existing = try loadAllMutations(jsonl: jsonl, legacy: legacy) - let next = (existing.map(\.sequence).max() ?? 0) + 1 + let lastSequence: Int + if fileManager.fileExists(atPath: jsonl.path) { + if fileManager.fileExists(atPath: legacy.path) { + try? fileManager.removeItem(at: legacy) + } + try repairTornTail(at: jsonl) + lastSequence = try lastPersistedSequence(at: jsonl) + } else { + // First write, or a legacy `mutations.json` array awaiting migration. + lastSequence = try loadAllMutations(jsonl: jsonl, legacy: legacy).map(\.sequence).max() ?? 0 + } var record = mutation - record.sequence = next + record.sequence = lastSequence + 1 if fileManager.fileExists(atPath: jsonl.path) { try appendJSONLLine(encode: record, to: jsonl) @@ -222,16 +233,88 @@ actor FileCheckpointStore: CheckpointStore { private func loadMutationsFromJSONL(at url: URL) throws -> [MutationRecord] { let data = try Data(contentsOf: url) if data.isEmpty { return [] } + let endsWithNewline = data.last == UInt8(ascii: "\n") let text = String(data: data, encoding: .utf8) ?? "" + let lines = text.split(whereSeparator: \.isNewline) var out: [MutationRecord] = [] - out.reserveCapacity(64) - for line in text.split(whereSeparator: \.isNewline) { + out.reserveCapacity(lines.count) + for (index, line) in lines.enumerated() { if line.isEmpty { continue } - out.append(try decoder.decode(MutationRecord.self, from: Data(String(line).utf8))) + do { + out.append(try decoder.decode(MutationRecord.self, from: Data(String(line).utf8))) + } catch { + // A crashed append leaves a strict prefix of `\n`: an undecodable final + // line with no trailing newline. Tolerate exactly that torn tail; anything else + // is real corruption and must surface. + if index == lines.count - 1, !endsWithNewline { + continue + } + throw error + } } return out } + /// Truncates a partial trailing line left behind by a crashed append so subsequent appends + /// do not glue new records onto the torn fragment. A torn append never contains a newline + /// (it is a strict prefix of `\n`), so everything after the final newline — or the + /// whole file when none exists — is the torn region. Must be called under the mutations lock. + private func repairTornTail(at url: URL) throws { + let handle = try FileHandle(forUpdating: url) + defer { try? handle.close() } + let size = try handle.seekToEnd() + guard size > 0 else { return } + try handle.seek(toOffset: size - 1) + let lastByte = try handle.read(upToCount: 1) + guard lastByte != Data("\n".utf8) else { return } + let newlineOffset = try Self.lastNewlineOffset(in: handle, before: size - 1) + try handle.truncate(atOffset: newlineOffset.map { $0 + 1 } ?? 0) + } + + /// Returns the sequence of the final record without loading the whole log. Appends are + /// strictly monotonic and full rewrites are sorted by sequence, so the last line always + /// carries the maximum. Falls back to a full scan for unusual tails such as blank trailing + /// lines. Must be called under the mutations lock, after ``repairTornTail(at:)``. + private func lastPersistedSequence(at url: URL) throws -> Int { + if let line = try lastLine(at: url), + let record = try? decoder.decode(MutationRecord.self, from: line) { + return record.sequence + } + return try loadMutationsFromJSONL(at: url).map(\.sequence).max() ?? 0 + } + + private func lastLine(at url: URL) throws -> Data? { + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + let size = try handle.seekToEnd() + guard size > 1 else { return nil } + // The file ends with `\n` after tail repair; the last line spans the byte after the + // previous newline through the byte before the trailing newline. + let lineEnd = size - 1 + let newlineOffset = try Self.lastNewlineOffset(in: handle, before: lineEnd) + let lineStart = newlineOffset.map { $0 + 1 } ?? 0 + guard lineEnd > lineStart else { return nil } + try handle.seek(toOffset: lineStart) + let line = try handle.read(upToCount: Int(lineEnd - lineStart)) ?? Data() + return line.isEmpty ? nil : line + } + + /// Scans backwards in chunks for the offset of the last `\n` strictly before `end`. + private static func lastNewlineOffset(in handle: FileHandle, before end: UInt64) throws -> UInt64? { + let chunkSize: UInt64 = 65536 + var cursor = end + while cursor > 0 { + let chunkStart = cursor > chunkSize ? cursor - chunkSize : 0 + try handle.seek(toOffset: chunkStart) + let chunk = try handle.read(upToCount: Int(cursor - chunkStart)) ?? Data() + if let index = chunk.lastIndex(of: UInt8(ascii: "\n")) { + return chunkStart + UInt64(chunk.distance(from: chunk.startIndex, to: index)) + } + cursor = chunkStart + } + return nil + } + private func writeAllMutationsAsJSONL(_ records: [MutationRecord], to url: URL) throws { if records.isEmpty { if fileManager.fileExists(atPath: url.path) { diff --git a/Sources/Workspace/FS/FileSystem.swift b/Sources/Workspace/FS/FileSystem.swift index 63f7e62..cd17ff2 100644 --- a/Sources/Workspace/FS/FileSystem.swift +++ b/Sources/Workspace/FS/FileSystem.swift @@ -22,6 +22,9 @@ public enum WorkspaceError: Error, CustomStringConvertible, Sendable { case snapshotNotFound(UUID) /// The parent workspace changed after a branch was created, so the branch cannot be merged. case mergeConflict(parentWorkspaceId: UUID, expectedBase: UUID?, actualHead: UUID?) + /// The parent workspace has tracked mutations that no checkpoint captures, so merging would + /// silently discard them. + case mergeUncheckpointedChanges(parentWorkspaceId: UUID, baseMutationCursor: Int, currentMutationCursor: Int) /// A tracked workspace mutation could not be recorded. case mutationFailed(String) @@ -53,6 +56,8 @@ public enum WorkspaceError: Error, CustomStringConvertible, Sendable { let expected = expectedBase?.uuidString ?? "nil" let actual = actualHead?.uuidString ?? "nil" return "cannot merge branch into workspace \(parentWorkspaceId.uuidString): expected base \(expected), current head is \(actual)" + case let .mergeUncheckpointedChanges(parentWorkspaceId, baseMutationCursor, currentMutationCursor): + return "cannot merge branch into workspace \(parentWorkspaceId.uuidString): the workspace has uncheckpointed changes (mutation sequence \(currentMutationCursor), branch base \(baseMutationCursor)); create a checkpoint or roll back before merging" case let .mutationFailed(message): return message } diff --git a/Sources/Workspace/FS/ReadWriteFilesystem.swift b/Sources/Workspace/FS/ReadWriteFilesystem.swift index 76fc7df..0a361ac 100644 --- a/Sources/Workspace/FS/ReadWriteFilesystem.swift +++ b/Sources/Workspace/FS/ReadWriteFilesystem.swift @@ -301,9 +301,25 @@ public final class ReadWriteFilesystem: FileSystem, @unchecked Sendable { let url = try existingOrPotentialURL(for: virtualPath, configuration: configuration) let parent = url.deletingLastPathComponent() try ensureInsideRoot(parent, configuration: configuration) + try ensureDestinationSymlinkStaysInsideRoot(url, configuration: configuration) return url } + /// The parent containment check never resolves the destination's final component, so a + /// pre-existing symlink at the destination could redirect follow-on opens (for example an + /// append) outside the configured root. Reject destinations whose final component is a + /// symlink resolving outside the root. + private func ensureDestinationSymlinkStaysInsideRoot( + _ url: URL, + configuration: ConfigurationSnapshot + ) throws { + let attributes = try? fileManager.attributesOfItem(atPath: url.path) + guard attributes?[.type] as? FileAttributeType == .typeSymbolicLink else { + return + } + try ensureInsideRoot(url, configuration: configuration) + } + private func ensureInsideRoot(_ url: URL, configuration: ConfigurationSnapshot) throws { let resolved = url.resolvingSymlinksInPath().standardizedFileURL.path let root = configuration.resolvedRootPath diff --git a/Sources/Workspace/Workspace+Branching.swift b/Sources/Workspace/Workspace+Branching.swift index 1be6729..0359768 100644 --- a/Sources/Workspace/Workspace+Branching.swift +++ b/Sources/Workspace/Workspace+Branching.swift @@ -25,7 +25,8 @@ extension Workspace { workspaceId: UUID(), filesystem: branchFilesystem, store: store, - baseCheckpointId: baseCheckpointId + baseCheckpointId: baseCheckpointId, + baseMutationCursor: latestMutationSequence() ) _ = try await branch.seedBranchCheckpoint( snapshot: snapshot, @@ -36,6 +37,12 @@ extension Workspace { } /// Merges another workspace into this workspace when this workspace still points at the other's base. + /// + /// The merge is refused when this workspace's checkpoint head moved past the branch's base + /// (``WorkspaceError/mergeConflict(parentWorkspaceId:expectedBase:actualHead:)``) or when this + /// workspace has tracked mutations that no checkpoint captures + /// (``WorkspaceError/mergeUncheckpointedChanges(parentWorkspaceId:baseMutationCursor:currentMutationCursor:)``), + /// since restoring the branch snapshot would silently discard those edits. public func merge(_ other: Workspace, label: String? = nil) async throws -> Checkpoint { try await ensureLoaded() try await reconcileCheckpointsWithStore() @@ -50,6 +57,20 @@ extension Workspace { ) } + // The head comparison only sees checkpoints. Tracked writes made after the branch was + // created leave the head untouched, so restoring the branch snapshot would silently + // discard them; refuse the merge instead. + if let expectedCursor = await other.mergeBaseMutationCursor() { + let currentCursor = latestMutationSequence() + guard currentCursor <= expectedCursor else { + throw WorkspaceError.mergeUncheckpointedChanges( + parentWorkspaceId: workspaceId, + baseMutationCursor: expectedCursor, + currentMutationCursor: currentCursor + ) + } + } + let previousSnapshot = try await captureSnapshot() let incomingSnapshot = try await other.captureSnapshot() let incomingHead = try await other.currentHeadCheckpointId() @@ -99,6 +120,10 @@ extension Workspace { baseCheckpointId } + func mergeBaseMutationCursor() -> Int? { + baseMutationCursor + } + func currentHeadCheckpointId() async throws -> UUID? { try await ensureLoaded() return headCheckpointId diff --git a/Sources/Workspace/Workspace.swift b/Sources/Workspace/Workspace.swift index 8844684..d82df07 100644 --- a/Sources/Workspace/Workspace.swift +++ b/Sources/Workspace/Workspace.swift @@ -31,6 +31,9 @@ public actor Workspace { public nonisolated let filesystem: any FileSystem let store: any CheckpointStore let baseCheckpointId: UUID? + /// The parent's mutation sequence at the moment this workspace was branched. Merge uses it to + /// detect tracked-but-uncheckpointed parent edits that a snapshot restore would discard. + let baseMutationCursor: Int? var loadTask: Task? var didLoadStoreState = false @@ -77,12 +80,14 @@ public actor Workspace { workspaceId: UUID = UUID(), filesystem: any FileSystem, store: any CheckpointStore, - baseCheckpointId: UUID? = nil + baseCheckpointId: UUID? = nil, + baseMutationCursor: Int? = nil ) { self.workspaceId = workspaceId self.filesystem = filesystem self.store = store self.baseCheckpointId = baseCheckpointId + self.baseMutationCursor = baseMutationCursor } static func makeStore(for storage: Storage) -> any CheckpointStore { diff --git a/Tests/WorkspaceTests/CheckpointStoreTests.swift b/Tests/WorkspaceTests/CheckpointStoreTests.swift index 00b03bd..e2cd2ec 100644 --- a/Tests/WorkspaceTests/CheckpointStoreTests.swift +++ b/Tests/WorkspaceTests/CheckpointStoreTests.swift @@ -399,6 +399,96 @@ struct CheckpointStoreTests { #expect(Set(mutations.map(\.sequence)) == expectedSequences) } + @Test + func `FileCheckpointStore skips and repairs a torn trailing mutation line`() async throws { + let root = try makeTempDirectory() + defer { removeTempDirectory(root) } + + let workspaceId = UUID() + let store = FileCheckpointStore(rootDirectory: root) + + for index in 0..<2 { + let mutation = MutationRecord( + sequence: 0, + workspaceId: workspaceId, + kind: .writeFile, + touchedPaths: [WorkspacePath(normalizing: "/file-\(index).txt")], + fileChanges: [] + ) + try await store.appendMutation(mutation) + } + + // Simulate a crash mid-append: a partial record with no trailing newline. + let jsonl = mutationsJsonlURL(root: root, workspaceId: workspaceId) + let handle = try FileHandle(forWritingTo: jsonl) + try handle.seekToEnd() + try handle.write(contentsOf: Data("{\"sequence\":3,\"worksp".utf8)) + try handle.close() + + // Reads skip the torn tail instead of poisoning the whole log. + let reader = FileCheckpointStore(rootDirectory: root) + let beforeRepair = try await reader.listMutationRecords(workspaceId: workspaceId) + #expect(beforeRepair.map(\.sequence) == [1, 2]) + + // The next append truncates the torn tail and continues the sequence. + let appended = try await reader.appendMutation( + MutationRecord( + sequence: 0, + workspaceId: workspaceId, + kind: .writeFile, + touchedPaths: [WorkspacePath(normalizing: "/file-2.txt")], + fileChanges: [] + ) + ) + #expect(appended.sequence == 3) + + let afterRepair = try await reader.listMutationRecords(workspaceId: workspaceId) + #expect(afterRepair.map(\.sequence) == [1, 2, 3]) + #expect(afterRepair.allSatisfy { $0.workspaceId == workspaceId }) + } + + @Test + func `FileCheckpointStore still surfaces corruption before the final line`() async throws { + let root = try makeTempDirectory() + defer { removeTempDirectory(root) } + + let workspaceId = UUID() + let store = FileCheckpointStore(rootDirectory: root) + + for index in 0..<2 { + let mutation = MutationRecord( + sequence: 0, + workspaceId: workspaceId, + kind: .writeFile, + touchedPaths: [WorkspacePath(normalizing: "/file-\(index).txt")], + fileChanges: [] + ) + try await store.appendMutation(mutation) + } + + // Corrupt the first record while keeping the file newline-terminated: this is real + // mid-file damage, not a torn append, and must not be silently skipped. + let jsonl = mutationsJsonlURL(root: root, workspaceId: workspaceId) + let contents = try String(contentsOf: jsonl, encoding: .utf8) + var lines = contents.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + lines[0] = "not json" + try Data(lines.joined(separator: "\n").utf8).write(to: jsonl) + + let reader = FileCheckpointStore(rootDirectory: root) + do { + _ = try await reader.listMutationRecords(workspaceId: workspaceId) + Issue.record("expected mid-file corruption to surface as an error") + } catch { + // Expected: only a torn tail without a trailing newline is tolerated. + } + } + + private func mutationsJsonlURL(root: URL, workspaceId: UUID) -> URL { + root + .appendingPathComponent(workspaceId.uuidString, isDirectory: true) + .appendingPathComponent("mutations.jsonl", isDirectory: false) + } + private func makeTempDirectory() throws -> URL { let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) let url = base.appendingPathComponent("CheckpointStoreTests-\(UUID().uuidString)", isDirectory: true) diff --git a/Tests/WorkspaceTests/FilesystemTests.swift b/Tests/WorkspaceTests/FilesystemTests.swift index 2fd2c1d..eaa6d11 100644 --- a/Tests/WorkspaceTests/FilesystemTests.swift +++ b/Tests/WorkspaceTests/FilesystemTests.swift @@ -154,6 +154,52 @@ struct FilesystemTests { } } + @Test(.tags(.readWrite)) + func `read-write filesystem rejects writes through symlink escaping root`() async throws { + let root = try FilesystemTestSupport.makeTempDirectory(prefix: "FilesystemRoot") + defer { FilesystemTestSupport.removeDirectory(root) } + + let outside = try FilesystemTestSupport.makeTempDirectory(prefix: "FilesystemOutside") + defer { FilesystemTestSupport.removeDirectory(outside) } + + let outsideFile = outside.appendingPathComponent("target.txt") + try Data("original".utf8).write(to: outsideFile) + + let filesystem = try ReadWriteFilesystem(rootDirectory: root) + try await filesystem.createSymlink(path: "/leak", target: outsideFile.path) + + do { + try await filesystem.writeFile(path: "/leak", data: Data("injected".utf8), append: true) + Issue.record("expected invalid path error for append through escaping symlink") + } catch let error as WorkspaceError { + #expect(error.description.contains("invalid path")) + } + + do { + try await filesystem.writeFile(path: "/leak", data: Data("injected".utf8), append: false) + Issue.record("expected invalid path error for write through escaping symlink") + } catch let error as WorkspaceError { + #expect(error.description.contains("invalid path")) + } + + #expect(try Data(contentsOf: outsideFile) == Data("original".utf8)) + } + + @Test(.tags(.readWrite)) + func `read-write filesystem still appends through symlink inside root`() async throws { + let root = try FilesystemTestSupport.makeTempDirectory(prefix: "FilesystemRoot") + defer { FilesystemTestSupport.removeDirectory(root) } + + let filesystem = try ReadWriteFilesystem(rootDirectory: root) + try await filesystem.writeFile(path: "/real.txt", data: Data("one".utf8), append: false) + try await filesystem.createSymlink(path: "/alias", target: "real.txt") + + try await filesystem.writeFile(path: "/alias", data: Data(" two".utf8), append: true) + + let data = try await filesystem.readFile(path: "/real.txt") + #expect(String(decoding: data, as: UTF8.self) == "one two") + } + @Test(.tags(.inMemory)) func `in-memory filesystem reset clears prior contents`() async throws { let filesystem = InMemoryFilesystem() diff --git a/Tests/WorkspaceTests/WorkspaceCheckpointTests.swift b/Tests/WorkspaceTests/WorkspaceCheckpointTests.swift index e859d53..2934ef2 100644 --- a/Tests/WorkspaceTests/WorkspaceCheckpointTests.swift +++ b/Tests/WorkspaceTests/WorkspaceCheckpointTests.swift @@ -194,6 +194,52 @@ struct WorkspaceCheckpointTests { } } + @Test + func `merge refuses when parent has uncheckpointed tracked changes`() async throws { + let workspace = Workspace(filesystem: InMemoryFilesystem()) + try await workspace.writeFile("/note.txt", content: "base") + _ = try await workspace.createCheckpoint(label: "base") + + let branch = try await workspace.branch() + try await branch.writeFile("/note.txt", content: "branch") + _ = try await branch.createCheckpoint(label: "branch head") + + // Tracked parent edits after branching, without a checkpoint: the head still matches the + // branch base, so only the mutation cursor can reveal them. + try await workspace.writeFile("/parent-only.txt", content: "keep me") + + do { + _ = try await workspace.merge(branch) + Issue.record("expected merge to refuse uncheckpointed parent changes") + } catch let error as WorkspaceError { + guard case let .mergeUncheckpointedChanges(parentWorkspaceId, baseCursor, currentCursor) = error else { + Issue.record("unexpected workspace error: \(error)") + return + } + #expect(parentWorkspaceId == workspace.workspaceId) + #expect(currentCursor > baseCursor) + } + + #expect(try await workspace.readFile("/parent-only.txt") == "keep me") + #expect(try await workspace.readFile("/note.txt") == "base") + } + + @Test + func `merge allows parent writes made before the branch`() async throws { + let workspace = Workspace(filesystem: InMemoryFilesystem()) + try await workspace.writeFile("/note.txt", content: "base") + _ = try await workspace.createCheckpoint(label: "base") + try await workspace.writeFile("/pre-branch.txt", content: "pre") + + let branch = try await workspace.branch() + try await branch.writeFile("/note.txt", content: "branch") + + _ = try await workspace.merge(branch) + + #expect(try await workspace.readFile("/note.txt") == "branch") + #expect(try await workspace.readFile("/pre-branch.txt") == "pre") + } + @Test func `file backed storage reloads checkpoint snapshot artifacts`() async throws { let root = try makeTempDirectory()