From 52d2d26f8bca8d056e8be369cfbfd525fb654b91 Mon Sep 17 00:00:00 2001 From: Zac White Date: Thu, 9 Jul 2026 19:10:58 -0700 Subject: [PATCH 1/8] Redesign Workspace around revisions and transactions --- README.md | 400 ++-- Sources/Workspace/ChangeEvent.swift | 42 - .../Workspace/ChangeSet+RevisionIndex.swift | 160 ++ Sources/Workspace/ChangeSet+Snapshot.swift | 103 ++ Sources/Workspace/Checkpoint.swift | 97 +- Sources/Workspace/CheckpointStore.swift | 292 ++- Sources/Workspace/FS/FileSystem.swift | 62 +- Sources/Workspace/FS/InMemoryFilesystem.swift | 6 +- Sources/Workspace/FS/LimitedFileSystem.swift | 144 ++ .../Workspace/FS/MountableFilesystem.swift | 45 +- Sources/Workspace/FS/OverlayFilesystem.swift | 48 +- .../Workspace/FS/ReadWriteFilesystem.swift | 17 +- Sources/Workspace/FS/SandboxFilesystem.swift | 15 +- .../FS/SecurityScopedFilesystem.swift | 18 +- Sources/Workspace/FileEdit.swift | 149 -- Sources/Workspace/FileTree.swift | 108 +- Sources/Workspace/MutationRecord.swift | 58 - Sources/Workspace/RevisionIndex.swift | 88 + Sources/Workspace/Snapshot.swift | 136 +- Sources/Workspace/Support/Permissions.swift | 163 +- Sources/Workspace/Support/WorkspacePath.swift | 54 +- Sources/Workspace/TextDiff.swift | 190 +- Sources/Workspace/Workspace+Branching.swift | 369 ---- Sources/Workspace/Workspace+Checkpoints.swift | 85 +- Sources/Workspace/Workspace+Edits.swift | 218 +++ Sources/Workspace/Workspace+Internals.swift | 439 +---- Sources/Workspace/Workspace+Merge.swift | 74 + Sources/Workspace/Workspace.swift | 1641 +---------------- Sources/Workspace/WorkspaceArchive.swift | 123 ++ Sources/Workspace/WorkspaceChanges.swift | 129 ++ Sources/Workspace/WorkspaceEdit.swift | 68 + Sources/Workspace/WorkspaceEvent.swift | 69 + Sources/Workspace/WorkspaceHistory.swift | 39 + Sources/Workspace/WorkspaceMutation.swift | 33 - Sources/Workspace/WorkspaceReplace.swift | 156 -- Sources/Workspace/WorkspaceRevision.swift | 256 +++ Sources/Workspace/WorkspaceSearch.swift | 178 ++ Sources/Workspace/WorkspaceTransaction.swift | 410 ++++ .../WorkspaceTests/CheckpointStoreTests.swift | 664 +------ Tests/WorkspaceTests/CheckpointTests.swift | 126 -- Tests/WorkspaceTests/CoreTests.swift | 1241 ------------- Tests/WorkspaceTests/FileSystemAPITests.swift | 135 ++ Tests/WorkspaceTests/FilesystemTests.swift | 110 +- Tests/WorkspaceTests/MountingTests.swift | 144 +- Tests/WorkspaceTests/PersistenceTests.swift | 90 + Tests/WorkspaceTests/SnapshotTests.swift | 173 +- Tests/WorkspaceTests/TestSupport.swift | 14 + Tests/WorkspaceTests/TextDiffTests.swift | 16 +- Tests/WorkspaceTests/ThreeWayMergeTests.swift | 144 -- .../WorkspaceTests/TrackingPolicyTests.swift | 104 -- Tests/WorkspaceTests/TransactionTests.swift | 94 + Tests/WorkspaceTests/WorkspaceAPITests.swift | 136 ++ .../WorkspaceCheckpointTests.swift | 456 ----- .../WorkspaceInternalsTests.swift | 286 --- 54 files changed, 3860 insertions(+), 6755 deletions(-) delete mode 100644 Sources/Workspace/ChangeEvent.swift create mode 100644 Sources/Workspace/ChangeSet+RevisionIndex.swift create mode 100644 Sources/Workspace/ChangeSet+Snapshot.swift create mode 100644 Sources/Workspace/FS/LimitedFileSystem.swift delete mode 100644 Sources/Workspace/FileEdit.swift delete mode 100644 Sources/Workspace/MutationRecord.swift create mode 100644 Sources/Workspace/RevisionIndex.swift delete mode 100644 Sources/Workspace/Workspace+Branching.swift create mode 100644 Sources/Workspace/Workspace+Edits.swift create mode 100644 Sources/Workspace/Workspace+Merge.swift create mode 100644 Sources/Workspace/WorkspaceArchive.swift create mode 100644 Sources/Workspace/WorkspaceChanges.swift create mode 100644 Sources/Workspace/WorkspaceEdit.swift create mode 100644 Sources/Workspace/WorkspaceEvent.swift create mode 100644 Sources/Workspace/WorkspaceHistory.swift delete mode 100644 Sources/Workspace/WorkspaceMutation.swift delete mode 100644 Sources/Workspace/WorkspaceReplace.swift create mode 100644 Sources/Workspace/WorkspaceRevision.swift create mode 100644 Sources/Workspace/WorkspaceSearch.swift create mode 100644 Sources/Workspace/WorkspaceTransaction.swift delete mode 100644 Tests/WorkspaceTests/CheckpointTests.swift delete mode 100644 Tests/WorkspaceTests/CoreTests.swift create mode 100644 Tests/WorkspaceTests/FileSystemAPITests.swift create mode 100644 Tests/WorkspaceTests/PersistenceTests.swift create mode 100644 Tests/WorkspaceTests/TestSupport.swift delete mode 100644 Tests/WorkspaceTests/ThreeWayMergeTests.swift delete mode 100644 Tests/WorkspaceTests/TrackingPolicyTests.swift create mode 100644 Tests/WorkspaceTests/TransactionTests.swift create mode 100644 Tests/WorkspaceTests/WorkspaceAPITests.swift delete mode 100644 Tests/WorkspaceTests/WorkspaceCheckpointTests.swift delete mode 100644 Tests/WorkspaceTests/WorkspaceInternalsTests.swift diff --git a/README.md b/README.md index 8e499da..aba05ab 100644 --- a/README.md +++ b/README.md @@ -1,358 +1,232 @@ # Workspace -`Workspace` is a shell-agnostic Swift package for building agent and tool runtimes around a controlled filesystem model. - -It gives you: -- virtual filesystem abstractions -- rooted and jailed disk access -- in-memory filesystems -- copy-on-write overlays -- mounted multi-root workspaces -- explicit permission checks for file operations -- one `Workspace` actor for reads, tracked writes, tree summaries, snapshots, checkpoints, rollback, branching, and merge - -`Workspace` is beta software and should be used at your own risk. It is useful for app and agent workflows, but it is not a hardened sandbox or a security boundary by itself. - -## Why - -Many agent and tooling flows need more than plain disk I/O: -- one isolated workspace per task -- a shared scratch or memory area -- the ability to read a real project without writing back to it -- explicit approvals before reads or writes -- tree summaries, JSON helpers, batched edits, and checkpoints without shell parsing - -`Workspace` provides one model for those cases. You can back it with memory, a rooted directory on disk, an overlay snapshot, or a mounted combination of several filesystems. - -## What It Provides - -- `Workspace`: high-level actor API for file operations, mutation tracking, snapshots, checkpoints, rollback, branch, and merge -- `Checkpoint` and `CheckpointEvent`: public checkpoint metadata and event stream values -- `Snapshot`: durable capture/restore of a subtree -- `ChangeEvent`: structured change notifications emitted by `Workspace.watchChanges(at:recursive:)` -- `FileSystem`: low-level protocol for custom filesystem backends -- `ReadWriteFilesystem`: real disk access rooted to a configured directory -- `InMemoryFilesystem`: fully in-memory filesystem for isolated workspaces and tests -- `OverlayFilesystem`: lazy copy-on-write view of a disk root — reads pass through, writes stay in memory -- `MountableFilesystem`: compose multiple filesystems under one virtual tree -- `PermissionedFileSystem`: wrap any filesystem with operation-level approvals -- `SandboxFilesystem`: convenience wrapper for app sandbox roots -- `SecurityScopedFilesystem`: security-scoped URL and bookmark-backed access -- `WorkspacePath`: path normalization and joining helpers +`Workspace` is a Swift package for agent and tool runtimes that need controlled filesystem access, structured edits, revision history, and isolated transactions without shell parsing. -## Installation +The package is beta software. Its rooted filesystems, authorization rules, and limits are useful safety layers, but they are not a hardened process sandbox. -Until this package is published to a remote, use it as a local SwiftPM dependency: +## Installation ```swift .dependencies: [ .package(path: "../Workspace") -], -.targets: [ - .target( - name: "YourTarget", - dependencies: ["Workspace"] - ) ] ``` -## Workspace +The package contains one library product, `Workspace`, and has no external dependencies. -Create an ephemeral workspace with the default initializer: +## The Model -```swift -import Workspace +The public API centers on a small set of concepts: -let workspace = Workspace() -try await workspace.writeFile("/notes/todo.txt", content: "ship it") +- `Workspace` owns current mutable state, history, checkpoints, and events. +- `Revision` addresses either the current state or a checkpoint. +- `Edit` describes a mutation. +- `ChangeSet` is the common result used by previews, edits, history, events, diffs, and transactions. +- `Workspace.Transaction` provides isolated preview, three-way commit, and discard. +- `FileSystem` is the low-level protocol for custom storage backends. -let text = try await workspace.readFile("/notes/todo.txt") -print(text) // ship it -``` +## Create a Workspace -Use a custom filesystem when the files should come from memory, disk, an overlay, a mount table, or a permission wrapper: +An empty in-memory workspace needs no configuration: ```swift -let filesystem = InMemoryFilesystem() -let workspace = Workspace(filesystem: filesystem) -``` - -After a workspace is created, `workspace.filesystem` exposes the backing filesystem as a normal -`FileSystem`. The property itself is immutable, but the returned filesystem keeps its read/write -capabilities: +import Workspace -```swift -let filesystem: any FileSystem = workspace.filesystem -let data = try await filesystem.readFile(path: "/notes/todo.txt") +let workspace = Workspace() +try await workspace.writeText("/notes/todo.txt", "ship it\n") +let text = try await workspace.readText("/notes/todo.txt") ``` -Persist checkpoint artifacts with file-backed storage: +Use a rooted local filesystem and persisted revision store when state should survive process exit: ```swift -let root = URL(fileURLWithPath: "/tmp/workspace-checkpoints", isDirectory: true) +let files = try LocalFileSystem(root: projectURL) let workspace = Workspace( - filesystem: InMemoryFilesystem(), - storage: .directory(at: root) + workspaceID: projectID, + fileSystem: files, + persistence: .directory(historyURL), + recording: .full(maxTextBytes: 1_000_000) ) ``` -`Storage.directory(at:)` writes checkpoint metadata, snapshot manifests, and a `mutations.jsonl` append log (one JSON record per line) under `//`. Snapshots are content-addressed: file bytes are stored once per unique content in `blobs/`, and each snapshot manifest references them by hash — so consecutive checkpoints share unchanged file content instead of re-serializing the whole tree, and contents are stored raw rather than base64-inflated. Legacy inline-snapshot JSON files and a legacy `mutations.json` array are still read (the latter is migrated to JSONL on first access). Blobs are retained until pruned by future GC tooling. 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 - -```swift -let data = try await workspace.readData(from: "/blob.bin") -let text = try await workspace.readFile("/notes/todo.txt") -let exists = await workspace.exists("/notes/todo.txt") -let info = try await workspace.fileInfo(at: "/notes/todo.txt") -let entries = try await workspace.listDirectory(at: "/notes") -let matches = try await workspace.glob("/notes/*.txt", currentDirectory: "/") -let tree = try await workspace.walkTree("/") -let summary = try await workspace.summarizeTree("/") -``` +The injected filesystem is intentionally not exposed from `Workspace`; mutations through it would bypass workspace history and events. -Glob wildcards use shell semantics: `*` and `?` match within a single path component, `**` matches recursively across components, and character classes support negation (`[!abc]`). +## Edits and Changes -Ranged reads avoid loading whole files when the backing filesystem supports seeking: +Every mutation returns the same structured `ChangeSet`: ```swift -let slice = try await workspace.readData(from: "/blob.bin", offset: 1024, length: 4096) -``` - -At the `FileSystem` level, implementations also provide `readFileChunks(path:chunkSize:)` for streaming reads, `createFile(path:data:)` for exclusive creation (fails with `EEXIST`), and `capabilities()` to query optional features (symlinks, hard links, permissions, real-path resolution) without probe-and-catch. - -JSON helpers encode and decode through `Codable`: +let preview = try await workspace.preview([ + .createDirectory("/Sources"), + .writeText("/Sources/main.swift", "print(\"hello\")\n") +]) -```swift -struct Config: Codable { - var name: String - var enabled: Bool -} +let result = try await workspace.apply([ + .createDirectory("/Sources"), + .writeText("/Sources/main.swift", "print(\"hello\")\n") +]) -try await workspace.writeJSON(Config(name: "demo", enabled: true), to: "/config.json") -let config: Config = try await workspace.readJSON(from: "/config.json") -print(config.enabled) // true +print(result.changes.touchedPaths) +print(result.changes.statistics.additions) ``` -### Tracked Writes +`apply` is atomic by default. `.stopOnError` and `.continueAfterError` expose partial failures explicitly. -Every public write records a mutation. The history is public — `mutationRecords()` returns the ordered records including per-file effects and text diffs — and the amount of detail is configurable per workspace: +Direct conveniences—`writeText`, `writeData`, `appendText`, `createDirectory`, `remove`, `copy`, `move`, link creation, and permission changes—use the same edit pipeline. + +Text replacement is an edit and shares its file selection and pattern engine with search: ```swift -let workspace = Workspace( - filesystem: InMemoryFilesystem(), - tracking: .fullDiffs(maxDiffBytes: 1_000_000) // cap diff computation to 1 MB files +let swiftFiles = FileSelection( + root: "/Sources", + include: ["**/*.swift"], + exclude: ["**/Generated/**"] ) -// .full — full diffs, no size cap (default) -// .pathsOnly — touched paths and effects, no text diffs -// .disabled — no history at all; change events still fire, but merge cannot -// detect uncheckpointed edits made in this mode -``` - -Tracked writes: -```swift -try await workspace.writeFile("/notes/todo.txt", content: "one") -try await workspace.appendFile("/notes/todo.txt", content: " two") -try await workspace.writeData(Data([0xDE, 0xAD]), to: "/blob.bin") -try await workspace.createDirectory(at: "/docs") -try await workspace.copyItem(from: "/notes/todo.txt", to: "/docs/todo.txt") -try await workspace.moveItem(from: "/docs/todo.txt", to: "/docs/done.txt") -try await workspace.removeItem(at: "/docs/done.txt") +try await workspace.apply([ + .replace(files: swiftFiles, pattern: .literal("OldName"), with: "NewName") +]) ``` -Batched edits and replacements can be previewed before execution: +## Search ```swift -let preview = try await workspace.previewEdits([ - .createDirectory(path: "/src"), - .writeFile(path: "/src/a.txt", content: "one"), -]) - -let result = try await workspace.applyEdits([ - .appendFile(path: "/src/a.txt", content: " two"), -]) - -let replacement = try await workspace.applyReplacement( - ReplacementRequest(pattern: "/src/*.txt", search: "one", replacement: "1") +let result = try await workspace.search( + SearchRequest( + pattern: .regularExpression(#"\bTODO\b"#), + files: swiftFiles, + contextLines: 2 + ) ) -print(preview.mode) // preview -print(result.mode) // execution -print(replacement.mode) // execution +for match in result.matches { + print("\(match.path):\(match.lineNumber): \(match.line)") +} ``` -`applyEdits` and `applyReplacement` use logical rollback when `failurePolicy` is `.rollback`. Other policies may leave partial changes in place. +Search is deterministic, line-oriented, does not follow symlinks, and reports binary, oversized, or truncated work as structured result data. -### Watchers +## Checkpoints, Revisions, and Diffs -```swift -let changes = await workspace.watchChanges(at: "/notes") - -Task { - for await change in changes { - print(change.kind, change.path) - } -} - -try await workspace.writeFile("/notes/todo.txt", content: "ship it") -``` - -Checkpoint events are separate from file change events: +Checkpoints are persisted revisions; there is no second public snapshot representation. ```swift -let checkpoints = try await workspace.watchCheckpointEvents() +let before = try await workspace.createCheckpoint(label: "before") +try await workspace.writeText("/README.md", "Updated\n") +let after = try await workspace.createCheckpoint(label: "after") -Task { - for await event in checkpoints { - print(event.kind, event.checkpoint.label ?? "") - } -} +let oldText = try await workspace.readText( + "/README.md", + at: .checkpoint(before.id) +) + +let changes = try await workspace.diff( + from: .checkpoint(before.id), + to: .checkpoint(after.id) +) ``` -### Snapshots And Checkpoints +Checkpoint reads use manifest metadata and load only selected blobs. `TextDiff` includes total line counts, addition/deletion statistics, hunks, and UTF-16 intra-line ranges. -Snapshots capture filesystem contents. Checkpoints persist a snapshot plus lineage and summary metadata. +Use `Workspace.Archive` only when a fully materialized, portable subtree is actually required: ```swift -try await workspace.writeFile("/readme.txt", content: "v1") -let checkpoint = try await workspace.createCheckpoint(label: "before edits") - -try await workspace.writeFile("/readme.txt", content: "v2") -let rollback = try await workspace.rollback(to: checkpoint.id, label: "restore v1") - -let all = try await workspace.listCheckpoints() -let snapshot = try await workspace.snapshot(for: checkpoint) - -print(rollback.rollbackSourceCheckpointId == checkpoint.id) // true -print(all.count) -print(snapshot.rootPath) +let archive = try await workspace.archive(at: "/Sources") +try await anotherWorkspace.restore(archive, at: "/Imported") ``` -Public `restoreSnapshot(_:)` is also tracked as a workspace mutation. Checkpoint rollback records the tree changes it performs as a `rollback` mutation in addition to the rollback checkpoint, so the mutation log remains a complete account of filesystem changes. Long-running workspaces can bound the log with `pruneMutationHistory(throughSequence:)`, which always retains the newest record so sequence numbers stay monotonic. +## Transactions -### Branch And Merge - -Branches are isolated `Workspace` actors cloned from the parent's current snapshot. They share the checkpoint store but not the filesystem, watchers, or mutation sequence. By default `branch()` materializes the snapshot into a new `InMemoryFilesystem`; pass `filesystem:` to use another implementation (for example a `ReadWriteFilesystem` if the branch should live on disk). +Transactions replace separate branch and merge APIs: ```swift -try await workspace.writeFile("/readme.txt", content: "base") -let base = try await workspace.createCheckpoint(label: "base") - -let branch = try await workspace.branch(label: "agent draft") -try await branch.writeFile("/readme.txt", content: "draft") -let branchHead = try await branch.createCheckpoint(label: "draft ready") +let transaction = try await workspace.beginTransaction(label: "agent draft") +try await transaction.writeText("/README.md", "Draft\n") -let merged = try await workspace.merge(branch, label: "merge draft") +let preview = try await transaction.preview() +let commit = try await transaction.commit(strategy: .threeWay) -print(merged.parentCheckpointId == base.id) // true -print(merged.mergedFromWorkspaceId == branch.workspaceId) // true -print(merged.mergedFromCheckpointId == branchHead.id) // true +if !commit.applied { + for conflict in commit.conflicts { + print(conflict.path, conflict.kind) + } + try await transaction.discard() +} ``` -`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. - -`mergeThreeWay(_:label:)` merges even when both sides advanced. Each path is resolved against the branch's base checkpoint: the side that changed wins, identical changes merge cleanly, and when both sides changed the same path differently the merge applies nothing and returns structured conflicts instead of throwing: +Manual conflicted transactions remain active. The scoped convenience commits on success and discards on failure or conflict: ```swift -let result = try await workspace.mergeThreeWay(branch, label: "merge draft") -if result.applied { - print("merged as checkpoint \(result.checkpoint!.id)") -} else { - for conflict in result.conflicts { - print("conflict at \(conflict.path): \(conflict.kind)") - // conflict.oursDiff / conflict.theirsDiff carry base→side diffs for text files - } +let result = try await workspace.transaction { transaction in + try await transaction.writeText("/result.txt", "done\n") + return "agent result" } ``` -## Common Filesystem Patterns +Transactions are logically atomic within cooperative workspace use. They are not crash-safe against an external process racing the commit. -### Rooted Disk Workspace +## Events and History ```swift -let root = URL(fileURLWithPath: "/tmp/demo-workspace", isDirectory: true) -let filesystem = try ReadWriteFilesystem(rootDirectory: root) -let workspace = Workspace(filesystem: filesystem) +let events = await workspace.events() +Task { + for await event in events { + print(event) + } +} -try await workspace.createDirectory(at: "/src", recursive: false) -try await workspace.writeFile("/src/main.swift", content: "print(\"hello\")\n") +let mutations = try await workspace.history() ``` -### Overlay On Top Of A Real Project +Change events, returned edit results, and mutation history all carry the same `ChangeSet` representation. Checkpoint events share the same stream. -```swift -let projectRoot = URL(fileURLWithPath: "/path/to/project", isDirectory: true) -let filesystem = try await OverlayFilesystem(rootDirectory: projectRoot) -let workspace = Workspace(filesystem: filesystem) +## Filesystems and Safety Layers -let preview = try await workspace.summarizeTree("/Sources", maxDepth: 2) -try await workspace.writeFile("/SCRATCH.md", content: "overlay-only change\n") -``` +Built-in filesystems use consistent names and constructor-driven configuration: -The overlay is lazy: nothing is copied at configuration time, reads pass through to the source directory (so files that change on disk are visible immediately), and only mutated entries are copied up into memory. Deletions are recorded as whiteouts that hide the source entry without touching the disk. `reload()` discards all overlay writes and whiteouts. +- `InMemoryFileSystem` +- `LocalFileSystem` +- `OverlayFileSystem` +- `MountedFileSystem` +- `SandboxFileSystem` +- `SecurityScopedFileSystem` +- `AuthorizedFileSystem` +- `LimitedFileSystem` -### Mounted Workspaces +Compose wrappers explicitly: ```swift -let mounted = MountableFilesystem( - base: InMemoryFilesystem(), - mounts: [ - .init(mountPoint: "/workspace-a", filesystem: InMemoryFilesystem()), - .init(mountPoint: "/workspace-b", filesystem: InMemoryFilesystem()), - .init(mountPoint: "/memory", filesystem: InMemoryFilesystem()), - ] +let local = try LocalFileSystem(root: projectURL) +let limited = LimitedFileSystem( + base: local, + limits: FileSystemLimits( + maxTotalBytes: 50_000_000, + maxEntryCount: 10_000, + maxWriteBytes: 1_000_000 + ) ) -let workspace = Workspace(filesystem: mounted) -try await workspace.writeFile("/memory/plan.txt", content: "shared notes") -try await workspace.copyItem(from: "/memory/plan.txt", to: "/workspace-a/plan.txt") -``` - -### Operation-Level Permissions - -```swift -let filesystem = PermissionedFileSystem( - base: InMemoryFilesystem(), - authorizer: PermissionAuthorizer { request in - switch request.operation { - case .readFile, .listDirectory, .stat: - return .allowForSession - default: - return .deny(message: "write access denied") - } - } +let rules = RuleBasedPermissionAuthorizer( + rules: [ + PermissionRule( + operations: [.readFile, .listDirectory, .stat], + pathPrefix: "/Sources", + effect: .allow + ) + ] ) -let workspace = Workspace(filesystem: filesystem) +let authorized = AuthorizedFileSystem(base: limited, authorizer: rules) +let workspace = Workspace(fileSystem: authorized) ``` -## Important Behavior - -- Reads do not load checkpoint state. Writes, checkpoint calls, rollback, branch, and merge do. -- All checkpoint reads share the workspace actor barrier with file I/O, so they serialize behind in-flight writes. -- `writeJSON` ends the file with a single trailing newline; `readJSON` decodes the value as usual. Checkpoint event polling (when using `watchCheckpointEvents`) uses `Workspace.checkpointEventPollInterval` (500 ms by default; shorten in tests to reduce wait time). -- `.inMemory` storage still records one mutation per write in memory, including old-content capture for text diffs. -- Branches created with `.directory(at:)` share the same storage directory as the parent but are partitioned by `workspaceId`. -- `walkTree` and `summarizeTree` return stable path ordering, which is useful for deterministic tool output. - -## Limitations - -- `Workspace` is not a hardened sandbox. -- Logical rollback is not crash-safe and does not coordinate with external processes. -- `OverlayFilesystem` does not persist writes back to the original root. -- Hard links across mounts are not supported. -- Some filesystem types still use `@unchecked Sendable`; treat shared mutable class-based implementations carefully unless their synchronization guarantees are documented. - -## Security Notes - -- Jail and root enforcement belong to the underlying filesystem implementation. -- `ReadWriteFilesystem` uses `lstat` semantics for entry-level operations (`stat`, `exists`, `remove`, `move` source, `readSymlink`): a symlink is handled as the link itself, never its target, so links pointing outside the root can be inspected and deleted but not read or written through. -- Permission checks are additive. They do not replace path normalization or jail enforcement. -- If you expose `Workspace` to model-driven or remote callers, the host still needs to define what roots, mounts, and permissions are acceptable. +Authorization supports one-shot, duration-limited, and session approvals plus bounded audit records. Prefix rules use path-component boundaries and require both paths to match for copy and move. ## Testing ```bash -swift test +swift build +swift test --disable-xctest --enable-swift-testing ``` + +CI runs Swift 6.2 tests on macOS and Linux. diff --git a/Sources/Workspace/ChangeEvent.swift b/Sources/Workspace/ChangeEvent.swift deleted file mode 100644 index 4695379..0000000 --- a/Sources/Workspace/ChangeEvent.swift +++ /dev/null @@ -1,42 +0,0 @@ -import Foundation - -/// A change observed from a workspace mutation. -public struct ChangeEvent: Sendable, Codable, Equatable { - /// The logical change kind. - public enum Kind: String, Sendable, Codable { - /// A new entry was created. - case created - /// An existing entry's content changed. - case modified - /// An existing entry was deleted. - case deleted - /// An entry was moved or renamed. - case moved - /// An entry was copied to a new path. - case copied - /// An entry's metadata changed without changing content. - case metadataModified - } - - /// The change kind. - public var kind: Kind - /// The affected path after the change. - public var path: WorkspacePath - /// The original path for move and copy operations when applicable. - public var sourcePath: WorkspacePath? - /// The logical kind of the affected node. - public var nodeKind: FileTree.Kind - - /// Creates a change event. - public init( - kind: Kind, - path: WorkspacePath, - sourcePath: WorkspacePath? = nil, - nodeKind: FileTree.Kind - ) { - self.kind = kind - self.path = path - self.sourcePath = sourcePath - self.nodeKind = nodeKind - } -} diff --git a/Sources/Workspace/ChangeSet+RevisionIndex.swift b/Sources/Workspace/ChangeSet+RevisionIndex.swift new file mode 100644 index 0000000..6758166 --- /dev/null +++ b/Sources/Workspace/ChangeSet+RevisionIndex.swift @@ -0,0 +1,160 @@ +import Foundation + +extension ChangeSet { + typealias RevisionLoader = @Sendable (WorkspacePath) async throws -> Data + + static func compare( + before: RevisionIndex, + after: RevisionIndex, + maxTextBytes: Int?, + loadBefore: @escaping RevisionLoader, + loadAfter: @escaping RevisionLoader + ) async throws -> ChangeSet { + let result = try await compareIndexEntries( + before.entry, + after.entry, + maxTextBytes: maxTextBytes, + loadBefore: loadBefore, + loadAfter: loadAfter + ) + return ChangeSet(changes: result.changes, textDiffOmissions: result.omissions) + } + + private static func compareIndexEntries( + _ old: RevisionIndex.Entry, + _ new: RevisionIndex.Entry, + maxTextBytes: Int?, + loadBefore: @escaping RevisionLoader, + loadAfter: @escaping RevisionLoader + ) async throws -> (changes: [FileChange], omissions: [TextDiffOmission]) { + switch (old, new) { + case (.missing, .missing): + return ([], []) + case (.missing, _): + return try await collectIndex( + new, + effect: .created, + maxTextBytes: maxTextBytes, + loader: loadAfter + ) + case (_, .missing): + return try await collectIndex( + old, + effect: .deleted, + maxTextBytes: maxTextBytes, + loader: loadBefore + ) + case let (.file(oldPath, oldSize, oldPermissions, oldID), + .file(newPath, newSize, newPermissions, newID)): + guard oldID != newID || oldPermissions != newPermissions else { return ([], []) } + guard oldID != newID else { + return ([.init(path: newPath, kind: .file, effect: .modified)], []) + } + let result = try await indexedTextDiff( + oldPath: oldPath, + newPath: newPath, + oldSize: oldSize, + newSize: newSize, + maxTextBytes: maxTextBytes, + loadBefore: loadBefore, + loadAfter: loadAfter + ) + return ([.init(path: newPath, kind: .file, effect: .modified, diff: result.diff)], result.omission.map { [$0] } ?? []) + case let (.symbolicLink(_, oldTarget, oldPermissions), + .symbolicLink(newPath, newTarget, newPermissions)): + guard oldTarget != newTarget || oldPermissions != newPermissions else { return ([], []) } + return ([.init(path: newPath, kind: .symlink, effect: .modified)], []) + case let (.directory(oldPath, oldPermissions, oldChildren), + .directory(newPath, newPermissions, newChildren)): + var changes: [FileChange] = [] + var omissions: [TextDiffOmission] = [] + if oldPermissions != newPermissions { + changes.append(.init(path: newPath, kind: .directory, effect: .modified)) + } + let oldByName = Dictionary(uniqueKeysWithValues: oldChildren.map { ($0.path.name, $0) }) + let newByName = Dictionary(uniqueKeysWithValues: newChildren.map { ($0.path.name, $0) }) + for name in Set(oldByName.keys).union(newByName.keys).sorted() { + let result = try await compareIndexEntries( + oldByName[name] ?? .missing(path: oldPath.appending(name)), + newByName[name] ?? .missing(path: newPath.appending(name)), + maxTextBytes: maxTextBytes, + loadBefore: loadBefore, + loadAfter: loadAfter + ) + changes += result.changes + omissions += result.omissions + } + return (changes, omissions) + default: + let deleted = try await collectIndex(old, effect: .deleted, maxTextBytes: maxTextBytes, loader: loadBefore) + let created = try await collectIndex(new, effect: .created, maxTextBytes: maxTextBytes, loader: loadAfter) + return (deleted.changes + created.changes, deleted.omissions + created.omissions) + } + } + + private static func collectIndex( + _ entry: RevisionIndex.Entry, + effect: FileChange.Effect, + maxTextBytes: Int?, + loader: @escaping RevisionLoader + ) async throws -> (changes: [FileChange], omissions: [TextDiffOmission]) { + switch entry { + case .missing: + return ([], []) + case let .file(path, size, _, _): + if let maxTextBytes, size > UInt64(maxTextBytes) { + return ( + [.init(path: path, kind: .file, effect: effect)], + [.init(path: path, reason: .sizeLimitExceeded)] + ) + } + let data = try await loader(path) + let old = effect == .created ? Data() : data + let new = effect == .created ? data : Data() + guard !old.contains(0), !new.contains(0), + let oldText = String(data: old, encoding: .utf8), + let newText = String(data: new, encoding: .utf8) + else { + return ([.init(path: path, kind: .file, effect: effect)], [.init(path: path, reason: .binary)]) + } + let diff = TextDiff.lineBased(from: oldText, to: newText) + return ([.init(path: path, kind: .file, effect: effect, diff: diff.hunks.isEmpty ? nil : diff)], []) + case let .symbolicLink(path, _, _): + return ([.init(path: path, kind: .symlink, effect: effect)], []) + case let .directory(path, _, children): + var changes = [FileChange(path: path, kind: .directory, effect: effect)] + var omissions: [TextDiffOmission] = [] + for child in children { + let result = try await collectIndex(child, effect: effect, maxTextBytes: maxTextBytes, loader: loader) + changes += result.changes + omissions += result.omissions + } + return (changes, omissions) + } + } + + private static func indexedTextDiff( + oldPath: WorkspacePath, + newPath: WorkspacePath, + oldSize: UInt64, + newSize: UInt64, + maxTextBytes: Int?, + loadBefore: @escaping RevisionLoader, + loadAfter: @escaping RevisionLoader + ) async throws -> (diff: TextDiff?, omission: TextDiffOmission?) { + if let maxTextBytes, oldSize > UInt64(maxTextBytes) || newSize > UInt64(maxTextBytes) { + return (nil, .init(path: newPath, reason: .sizeLimitExceeded)) + } + async let old = loadBefore(oldPath) + async let new = loadAfter(newPath) + let (oldData, newData) = try await (old, new) + guard !oldData.contains(0), !newData.contains(0), + let oldText = String(data: oldData, encoding: .utf8), + let newText = String(data: newData, encoding: .utf8) + else { + return (nil, .init(path: newPath, reason: .binary)) + } + let diff = TextDiff.lineBased(from: oldText, to: newText) + return (diff.hunks.isEmpty ? nil : diff, nil) + } +} diff --git a/Sources/Workspace/ChangeSet+Snapshot.swift b/Sources/Workspace/ChangeSet+Snapshot.swift new file mode 100644 index 0000000..1a9e44b --- /dev/null +++ b/Sources/Workspace/ChangeSet+Snapshot.swift @@ -0,0 +1,103 @@ +import Foundation + +extension ChangeSet { + static func compare(before: Snapshot, after: Snapshot, maxTextBytes: Int?) -> ChangeSet { + var changes: [FileChange] = [] + var omissions: [TextDiffOmission] = [] + compareEntries( + before.entry, + after.entry, + maxTextBytes: maxTextBytes, + changes: &changes, + omissions: &omissions + ) + return ChangeSet(changes: changes, textDiffOmissions: omissions) + } + + private static func compareEntries( + _ old: Snapshot.Entry, + _ new: Snapshot.Entry, + maxTextBytes: Int?, + changes: inout [FileChange], + omissions: inout [TextDiffOmission] + ) { + switch (old, new) { + case (.missing, .missing): + return + case (.missing, _): + collect(new, effect: .created, maxTextBytes: maxTextBytes, changes: &changes, omissions: &omissions) + case (_, .missing): + collect(old, effect: .deleted, maxTextBytes: maxTextBytes, changes: &changes, omissions: &omissions) + case let (.file(lhs), .file(rhs)): + guard lhs.data != rhs.data || lhs.permissions != rhs.permissions else { return } + let diffResult = textDiff(old: lhs.data, new: rhs.data, path: rhs.path, maxBytes: maxTextBytes) + changes.append(.init(path: rhs.path, kind: .file, effect: .modified, diff: diffResult.diff)) + if let omission = diffResult.omission { omissions.append(omission) } + case let (.symlink(lhs), .symlink(rhs)): + guard lhs.target != rhs.target || lhs.permissions != rhs.permissions else { return } + changes.append(.init(path: rhs.path, kind: .symlink, effect: .modified)) + case let (.directory(lhs), .directory(rhs)): + if lhs.permissions != rhs.permissions { + changes.append(.init(path: rhs.path, kind: .directory, effect: .modified)) + } + let oldChildren = Dictionary(uniqueKeysWithValues: lhs.children.map { ($0.path.name, $0) }) + let newChildren = Dictionary(uniqueKeysWithValues: rhs.children.map { ($0.path.name, $0) }) + for name in Set(oldChildren.keys).union(newChildren.keys).sorted() { + compareEntries( + oldChildren[name] ?? .missing(.init(path: lhs.path.appending(name))), + newChildren[name] ?? .missing(.init(path: rhs.path.appending(name))), + maxTextBytes: maxTextBytes, + changes: &changes, + omissions: &omissions + ) + } + default: + collect(old, effect: .deleted, maxTextBytes: maxTextBytes, changes: &changes, omissions: &omissions) + collect(new, effect: .created, maxTextBytes: maxTextBytes, changes: &changes, omissions: &omissions) + } + } + + private static func collect( + _ entry: Snapshot.Entry, + effect: FileChange.Effect, + maxTextBytes: Int?, + changes: inout [FileChange], + omissions: inout [TextDiffOmission] + ) { + switch entry { + case .missing: + return + case let .file(file): + let dataPair = effect == .created ? (Data(), file.data) : (file.data, Data()) + let result = textDiff(old: dataPair.0, new: dataPair.1, path: file.path, maxBytes: maxTextBytes) + changes.append(.init(path: file.path, kind: .file, effect: effect, diff: result.diff)) + if let omission = result.omission { omissions.append(omission) } + case let .symlink(link): + changes.append(.init(path: link.path, kind: .symlink, effect: effect)) + case let .directory(directory): + changes.append(.init(path: directory.path, kind: .directory, effect: effect)) + for child in directory.children { + collect(child, effect: effect, maxTextBytes: maxTextBytes, changes: &changes, omissions: &omissions) + } + } + } + + private static func textDiff( + old: Data, + new: Data, + path: WorkspacePath, + maxBytes: Int? + ) -> (diff: TextDiff?, omission: TextDiffOmission?) { + if let maxBytes, old.count > maxBytes || new.count > maxBytes { + return (nil, .init(path: path, reason: .sizeLimitExceeded)) + } + guard !old.contains(0), !new.contains(0), + let oldText = String(data: old, encoding: .utf8), + let newText = String(data: new, encoding: .utf8) + else { + return (nil, .init(path: path, reason: .binary)) + } + let diff = TextDiff.lineBased(from: oldText, to: newText) + return (diff.hunks.isEmpty ? nil : diff, nil) + } +} diff --git a/Sources/Workspace/Checkpoint.swift b/Sources/Workspace/Checkpoint.swift index bd3baae..74ae80e 100644 --- a/Sources/Workspace/Checkpoint.swift +++ b/Sources/Workspace/Checkpoint.swift @@ -1,58 +1,33 @@ import Foundation -/// A labeled, parented moment in workspace history. -/// -/// A `Checkpoint` records when a workspace state was captured, the parent checkpoint it follows, -/// and a compact summary of the changes relative to that parent. The actual file contents live in a -/// separate ``Snapshot`` artifact that can be loaded on demand with ``Workspace/snapshot(for:)``. +/// Metadata for a persisted workspace revision. public struct Checkpoint: Sendable, Codable, Equatable { - /// A lightweight summary of changes relative to the parent checkpoint. - public struct Summary: Sendable, Codable, Equatable { - /// The number of paths that differ from the parent checkpoint. - public var changeCount: Int - /// The paths that changed relative to the parent checkpoint. - public var touchedPaths: [WorkspacePath] - /// Whether any changed file paths involve UTF-8 decodable text. - public var hasTextDiffs: Bool - - /// Creates a checkpoint summary. - public init(changeCount: Int, touchedPaths: [WorkspacePath], hasTextDiffs: Bool) { - self.changeCount = changeCount - self.touchedPaths = touchedPaths - self.hasTextDiffs = hasTextDiffs - } + public enum Origin: Sendable, Codable, Equatable { + case manual + case rollback(from: UUID) + case transaction(base: UUID?) + case archiveRestore } - /// The checkpoint identifier. public var id: UUID - /// The workspace that owns this checkpoint. - public var workspaceId: UUID - /// An optional human-readable label. + public var workspaceID: UUID public var label: String? - /// The checkpoint creation timestamp. public var createdAt: Date - /// The previous checkpoint in the same workspace, when present. - public var parentCheckpointId: UUID? - /// The parent workspace's head checkpoint when this workspace was branched. - public var baseCheckpointId: UUID? - /// The workspace that was merged to create this checkpoint, when applicable. - public var mergedFromWorkspaceId: UUID? - /// The merged workspace's head checkpoint when this checkpoint was created. - public var mergedFromCheckpointId: UUID? - /// The source checkpoint a rollback restored from when applicable. - public var rollbackSourceCheckpointId: UUID? - /// A summary of what changed relative to the parent checkpoint. - public var summary: Summary + public var parentID: UUID? + public var origin: Origin + public var summary: ChangeSet.Summary + // Persistence cursors and the content-addressed snapshot identifier stay behind the public API. var firstMutationSequence: Int? var lastMutationSequence: Int? var mutationCursor: Int var snapshotId: UUID - var inferredEventKind: CheckpointEvent.Kind { - if rollbackSourceCheckpointId != nil { return .rolledBack } - if mergedFromWorkspaceId != nil { return .merged } - return .created + var workspaceId: UUID { workspaceID } + var parentCheckpointId: UUID? { parentID } + var rollbackSourceCheckpointId: UUID? { + if case let .rollback(from) = origin { return from } + return nil } init( @@ -61,25 +36,19 @@ public struct Checkpoint: Sendable, Codable, Equatable { label: String?, createdAt: Date = Date(), parentCheckpointId: UUID?, - baseCheckpointId: UUID? = nil, - mergedFromWorkspaceId: UUID? = nil, - mergedFromCheckpointId: UUID? = nil, - rollbackSourceCheckpointId: UUID? = nil, + origin: Origin = .manual, firstMutationSequence: Int?, lastMutationSequence: Int?, mutationCursor: Int, snapshotId: UUID, - summary: Summary + summary: ChangeSet.Summary ) { self.id = id - self.workspaceId = workspaceId + self.workspaceID = workspaceId self.label = label self.createdAt = createdAt - self.parentCheckpointId = parentCheckpointId - self.baseCheckpointId = baseCheckpointId - self.mergedFromWorkspaceId = mergedFromWorkspaceId - self.mergedFromCheckpointId = mergedFromCheckpointId - self.rollbackSourceCheckpointId = rollbackSourceCheckpointId + self.parentID = parentCheckpointId + self.origin = origin self.firstMutationSequence = firstMutationSequence self.lastMutationSequence = lastMutationSequence self.mutationCursor = mutationCursor @@ -87,27 +56,3 @@ public struct Checkpoint: Sendable, Codable, Equatable { self.summary = summary } } - -/// A checkpoint event emitted by ``Workspace``. -public struct CheckpointEvent: Sendable, Codable, Equatable { - /// The event kind. - public enum Kind: String, Sendable, Codable { - /// A checkpoint was created. - case created - /// A rollback restored a prior checkpoint. - case rolledBack - /// A branch workspace was merged. - case merged - } - - /// The event kind. - public var kind: Kind - /// The checkpoint that triggered this event. - public var checkpoint: Checkpoint - - /// Creates a checkpoint event. - public init(kind: Kind, checkpoint: Checkpoint) { - self.kind = kind - self.checkpoint = checkpoint - } -} diff --git a/Sources/Workspace/CheckpointStore.swift b/Sources/Workspace/CheckpointStore.swift index 1d4f104..35469b5 100644 --- a/Sources/Workspace/CheckpointStore.swift +++ b/Sources/Workspace/CheckpointStore.swift @@ -8,7 +8,7 @@ import Glibc /// Persistence for workspace checkpoints, snapshots, and mutation logs. /// -/// The store is the **source of truth** for `MutationRecord.sequence` values. Callers may pass +/// The store is the **source of truth** for `Mutation.sequence` values. Callers may pass /// any placeholder sequence; ``appendMutation(_:)`` returns the record with the next persisted /// monotonic number for that workspace, serialized under the mutations lock. protocol CheckpointStore: AnyObject, Sendable { @@ -17,19 +17,27 @@ protocol CheckpointStore: AnyObject, Sendable { func listCheckpoints(workspaceId: UUID) async throws -> [Checkpoint] func saveSnapshot(_ snapshot: Snapshot, workspaceId: UUID) async throws func loadSnapshot(id: UUID, workspaceId: UUID) async throws -> Snapshot? + func loadRevisionIndex(id: UUID, workspaceId: UUID) async throws -> RevisionIndex? + func readSnapshotFile( + id: UUID, + workspaceId: UUID, + path: WorkspacePath, + offset: UInt64, + length: Int? + ) async throws -> Data? @discardableResult - func appendMutation(_ mutation: MutationRecord) async throws -> MutationRecord - func listMutationRecords(workspaceId: UUID) async throws -> [MutationRecord] + func appendMutation(_ mutation: Mutation) async throws -> Mutation + func listMutations(workspaceId: UUID) async throws -> [Mutation] /// Removes mutation records with `sequence <= throughSequence`. The record carrying the /// highest sequence is always retained so later appends stay monotonic. - func pruneMutationRecords(workspaceId: UUID, throughSequence: Int) async throws + func pruneMutations(workspaceId: UUID, throughSequence: Int) async throws } /// An in-memory checkpoint store for tests and ephemeral workspaces. actor InMemoryCheckpointStore: CheckpointStore { private var checkpointsByWorkspace: [UUID: [Checkpoint]] = [:] private var snapshotsByWorkspace: [UUID: [UUID: Snapshot]] = [:] - private var mutationsByWorkspace: [UUID: [MutationRecord]] = [:] + private var mutationsByWorkspace: [UUID: [Mutation]] = [:] init() {} @@ -64,22 +72,44 @@ actor InMemoryCheckpointStore: CheckpointStore { snapshotsByWorkspace[workspaceId]?[id] } + func loadRevisionIndex(id: UUID, workspaceId: UUID) async throws -> RevisionIndex? { + snapshotsByWorkspace[workspaceId]?[id].map(RevisionIndex.init) + } + + func readSnapshotFile( + id: UUID, + workspaceId: UUID, + path: WorkspacePath, + offset: UInt64, + length: Int? + ) async throws -> Data? { + guard let snapshot = snapshotsByWorkspace[workspaceId]?[id], + let entry = Workspace.snapshotEntry(at: path, in: snapshot.entry), + case let .file(file) = entry + else { return nil } + guard offset < UInt64(file.data.count) else { return Data() } + let start = file.data.index(file.data.startIndex, offsetBy: Int(offset)) + let end = length.map { file.data.index(start, offsetBy: $0, limitedBy: file.data.endIndex) ?? file.data.endIndex } + ?? file.data.endIndex + return Data(file.data[start.. MutationRecord { - var list = mutationsByWorkspace[mutation.workspaceId, default: []] + func appendMutation(_ mutation: Mutation) async throws -> Mutation { + var list = mutationsByWorkspace[mutation.workspaceID, default: []] let next = (list.map(\.sequence).max() ?? 0) + 1 var record = mutation record.sequence = next list.append(record) - mutationsByWorkspace[mutation.workspaceId] = list + mutationsByWorkspace[mutation.workspaceID] = list return record } - func listMutationRecords(workspaceId: UUID) async throws -> [MutationRecord] { + func listMutations(workspaceId: UUID) async throws -> [Mutation] { (mutationsByWorkspace[workspaceId] ?? []).sorted { $0.sequence < $1.sequence } } - func pruneMutationRecords(workspaceId: UUID, throughSequence: Int) async throws { + func pruneMutations(workspaceId: UUID, throughSequence: Int) async throws { let records = (mutationsByWorkspace[workspaceId] ?? []).sorted { $0.sequence < $1.sequence } guard let last = records.last else { return @@ -97,8 +127,7 @@ actor InMemoryCheckpointStore: CheckpointStore { /// Mutations are stored as one JSON line per record in `mutations.jsonl` (append-friendly under /// 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 +/// crashed append is skipped when reading and truncated before the next 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`` /// instances in the same process and across cooperating processes do not lose appends. Checkpoint @@ -131,6 +160,7 @@ actor FileCheckpointStore: CheckpointStore { } func loadCheckpoint(id: UUID, workspaceId: UUID) async throws -> Checkpoint? { + try validateWorkspaceFormatIfPresent(workspaceId) let url = checkpointURL(id: id, workspaceId: workspaceId) guard fileManager.fileExists(atPath: url.path) else { return nil @@ -139,6 +169,7 @@ actor FileCheckpointStore: CheckpointStore { } func listCheckpoints(workspaceId: UUID) async throws -> [Checkpoint] { + try validateWorkspaceFormatIfPresent(workspaceId) let directoryURL = checkpointsDirectoryURL(workspaceId: workspaceId) guard fileManager.fileExists(atPath: directoryURL.path) else { listCheckpointsCache[workspaceId] = nil @@ -182,6 +213,7 @@ actor FileCheckpointStore: CheckpointStore { var path: WorkspacePath var permissions: POSIXPermissions? var contentHash: String? + var contentSize: UInt64? = nil var symlinkTarget: String? var children: [Node]? } @@ -205,20 +237,98 @@ actor FileCheckpointStore: CheckpointStore { } func loadSnapshot(id: UUID, workspaceId: UUID) async throws -> Snapshot? { + try validateWorkspaceFormatIfPresent(workspaceId) let url = snapshotURL(id: id, workspaceId: workspaceId) guard fileManager.fileExists(atPath: url.path) else { return nil } - let data = try Data(contentsOf: url) - if let manifest = try? decoder.decode(SnapshotManifest.self, from: data) { - return Snapshot( - id: manifest.id, - rootPath: manifest.rootPath, - entry: try snapshotEntry(from: manifest.root, workspaceId: workspaceId) + let manifest = try decoder.decode(SnapshotManifest.self, from: Data(contentsOf: url)) + return Snapshot( + id: manifest.id, + rootPath: manifest.rootPath, + entry: try snapshotEntry(from: manifest.root, workspaceId: workspaceId) + ) + } + + func loadRevisionIndex(id: UUID, workspaceId: UUID) async throws -> RevisionIndex? { + try validateWorkspaceFormatIfPresent(workspaceId) + let url = snapshotURL(id: id, workspaceId: workspaceId) + guard fileManager.fileExists(atPath: url.path) else { return nil } + let manifest = try decoder.decode(SnapshotManifest.self, from: Data(contentsOf: url)) + return RevisionIndex( + id: manifest.id, + root: manifest.rootPath, + entry: try revisionEntry(from: manifest.root, workspaceId: workspaceId) + ) + } + + func readSnapshotFile( + id: UUID, + workspaceId: UUID, + path: WorkspacePath, + offset: UInt64, + length: Int? + ) async throws -> Data? { + try validateWorkspaceFormatIfPresent(workspaceId) + if let length, length < 0 { + throw WorkspaceError.unsupported("read length must not be negative") + } + let url = snapshotURL(id: id, workspaceId: workspaceId) + guard fileManager.fileExists(atPath: url.path) else { return nil } + let manifest = try decoder.decode(SnapshotManifest.self, from: Data(contentsOf: url)) + guard let node = manifestNode(at: path, in: manifest.root), node.kind == .file, + let hash = node.contentHash + else { return nil } + let blob = blobURL(hash: hash, workspaceId: workspaceId) + guard fileManager.fileExists(atPath: blob.path) else { + throw WorkspaceError.storageCorrupted("missing content blob \(hash) for \(path)") + } + let handle = try FileHandle(forReadingFrom: blob) + defer { try? handle.close() } + let size = try handle.seekToEnd() + guard offset < size else { return Data() } + try handle.seek(toOffset: offset) + return try handle.read(upToCount: length ?? Int(size - offset)) ?? Data() + } + + private func revisionEntry( + from node: SnapshotManifest.Node, + workspaceId: UUID + ) throws -> RevisionIndex.Entry { + switch node.kind { + case .missing: + return .missing(path: node.path) + case .file: + guard let hash = node.contentHash else { + throw WorkspaceError.storageCorrupted("snapshot file node \(node.path) has no content hash") + } + return .file( + path: node.path, + size: node.contentSize ?? 0, + permissions: node.permissions ?? .defaultFile, + contentID: hash ) + case .directory: + return .directory( + path: node.path, + permissions: node.permissions ?? .defaultDirectory, + children: try (node.children ?? []).map { try revisionEntry(from: $0, workspaceId: workspaceId) } + ) + case .symlink: + return .symbolicLink( + path: node.path, + target: node.symlinkTarget ?? "", + permissions: node.permissions ?? POSIXPermissions(0o777) + ) + } + } + + private func manifestNode(at path: WorkspacePath, in node: SnapshotManifest.Node) -> SnapshotManifest.Node? { + if node.path == path { return node } + for child in node.children ?? [] where child.path == path || path.string.hasPrefix(child.path.string + "/") { + return manifestNode(at: path, in: child) } - // Legacy format: the full snapshot tree with inline base64 contents. - return try decoder.decode(Snapshot.self, from: data) + return nil } private func writeManifestNode( @@ -238,7 +348,8 @@ actor FileCheckpointStore: CheckpointStore { kind: .file, path: file.path, permissions: file.permissions, - contentHash: hash + contentHash: hash, + contentSize: UInt64(file.data.count) ) case let .symlink(symlink): return SnapshotManifest.Node( @@ -303,22 +414,17 @@ actor FileCheckpointStore: CheckpointStore { } @discardableResult - func appendMutation(_ mutation: MutationRecord) async throws -> MutationRecord { - try ensureWorkspaceDirectories(for: mutation.workspaceId) - let jsonl = mutationsJsonlURL(workspaceId: mutation.workspaceId) - let legacy = legacyMutationsArrayURL(workspaceId: mutation.workspaceId) - let lockURL = mutationsLockURL(workspaceId: mutation.workspaceId) - return try Self.withMutationsExclusiveLock(at: lockURL) { + func appendMutation(_ mutation: Mutation) async throws -> Mutation { + try ensureWorkspaceDirectories(for: mutation.workspaceID) + let jsonl = mutationsJsonlURL(workspaceId: mutation.workspaceID) + let lockURL = mutationsLockURL(workspaceId: mutation.workspaceID) + return try Self.withExclusiveLock(at: lockURL) { 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 + lastSequence = 0 } var record = mutation record.sequence = lastSequence + 1 @@ -332,28 +438,28 @@ actor FileCheckpointStore: CheckpointStore { } } - func listMutationRecords(workspaceId: UUID) async throws -> [MutationRecord] { + func listMutations(workspaceId: UUID) async throws -> [Mutation] { + try validateWorkspaceFormatIfPresent(workspaceId) let jsonl = mutationsJsonlURL(workspaceId: workspaceId) - let legacy = legacyMutationsArrayURL(workspaceId: workspaceId) - guard fileManager.fileExists(atPath: jsonl.path) || fileManager.fileExists(atPath: legacy.path) else { + guard fileManager.fileExists(atPath: jsonl.path) else { return [] } let lockURL = mutationsLockURL(workspaceId: workspaceId) - return try Self.withMutationsExclusiveLock(at: lockURL) { - try loadAllMutations(jsonl: jsonl, legacy: legacy) + return try Self.withExclusiveLock(at: lockURL) { + try loadMutationsFromJSONL(at: jsonl) .sorted { $0.sequence < $1.sequence } } } - func pruneMutationRecords(workspaceId: UUID, throughSequence: Int) async throws { + func pruneMutations(workspaceId: UUID, throughSequence: Int) async throws { + try validateWorkspaceFormatIfPresent(workspaceId) let jsonl = mutationsJsonlURL(workspaceId: workspaceId) - let legacy = legacyMutationsArrayURL(workspaceId: workspaceId) - guard fileManager.fileExists(atPath: jsonl.path) || fileManager.fileExists(atPath: legacy.path) else { + guard fileManager.fileExists(atPath: jsonl.path) else { return } let lockURL = mutationsLockURL(workspaceId: workspaceId) - try Self.withMutationsExclusiveLock(at: lockURL) { - let records = try loadAllMutations(jsonl: jsonl, legacy: legacy) + try Self.withExclusiveLock(at: lockURL) { + let records = try loadMutationsFromJSONL(at: jsonl) .sorted { $0.sequence < $1.sequence } guard let last = records.last else { return @@ -369,38 +475,18 @@ actor FileCheckpointStore: CheckpointStore { } } - private func loadAllMutations(jsonl: URL, legacy: URL) throws -> [MutationRecord] { - if fileManager.fileExists(atPath: jsonl.path) { - if fileManager.fileExists(atPath: legacy.path) { - try? fileManager.removeItem(at: legacy) - } - return try loadMutationsFromJSONL(at: jsonl) - } - if fileManager.fileExists(atPath: legacy.path) { - let data = try Data(contentsOf: legacy) - if data.isEmpty { return [] } - let records = try decoder.decode([MutationRecord].self, from: data) - if !records.isEmpty { - try fileManager.removeItem(at: legacy) - try writeAllMutationsAsJSONL(records, to: jsonl) - } - return records - } - return [] - } - - private func loadMutationsFromJSONL(at url: URL) throws -> [MutationRecord] { + private func loadMutationsFromJSONL(at url: URL) throws -> [Mutation] { 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] = [] + var out: [Mutation] = [] out.reserveCapacity(lines.count) for (index, line) in lines.enumerated() { if line.isEmpty { continue } do { - out.append(try decoder.decode(MutationRecord.self, from: Data(String(line).utf8))) + out.append(try decoder.decode(Mutation.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 @@ -436,7 +522,7 @@ actor FileCheckpointStore: CheckpointStore { /// 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) { + let record = try? decoder.decode(Mutation.self, from: line) { return record.sequence } return try loadMutationsFromJSONL(at: url).map(\.sequence).max() ?? 0 @@ -474,7 +560,7 @@ actor FileCheckpointStore: CheckpointStore { return nil } - private func writeAllMutationsAsJSONL(_ records: [MutationRecord], to url: URL) throws { + private func writeAllMutationsAsJSONL(_ records: [Mutation], to url: URL) throws { if records.isEmpty { if fileManager.fileExists(atPath: url.path) { try fileManager.removeItem(at: url) @@ -490,7 +576,7 @@ actor FileCheckpointStore: CheckpointStore { try data.write(to: url, options: .atomic) } - private func appendJSONLLine(encode record: MutationRecord, to url: URL) throws { + private func appendJSONLLine(encode record: Mutation, to url: URL) throws { var line = try compactEncoder.encode(record) line.append(Data("\n".utf8)) if fileManager.fileExists(atPath: url.path) { @@ -515,12 +601,52 @@ actor FileCheckpointStore: CheckpointStore { } private func ensureWorkspaceDirectories(for workspaceId: UUID) throws { - try fileManager.createDirectory(at: workspaceDirectoryURL(workspaceId: workspaceId), withIntermediateDirectories: true) + try fileManager.createDirectory(at: rootDirectory, withIntermediateDirectories: true) + try Self.withExclusiveLock(at: formatLockURL(workspaceId: workspaceId)) { + try ensureWorkspaceDirectoriesLocked(for: workspaceId) + } + } + + private func ensureWorkspaceDirectoriesLocked(for workspaceId: UUID) throws { + let workspaceURL = workspaceDirectoryURL(workspaceId: workspaceId) + let existed = fileManager.fileExists(atPath: workspaceURL.path) + try fileManager.createDirectory(at: workspaceURL, withIntermediateDirectories: true) + let formatURL = workspaceURL.appendingPathComponent("format.json") + if fileManager.fileExists(atPath: formatURL.path) { + try validateWorkspaceFormatLocked(workspaceId) + } else { + if existed, + !(try fileManager.contentsOfDirectory(at: workspaceURL, includingPropertiesForKeys: nil)).isEmpty + { + throw WorkspaceError.storageCorrupted("workspace store has no format version") + } + try encoder.encode(StoreFormat(version: StoreFormat.currentVersion)).write(to: formatURL, options: .atomic) + } try fileManager.createDirectory(at: checkpointsDirectoryURL(workspaceId: workspaceId), withIntermediateDirectories: true) try fileManager.createDirectory(at: snapshotsDirectoryURL(workspaceId: workspaceId), withIntermediateDirectories: true) try fileManager.createDirectory(at: blobsDirectoryURL(workspaceId: workspaceId), withIntermediateDirectories: true) } + private func validateWorkspaceFormatIfPresent(_ workspaceId: UUID) throws { + let workspaceURL = workspaceDirectoryURL(workspaceId: workspaceId) + guard fileManager.fileExists(atPath: workspaceURL.path) else { return } + try Self.withExclusiveLock(at: formatLockURL(workspaceId: workspaceId)) { + try validateWorkspaceFormatLocked(workspaceId) + } + } + + private func validateWorkspaceFormatLocked(_ workspaceId: UUID) throws { + let workspaceURL = workspaceDirectoryURL(workspaceId: workspaceId) + let formatURL = workspaceURL.appendingPathComponent("format.json") + guard fileManager.fileExists(atPath: formatURL.path) else { + throw WorkspaceError.storageCorrupted("workspace store has no format version") + } + let format = try decoder.decode(StoreFormat.self, from: Data(contentsOf: formatURL)) + guard format.version == StoreFormat.currentVersion else { + throw WorkspaceError.storageCorrupted("unsupported store format version \(format.version)") + } + } + private func workspaceDirectoryURL(workspaceId: UUID) -> URL { rootDirectory.appendingPathComponent(workspaceId.uuidString, isDirectory: true) } @@ -553,20 +679,19 @@ actor FileCheckpointStore: CheckpointStore { workspaceDirectoryURL(workspaceId: workspaceId).appendingPathComponent("mutations.jsonl", isDirectory: false) } - private func legacyMutationsArrayURL(workspaceId: UUID) -> URL { - workspaceDirectoryURL(workspaceId: workspaceId).appendingPathComponent("mutations.json", isDirectory: false) - } - - /// A persistent sidecar lockfile used by ``withMutationsExclusiveLock(at:_:)``. + /// A persistent sidecar lockfile used to serialize mutation-log access. /// /// The mutations log is append-only; we still take the lock on a **stable** sidecar so the lock /// is not taken on a file that is unlinked/renamed by atomic write helpers in other - /// subsystems. Mutations are written to `mutations.jsonl` (or created there after migrating - /// from a legacy `mutations.json` array on first access). + /// subsystems. Mutations are written to `mutations.jsonl`. private func mutationsLockURL(workspaceId: UUID) -> URL { workspaceDirectoryURL(workspaceId: workspaceId).appendingPathComponent("mutations.lock", isDirectory: false) } + private func formatLockURL(workspaceId: UUID) -> URL { + rootDirectory.appendingPathComponent(".\(workspaceId.uuidString).format.lock", isDirectory: false) + } + private func write(_ value: T, to url: URL) throws { try encoder.encode(value).write(to: url, options: .atomic) } @@ -575,28 +700,33 @@ actor FileCheckpointStore: CheckpointStore { try decoder.decode(type, from: Data(contentsOf: url)) } - private struct MutationsFileError: Error { + private struct LockFileError: Error { var message: String } + private struct StoreFormat: Codable { + static let currentVersion = 1 + var version: Int + } + #if canImport(Darwin) || canImport(Glibc) - private static func withMutationsExclusiveLock(at url: URL, _ body: () throws -> R) throws -> R { + private static func withExclusiveLock(at url: URL, _ body: () throws -> R) throws -> R { let path = url.path let fd = open(path, O_RDWR | O_CREAT, 0o644) guard fd >= 0 else { - throw MutationsFileError(message: "could not open mutations file at \(path)") + throw LockFileError(message: "could not open lock file at \(path)") } defer { close(fd) } while flock(fd, LOCK_EX) != 0 { if errno != EINTR { - throw MutationsFileError(message: "could not acquire exclusive lock on mutations file at \(path)") + throw LockFileError(message: "could not acquire exclusive lock at \(path)") } } defer { _ = flock(fd, LOCK_UN) } return try body() } #else - private static func withMutationsExclusiveLock(at url: URL, _ body: () throws -> R) throws -> R { + private static func withExclusiveLock(at url: URL, _ body: () throws -> R) throws -> R { try body() } #endif diff --git a/Sources/Workspace/FS/FileSystem.swift b/Sources/Workspace/FS/FileSystem.swift index f097f42..f5d1a72 100644 --- a/Sources/Workspace/FS/FileSystem.swift +++ b/Sources/Workspace/FS/FileSystem.swift @@ -10,8 +10,6 @@ import Glibc public enum WorkspaceError: Error, CustomStringConvertible, Sendable { /// The caller supplied a path that cannot be represented safely. case invalidPath(String) - /// The filesystem has no backing root configured. - case notConfigured /// Mutations are not allowed (for example, read-only access). case readOnly /// File content is not valid for the requested operation (for example, not UTF-8 text). @@ -24,13 +22,8 @@ public enum WorkspaceError: Error, CustomStringConvertible, Sendable { case unsupported(String) /// The requested checkpoint does not exist for this workspace. case checkpointNotFound(UUID) - /// The snapshot artifact for a checkpoint is missing. - 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) + /// Persisted revision data for a checkpoint is missing. + case revisionDataNotFound(UUID) /// A tracked workspace mutation could not be recorded. case mutationFailed(String) /// Persisted checkpoint or snapshot storage is damaged (for example, a missing content blob). @@ -44,8 +37,6 @@ public enum WorkspaceError: Error, CustomStringConvertible, Sendable { return "path contains null byte" } return "invalid path: \(path)" - case .notConfigured: - return "filesystem is not configured" case .readOnly: return "filesystem is read-only" case let .invalidEncoding(path): @@ -58,14 +49,8 @@ public enum WorkspaceError: Error, CustomStringConvertible, Sendable { return message case let .checkpointNotFound(checkpointId): return "workspace checkpoint not found: \(checkpointId.uuidString)" - case let .snapshotNotFound(snapshotId): - return "workspace snapshot not found: \(snapshotId.uuidString)" - case let .mergeConflict(parentWorkspaceId, expectedBase, actualHead): - 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 .revisionDataNotFound(id): + return "workspace revision data not found: \(id.uuidString)" case let .mutationFailed(message): return message case let .storageCorrupted(message): @@ -121,7 +106,7 @@ public struct DirectoryEntry: Sendable, Codable { /// /// Callers can branch on capabilities instead of probing operations and catching /// ``WorkspaceError/unsupported(_:)``. -public struct FileSystemCapabilities: OptionSet, Sendable { +public struct FileSystemFeatures: OptionSet, Sendable { public let rawValue: Int public init(rawValue: Int) { @@ -129,17 +114,17 @@ public struct FileSystemCapabilities: OptionSet, Sendable { } /// Symbolic links can be created and read. - public static let symlinks = FileSystemCapabilities(rawValue: 1 << 0) + public static let symlinks = FileSystemFeatures(rawValue: 1 << 0) /// Hard links can be created. - public static let hardLinks = FileSystemCapabilities(rawValue: 1 << 1) + public static let hardLinks = FileSystemFeatures(rawValue: 1 << 1) /// POSIX permissions can be read and mutated. - public static let permissions = FileSystemCapabilities(rawValue: 1 << 2) + public static let permissions = FileSystemFeatures(rawValue: 1 << 2) /// ``FileSystem/resolveRealPath(path:)`` resolves symlink chains. - public static let realPathResolution = FileSystemCapabilities(rawValue: 1 << 3) + public static let realPathResolution = FileSystemFeatures(rawValue: 1 << 3) } -/// Read-only filesystem capabilities. -public protocol ReadableFileSystem: AnyObject, Sendable { +/// The low-level interface implemented by workspace storage backends. +public protocol FileSystem: AnyObject, Sendable { /// Returns metadata for the entry at `path`. func stat(path: WorkspacePath) async throws -> FileInfo /// Lists the direct children of the directory at `path`. @@ -150,10 +135,6 @@ public protocol ReadableFileSystem: AnyObject, Sendable { func exists(path: WorkspacePath) async -> Bool /// Expands a glob pattern relative to `currentDirectory`. func glob(pattern: String, currentDirectory: WorkspacePath) async throws -> [WorkspacePath] -} - -/// Writable filesystem capabilities. -public protocol WritableFileSystem: ReadableFileSystem { /// Writes `data` to `path`, optionally appending instead of replacing. func writeFile(path: WorkspacePath, data: Data, append: Bool) async throws /// Creates a directory at `path`. @@ -164,15 +145,6 @@ public protocol WritableFileSystem: ReadableFileSystem { func move(from sourcePath: WorkspacePath, to destinationPath: WorkspacePath) async throws /// Copies an entry. func copy(from sourcePath: WorkspacePath, to destinationPath: WorkspacePath, recursive: Bool) async throws -} - -/// The common interface for workspace filesystem implementations. -/// -/// Paths are expressed as ``WorkspacePath`` values so callers can work with virtual paths that may -/// be backed by memory, overlays, mounts, sandboxes, or real disk storage. -public protocol FileSystem: WritableFileSystem { - /// Configures the filesystem to use `rootDirectory` as its backing root when applicable. - func configure(rootDirectory: URL) async throws /// Creates a symbolic link at `path` pointing to `target`. func createSymlink(path: WorkspacePath, target: String) async throws /// Creates a hard link at `path` pointing to `target`. @@ -184,7 +156,7 @@ public protocol FileSystem: WritableFileSystem { /// Resolves the real path of `path`, following symlinks. func resolveRealPath(path: WorkspacePath) async throws -> WorkspacePath /// Returns the optional features this filesystem supports. - func capabilities() async -> FileSystemCapabilities + func capabilities() async -> FileSystemFeatures /// Reads up to `length` bytes starting at `offset`. Passing `nil` reads to the end of the /// file; an offset at or past the end returns empty data. func readFile(path: WorkspacePath, offset: UInt64, length: Int?) async throws -> Data @@ -195,12 +167,6 @@ public protocol FileSystem: WritableFileSystem { } extension FileSystem { - /// The default implementation throws ``WorkspaceError/notConfigured``. - public func configure(rootDirectory: URL) async throws { - _ = rootDirectory - throw WorkspaceError.notConfigured - } - /// The default implementation throws ``WorkspaceError/unsupported(_:)``. public func createSymlink(path: WorkspacePath, target: String) async throws { _ = path @@ -235,7 +201,7 @@ extension FileSystem { } /// The default implementation advertises no optional features. - public func capabilities() async -> FileSystemCapabilities { + public func capabilities() async -> FileSystemFeatures { [] } @@ -284,3 +250,5 @@ extension FileSystem { try await writeFile(path: path, data: data, append: false) } } + +typealias FileSystemCapabilities = FileSystemFeatures diff --git a/Sources/Workspace/FS/InMemoryFilesystem.swift b/Sources/Workspace/FS/InMemoryFilesystem.swift index 2378c43..a9951db 100644 --- a/Sources/Workspace/FS/InMemoryFilesystem.swift +++ b/Sources/Workspace/FS/InMemoryFilesystem.swift @@ -13,7 +13,7 @@ import Glibc /// /// Actor isolation serializes access to the tree so the filesystem can be shared safely across /// concurrent tasks. -public actor InMemoryFilesystem: FileSystem { +public actor InMemoryFileSystem: FileSystem { private final class Node { enum Kind { case file(Data) @@ -81,7 +81,7 @@ public actor InMemoryFilesystem: FileSystem { } /// See ``FileSystem/configure(rootDirectory:)``. - public func configure(rootDirectory: URL) async throws { + func configure(rootDirectory: URL) async throws { _ = rootDirectory root = Node(kind: .directory([:]), permissions: 0o755) } @@ -126,7 +126,7 @@ public actor InMemoryFilesystem: FileSystem { } /// See ``FileSystem/capabilities()``. - public func capabilities() async -> FileSystemCapabilities { + public func capabilities() async -> FileSystemFeatures { [.symlinks, .hardLinks, .permissions, .realPathResolution] } diff --git a/Sources/Workspace/FS/LimitedFileSystem.swift b/Sources/Workspace/FS/LimitedFileSystem.swift new file mode 100644 index 0000000..8f058f2 --- /dev/null +++ b/Sources/Workspace/FS/LimitedFileSystem.swift @@ -0,0 +1,144 @@ +import Foundation + +public struct FileSystemLimits: Sendable, Codable, Equatable { + public var maxTotalBytes: UInt64? + public var maxEntryCount: Int? + public var maxWriteBytes: Int? + + public init(maxTotalBytes: UInt64? = nil, maxEntryCount: Int? = nil, maxWriteBytes: Int? = nil) { + self.maxTotalBytes = maxTotalBytes + self.maxEntryCount = maxEntryCount + self.maxWriteBytes = maxWriteBytes + } +} + +public enum FileSystemLimitError: Error, Sendable, Equatable, CustomStringConvertible { + case totalBytes(attempted: UInt64, limit: UInt64) + case entryCount(attempted: Int, limit: Int) + case writeBytes(attempted: Int, limit: Int) + + public var description: String { + switch self { + case let .totalBytes(attempted, limit): "total byte limit exceeded: \(attempted) > \(limit)" + case let .entryCount(attempted, limit): "entry count limit exceeded: \(attempted) > \(limit)" + case let .writeBytes(attempted, limit): "write byte limit exceeded: \(attempted) > \(limit)" + } + } +} + +/// A serializing filesystem decorator that rejects mutations exceeding projected limits. +public actor LimitedFileSystem: FileSystem { + private let base: any FileSystem + public let limits: FileSystemLimits + + public init(base: any FileSystem, limits: FileSystemLimits) { + self.base = base + self.limits = limits + } + + public func stat(path: WorkspacePath) async throws -> FileInfo { try await base.stat(path: path) } + public func listDirectory(path: WorkspacePath) async throws -> [DirectoryEntry] { + try await base.listDirectory(path: path) + } + public func readFile(path: WorkspacePath) async throws -> Data { try await base.readFile(path: path) } + public func readFile(path: WorkspacePath, offset: UInt64, length: Int?) async throws -> Data { + try await base.readFile(path: path, offset: offset, length: length) + } + public func readFileChunks(path: WorkspacePath, chunkSize: Int) async throws -> AsyncThrowingStream { + try await base.readFileChunks(path: path, chunkSize: chunkSize) + } + public func capabilities() async -> FileSystemFeatures { await base.capabilities() } + public func readSymlink(path: WorkspacePath) async throws -> String { try await base.readSymlink(path: path) } + public func resolveRealPath(path: WorkspacePath) async throws -> WorkspacePath { + try await base.resolveRealPath(path: path) + } + public func exists(path: WorkspacePath) async -> Bool { await base.exists(path: path) } + public func glob(pattern: String, currentDirectory: WorkspacePath) async throws -> [WorkspacePath] { + try await base.glob(pattern: pattern, currentDirectory: currentDirectory) + } + + public func createFile(path: WorkspacePath, data: Data) async throws { + try checkWrite(data.count) + try await preflight { try await $0.createFile(path: path, data: data) } + try await base.createFile(path: path, data: data) + } + + public func writeFile(path: WorkspacePath, data: Data, append: Bool) async throws { + try checkWrite(data.count) + try await preflight { try await $0.writeFile(path: path, data: data, append: append) } + try await base.writeFile(path: path, data: data, append: append) + } + + public func createDirectory(path: WorkspacePath, recursive: Bool) async throws { + try await preflight { try await $0.createDirectory(path: path, recursive: recursive) } + try await base.createDirectory(path: path, recursive: recursive) + } + + public func remove(path: WorkspacePath, recursive: Bool) async throws { + try await preflight { try await $0.remove(path: path, recursive: recursive) } + try await base.remove(path: path, recursive: recursive) + } + + public func move(from sourcePath: WorkspacePath, to destinationPath: WorkspacePath) async throws { + try await preflight { try await $0.move(from: sourcePath, to: destinationPath) } + try await base.move(from: sourcePath, to: destinationPath) + } + + public func copy(from sourcePath: WorkspacePath, to destinationPath: WorkspacePath, recursive: Bool) async throws { + try await preflight { + try await $0.copy(from: sourcePath, to: destinationPath, recursive: recursive) + } + try await base.copy(from: sourcePath, to: destinationPath, recursive: recursive) + } + + public func createSymlink(path: WorkspacePath, target: String) async throws { + try await preflight { try await $0.createSymlink(path: path, target: target) } + try await base.createSymlink(path: path, target: target) + } + + public func createHardLink(path: WorkspacePath, target: WorkspacePath) async throws { + try await preflight { try await $0.createHardLink(path: path, target: target) } + try await base.createHardLink(path: path, target: target) + } + + public func setPermissions(path: WorkspacePath, permissions: POSIXPermissions) async throws { + try await base.setPermissions(path: path, permissions: permissions) + } + + private func checkWrite(_ count: Int) throws { + if let limit = limits.maxWriteBytes, count > limit { + throw FileSystemLimitError.writeBytes(attempted: count, limit: limit) + } + } + + private func preflight(_ mutation: @Sendable (InMemoryFileSystem) async throws -> Void) async throws { + let before = try await Snapshot.capture(from: base) + let scratch = InMemoryFileSystem() + try await Snapshot.restore(before, to: scratch) + try await mutation(scratch) + let projected = try await Snapshot.capture(from: scratch) + let usage = Self.usage(projected.entry, isRoot: true) + if let limit = limits.maxTotalBytes, usage.bytes > limit { + throw FileSystemLimitError.totalBytes(attempted: usage.bytes, limit: limit) + } + if let limit = limits.maxEntryCount, usage.entries > limit { + throw FileSystemLimitError.entryCount(attempted: usage.entries, limit: limit) + } + } + + private static func usage(_ entry: Snapshot.Entry, isRoot: Bool) -> (bytes: UInt64, entries: Int) { + switch entry { + case .missing: + return (0, 0) + case let .file(file): + return (UInt64(file.data.count), isRoot ? 0 : 1) + case .symlink: + return (0, isRoot ? 0 : 1) + case let .directory(directory): + return directory.children.reduce((bytes: 0, entries: isRoot ? 0 : 1)) { result, child in + let childUsage = usage(child, isRoot: false) + return (result.bytes + childUsage.bytes, result.entries + childUsage.entries) + } + } + } +} diff --git a/Sources/Workspace/FS/MountableFilesystem.swift b/Sources/Workspace/FS/MountableFilesystem.swift index 653ac29..75178d1 100644 --- a/Sources/Workspace/FS/MountableFilesystem.swift +++ b/Sources/Workspace/FS/MountableFilesystem.swift @@ -10,23 +10,27 @@ import Glibc /// /// Each mount is exposed at a virtual path, allowing callers to present a single workspace assembled /// from multiple independent roots. -public final class MountableFilesystem: FileSystem, @unchecked Sendable { +public final class MountedFileSystem: FileSystem, @unchecked Sendable { /// A mounted filesystem and the virtual path where it appears. public struct Mount: Sendable { /// The normalized virtual mount point. public var mountPoint: WorkspacePath /// The filesystem exposed at `mountPoint`. - public var filesystem: any FileSystem + public var fileSystem: any FileSystem /// Creates a mount description from a typed workspace path. - public init(mountPoint: WorkspacePath, filesystem: any FileSystem) { + public init(mountPoint: WorkspacePath, fileSystem: any FileSystem) { self.mountPoint = mountPoint - self.filesystem = filesystem + self.fileSystem = fileSystem + } + + init(mountPoint: WorkspacePath, filesystem: any FileSystem) { + self.init(mountPoint: mountPoint, fileSystem: filesystem) } /// Convenience initializer that accepts a string mount point. - public init(mountPoint: String, filesystem: any FileSystem) { - self.init(mountPoint: WorkspacePath(normalizing: mountPoint), filesystem: filesystem) + init(mountPoint: String, filesystem: any FileSystem) { + self.init(mountPoint: WorkspacePath(normalizing: mountPoint), fileSystem: filesystem) } } @@ -46,7 +50,7 @@ public final class MountableFilesystem: FileSystem, @unchecked Sendable { /// Creates a mountable filesystem with an optional base filesystem and initial mounts. public init( - base: any FileSystem = InMemoryFilesystem(), + base: any FileSystem = InMemoryFileSystem(), mounts: [Mount] = [] ) { self.base = base @@ -54,22 +58,21 @@ public final class MountableFilesystem: FileSystem, @unchecked Sendable { } /// Adds a filesystem mount at `mountPoint`. - public func mount(_ mountPoint: WorkspacePath, filesystem: any FileSystem) { + public func mount(_ mountPoint: WorkspacePath, fileSystem: any FileSystem) { withLock { - let mount = Mount(mountPoint: mountPoint, filesystem: filesystem) + let mount = Mount(mountPoint: mountPoint, fileSystem: fileSystem) mounts.append(mount) mounts.sort { $0.mountPoint.string.count > $1.mountPoint.string.count } } } - /// Convenience overload that accepts a string mount point. - public func mount(_ mountPoint: String, filesystem: any FileSystem) { - mount(WorkspacePath(normalizing: mountPoint), filesystem: filesystem) + func mount(_ mountPoint: WorkspacePath, filesystem: any FileSystem) { + mount(mountPoint, fileSystem: filesystem) } - /// See ``FileSystem/configure(rootDirectory:)``. - public func configure(rootDirectory: URL) async throws { - try await base.configure(rootDirectory: rootDirectory) + /// Convenience overload that accepts a string mount point. + func mount(_ mountPoint: String, filesystem: any FileSystem) { + mount(WorkspacePath(normalizing: mountPoint), fileSystem: filesystem) } /// See ``FileSystem/stat(path:)``. @@ -185,10 +188,10 @@ public final class MountableFilesystem: FileSystem, @unchecked Sendable { /// See ``FileSystem/capabilities()``. Reports the intersection of the base filesystem's and /// every mount's capabilities, since a path may resolve to any of them. - public func capabilities() async -> FileSystemCapabilities { + public func capabilities() async -> FileSystemFeatures { var combined = await base.capabilities() for mount in mountsSnapshot() { - combined.formIntersection(await mount.filesystem.capabilities()) + combined.formIntersection(await mount.fileSystem.capabilities()) } return combined } @@ -424,14 +427,18 @@ public final class MountableFilesystem: FileSystem, @unchecked Sendable { { for mount in mounts { if mount.mountPoint == path { - return (mount.mountPoint, mount.filesystem, .root) + return (mount.mountPoint, mount.fileSystem, .root) + } + + if mount.mountPoint.isRoot { + return (mount.mountPoint, mount.fileSystem, path) } if !mount.mountPoint.isRoot, path.string.hasPrefix(mount.mountPoint.string + "/") { let suffix = String(path.string.dropFirst(mount.mountPoint.string.count)) return ( mount.mountPoint, - mount.filesystem, + mount.fileSystem, WorkspacePath(normalizing: suffix.isEmpty ? "/" : suffix) ) } diff --git a/Sources/Workspace/FS/OverlayFilesystem.swift b/Sources/Workspace/FS/OverlayFilesystem.swift index 01359ef..9c86ef6 100644 --- a/Sources/Workspace/FS/OverlayFilesystem.swift +++ b/Sources/Workspace/FS/OverlayFilesystem.swift @@ -16,10 +16,10 @@ import Glibc /// /// `reload()` (or `configure(rootDirectory:)`) discards the upper layer and whiteouts, so the /// overlay reflects the source directory's current state again. -public actor OverlayFilesystem: FileSystem { +public actor OverlayFileSystem: FileSystem { private let fileManager: FileManager - private var upper: InMemoryFilesystem - private var lower: ReadWriteFilesystem? + private var upper: InMemoryFileSystem + private var lower: (any FileSystem)? private var rootURL: URL? /// Paths whose lower-layer entries (including everything beneath them) are hidden from the /// merged view. Creating a new entry at a cut path shadows it in the upper layer; the cut @@ -27,34 +27,58 @@ public actor OverlayFilesystem: FileSystem { private var cuts: Set = [] /// Creates an unconfigured overlay filesystem. - public init(fileManager: FileManager = .default) { + init(fileManager: FileManager = .default) { self.fileManager = fileManager - upper = InMemoryFilesystem() + upper = InMemoryFileSystem() + } + + /// Creates a live copy-on-write overlay over any filesystem. + public init(over base: any FileSystem) { + fileManager = .default + upper = InMemoryFileSystem() + lower = base + rootURL = nil } /// Creates and configures an overlay for `rootDirectory`. - public init(rootDirectory: URL, fileManager: FileManager = .default) async throws { + init(rootDirectory: URL, fileManager: FileManager = .default) async throws { self.fileManager = fileManager - upper = InMemoryFilesystem() + upper = InMemoryFileSystem() try await configure(rootDirectory: rootDirectory) } + /// Creates a disk overlay rooted at `root`. + public init(root: URL, fileManager: FileManager = .default) async throws { + self.fileManager = fileManager + upper = InMemoryFileSystem() + try await configure(rootDirectory: root) + } + /// See ``FileSystem/configure(rootDirectory:)``. - public func configure(rootDirectory: URL) async throws { + func configure(rootDirectory: URL) async throws { rootURL = rootDirectory.standardizedFileURL try resetLayers() } /// Discards overlay writes and whiteouts so reads reflect the source directory again. - public func reload() async throws { + func reload() async throws { guard rootURL != nil else { throw WorkspaceError.unsupported("overlay filesystem requires rootDirectory") } try resetLayers() } + /// Discards upper-layer changes and whiteouts. + public func discardChanges() async throws { + upper = InMemoryFileSystem() + cuts = [] + if rootURL != nil { + try resetLayers() + } + } + private func resetLayers() throws { - upper = InMemoryFilesystem() + upper = InMemoryFileSystem() cuts = [] guard let rootURL else { lower = nil @@ -62,7 +86,7 @@ public actor OverlayFilesystem: FileSystem { } var isDirectory: ObjCBool = false if fileManager.fileExists(atPath: rootURL.path, isDirectory: &isDirectory), isDirectory.boolValue { - lower = try ReadWriteFilesystem(rootDirectory: rootURL, fileManager: fileManager) + lower = try LocalFileSystem(rootDirectory: rootURL, fileManager: fileManager) } else { lower = nil } @@ -173,7 +197,7 @@ public actor OverlayFilesystem: FileSystem { } /// See ``FileSystem/capabilities()``. - public func capabilities() async -> FileSystemCapabilities { + public func capabilities() async -> FileSystemFeatures { [.symlinks, .hardLinks, .permissions, .realPathResolution] } diff --git a/Sources/Workspace/FS/ReadWriteFilesystem.swift b/Sources/Workspace/FS/ReadWriteFilesystem.swift index 44de74e..5c62080 100644 --- a/Sources/Workspace/FS/ReadWriteFilesystem.swift +++ b/Sources/Workspace/FS/ReadWriteFilesystem.swift @@ -10,7 +10,7 @@ import Glibc /// /// Paths are resolved relative to the configured root and constrained so callers cannot escape that root /// through traversal or symlink resolution. -public final class ReadWriteFilesystem: FileSystem, @unchecked Sendable { +public final class LocalFileSystem: FileSystem, @unchecked Sendable { private let fileManager: FileManager private let stateLock = NSLock() private var rootURL: URL? @@ -22,18 +22,23 @@ public final class ReadWriteFilesystem: FileSystem, @unchecked Sendable { } /// Creates an unconfigured disk-backed filesystem. - public init(fileManager: FileManager = .default) { + init(fileManager: FileManager = .default) { self.fileManager = fileManager } /// Creates and configures a disk-backed filesystem rooted at `rootDirectory`. - public convenience init(rootDirectory: URL, fileManager: FileManager = .default) throws { + convenience init(rootDirectory: URL, fileManager: FileManager = .default) throws { self.init(fileManager: fileManager) try applyConfiguration(rootDirectory: rootDirectory) } + /// Creates a disk-backed filesystem with an immutable public root. + public convenience init(root: URL, fileManager: FileManager = .default) throws { + try self.init(rootDirectory: root, fileManager: fileManager) + } + /// See ``FileSystem/configure(rootDirectory:)``. - public func configure(rootDirectory: URL) async throws { + func configure(rootDirectory: URL) async throws { try applyConfiguration(rootDirectory: rootDirectory) } @@ -110,7 +115,7 @@ public final class ReadWriteFilesystem: FileSystem, @unchecked Sendable { } /// See ``FileSystem/capabilities()``. - public func capabilities() async -> FileSystemCapabilities { + public func capabilities() async -> FileSystemFeatures { [.symlinks, .hardLinks, .permissions, .realPathResolution] } @@ -469,7 +474,7 @@ public final class ReadWriteFilesystem: FileSystem, @unchecked Sendable { stateLock.lock() defer { stateLock.unlock() } guard let rootURL, let resolvedRootPath else { - throw WorkspaceError.notConfigured + throw WorkspaceError.unsupported("local filesystem requires a root") } return ConfigurationSnapshot(rootURL: rootURL, resolvedRootPath: resolvedRootPath) } diff --git a/Sources/Workspace/FS/SandboxFilesystem.swift b/Sources/Workspace/FS/SandboxFilesystem.swift index eb73196..4ec9d82 100644 --- a/Sources/Workspace/FS/SandboxFilesystem.swift +++ b/Sources/Workspace/FS/SandboxFilesystem.swift @@ -1,7 +1,7 @@ import Foundation /// A disk-backed filesystem rooted in a standard app sandbox location. -public final class SandboxFilesystem: FileSystem, Sendable { +public final class SandboxFileSystem: FileSystem, Sendable { /// Supported strategies for choosing the sandbox root. public enum Root: Sendable { /// The user's documents directory. @@ -16,20 +16,15 @@ public final class SandboxFilesystem: FileSystem, Sendable { case url(URL) } - private let backing: ReadWriteFilesystem + private let backing: LocalFileSystem /// Creates a sandbox filesystem rooted according to `root`. public init(root: Root, fileManager: FileManager = .default) throws { - backing = ReadWriteFilesystem(fileManager: fileManager) + backing = LocalFileSystem(fileManager: fileManager) let resolvedRoot = try Self.resolveRootURL(root, fileManager: fileManager) try backing.applyConfiguration(rootDirectory: resolvedRoot) } - /// See ``FileSystem/configure(rootDirectory:)``. - public func configure(rootDirectory: URL) async throws { - try await backing.configure(rootDirectory: rootDirectory) - } - /// See ``FileSystem/stat(path:)``. public func stat(path: WorkspacePath) async throws -> FileInfo { try await backing.stat(path: path) @@ -64,7 +59,7 @@ public final class SandboxFilesystem: FileSystem, Sendable { } /// See ``FileSystem/capabilities()``. - public func capabilities() async -> FileSystemCapabilities { + public func capabilities() async -> FileSystemFeatures { await backing.capabilities() } @@ -161,3 +156,5 @@ public final class SandboxFilesystem: FileSystem, Sendable { } } } + +typealias SandboxFilesystem = SandboxFileSystem diff --git a/Sources/Workspace/FS/SecurityScopedFilesystem.swift b/Sources/Workspace/FS/SecurityScopedFilesystem.swift index 2c2c519..18c306c 100644 --- a/Sources/Workspace/FS/SecurityScopedFilesystem.swift +++ b/Sources/Workspace/FS/SecurityScopedFilesystem.swift @@ -5,7 +5,7 @@ import Foundation /// /// This adapter is intended for iOS and macOS workflows where filesystem access must be granted through /// a bookmark or document picker URL. -public final class SecurityScopedFilesystem: FileSystem, @unchecked Sendable { +public final class SecurityScopedFileSystem: FileSystem, @unchecked Sendable { /// The permitted mutability for the scoped filesystem. public enum AccessMode: Sendable { /// Only read operations are allowed. @@ -15,7 +15,7 @@ public final class SecurityScopedFilesystem: FileSystem, @unchecked Sendable { } private let mode: AccessMode - private let backing: ReadWriteFilesystem + private let backing: LocalFileSystem private let stateLock = NSLock() private var scopedURL: URL @@ -31,7 +31,7 @@ public final class SecurityScopedFilesystem: FileSystem, @unchecked Sendable { /// Creates a filesystem rooted at a security-scoped URL. public init(url: URL, mode: AccessMode = .readWrite, fileManager: FileManager = .default) throws { self.mode = mode - backing = ReadWriteFilesystem(fileManager: fileManager) + backing = LocalFileSystem(fileManager: fileManager) scopedURL = url.standardizedFileURL cachedBookmarkData = nil try configureBackingForCurrentURL() @@ -43,7 +43,7 @@ public final class SecurityScopedFilesystem: FileSystem, @unchecked Sendable { throw WorkspaceError.unsupported("security-scoped URLs not supported on this platform") #else self.mode = mode - backing = ReadWriteFilesystem(fileManager: fileManager) + backing = LocalFileSystem(fileManager: fileManager) var isStale = false let resolvedURL = try URL( @@ -111,15 +111,15 @@ public final class SecurityScopedFilesystem: FileSystem, @unchecked Sendable { store: any BookmarkStore, mode: AccessMode = .readWrite, fileManager: FileManager = .default - ) async throws -> SecurityScopedFilesystem { + ) async throws -> SecurityScopedFileSystem { guard let data = try await store.loadBookmark(for: id) else { throw WorkspaceError.unsupported("bookmark not found: \(id)") } - return try SecurityScopedFilesystem(bookmarkData: data, mode: mode, fileManager: fileManager) + return try SecurityScopedFileSystem(bookmarkData: data, mode: mode, fileManager: fileManager) } /// See ``FileSystem/configure(rootDirectory:)``. - public func configure(rootDirectory: URL) async throws { + func configure(rootDirectory: URL) async throws { try withLock { stopAccessingSecurityScopeIfNeeded() scopedURL = rootDirectory.standardizedFileURL @@ -163,7 +163,7 @@ public final class SecurityScopedFilesystem: FileSystem, @unchecked Sendable { } /// See ``FileSystem/capabilities()``. - public func capabilities() async -> FileSystemCapabilities { + public func capabilities() async -> FileSystemFeatures { await backing.capabilities() } @@ -282,4 +282,6 @@ public final class SecurityScopedFilesystem: FileSystem, @unchecked Sendable { private static let bookmarkResolutionOptions: URL.BookmarkResolutionOptions = [] #endif } + +typealias SecurityScopedFilesystem = SecurityScopedFileSystem #endif diff --git a/Sources/Workspace/FileEdit.swift b/Sources/Workspace/FileEdit.swift deleted file mode 100644 index 417a5b5..0000000 --- a/Sources/Workspace/FileEdit.swift +++ /dev/null @@ -1,149 +0,0 @@ -import Foundation - -/// A filesystem edit that can be applied as part of a batch. -public enum FileEdit: Sendable, Equatable, Codable { - /// Writes UTF-8 content to a file, replacing any existing contents. - case writeFile(path: WorkspacePath, content: String) - /// Appends UTF-8 content to a file. - case appendFile(path: WorkspacePath, content: String) - /// Removes a file or directory. - case delete(path: WorkspacePath, recursive: Bool = true) - /// Creates a directory. - case createDirectory(path: WorkspacePath, recursive: Bool = true) - /// Moves or renames an entry. - case move(from: WorkspacePath, to: WorkspacePath) - /// Copies an entry. - case copy(from: WorkspacePath, to: WorkspacePath, recursive: Bool = true) - - /// Whether a requested edit materially changes the workspace. - public enum ChangeState: String, Sendable, Codable { - /// The edit changes the workspace. - case changed - /// The edit leaves the workspace unchanged. - case unchanged - } - - /// The predicted or observed effect of a file-level change. - public enum Effect: String, Sendable, Codable { - /// The operation creates a new entry. - case created - /// The operation changes an existing entry. - case modified - /// The operation removes an existing entry. - case deleted - /// The operation moves an entry to a new path. - case moved - /// The operation copies an entry to a new path. - case copied - /// The operation leaves the filesystem unchanged. - case unchanged - } - - /// A file-level change contained within a batch edit result. - public struct FileChange: Sendable, Codable, Equatable { - /// The affected file or symlink path. - public var path: WorkspacePath - /// The original path for move and copy operations when applicable. - public var sourcePath: WorkspacePath? - /// The logical kind of the affected node. - public var kind: FileTree.Kind - /// The predicted or observed effect of the change. - public var effect: Effect - /// The execution status of the change. - public var status: MutationStatus - /// A structured text diff when the change represents UTF-8 file content. - public var diff: TextDiff? - - /// Creates a file-level change description. - public init( - path: WorkspacePath, - sourcePath: WorkspacePath? = nil, - kind: FileTree.Kind, - effect: Effect, - status: MutationStatus = .planned, - diff: TextDiff? = nil - ) { - self.path = path - self.sourcePath = sourcePath - self.kind = kind - self.effect = effect - self.status = status - self.diff = diff - } - } - - /// A preview or result entry for a single batch edit operation. - public struct Entry: Sendable, Codable, Equatable { - /// The requested edit. - public var edit: FileEdit - /// Whether the requested edit materially changes the workspace. - public var changeState: ChangeState - /// The execution status of the operation. - public var status: MutationStatus - /// Paths touched by the edit. - public var touchedPaths: [WorkspacePath] - /// File-level details for text and structural file changes under this edit. - public var fileChanges: [FileChange] - - /// Creates a batch edit entry. - public init( - edit: FileEdit, - changeState: ChangeState, - status: MutationStatus = .planned, - touchedPaths: [WorkspacePath], - fileChanges: [FileChange] = [] - ) { - self.edit = edit - self.changeState = changeState - self.status = status - self.touchedPaths = touchedPaths - self.fileChanges = fileChanges - } - } - - /// A failure encountered while executing a single batch edit. - public struct Failure: Sendable, Codable, Equatable { - /// The index of the failed edit in the original request. - public var index: Int - /// The edit that failed. - public var edit: FileEdit - /// A human-readable failure message. - public var message: String - - /// Creates a batch edit failure. - public init(index: Int, edit: FileEdit, message: String) { - self.index = index - self.edit = edit - self.message = message - } - } - - /// The result of applying a batch of workspace edits. - public struct BatchResult: Sendable, Codable, Equatable { - /// Whether the operation was previewed or executed. - public var mode: MutationMode - /// Canonicalized paths touched by the batch. - public var touchedPaths: [WorkspacePath] - /// Per-edit preview or result entries. - public var edits: [Entry] - /// Execution failures encountered while applying the batch. - public var failures: [Failure] - /// Whether the batch rolled back after an error. - public var rolledBack: Bool - - /// Creates a batch edit result. - public init( - mode: MutationMode, - touchedPaths: [WorkspacePath], - edits: [Entry], - failures: [Failure] = [], - rolledBack: Bool - ) { - self.mode = mode - self.touchedPaths = touchedPaths - self.edits = edits - self.failures = failures - self.rolledBack = rolledBack - } - } -} diff --git a/Sources/Workspace/FileTree.swift b/Sources/Workspace/FileTree.swift index 43988ae..4d42ae8 100644 --- a/Sources/Workspace/FileTree.swift +++ b/Sources/Workspace/FileTree.swift @@ -43,55 +43,77 @@ public struct FileTree: Sendable, Codable { } } -/// Aggregate information about a subtree in the workspace. -public struct FileTreeSummary: Sendable, Codable { - /// A summary entry for a direct child in a tree summary. - public struct Entry: Sendable, Codable { - /// The normalized path for the child entry. +extension FileTree { + /// Aggregate information for an already-loaded metadata tree. + public struct Summary: Sendable, Codable { + public struct Entry: Sendable, Codable { + public var path: WorkspacePath + public var kind: FileTree.Kind + public var size: UInt64 + public var permissions: POSIXPermissions + + public init(path: WorkspacePath, kind: FileTree.Kind, size: UInt64, permissions: POSIXPermissions) { + self.path = path + self.kind = kind + self.size = size + self.permissions = permissions + } + } + public var path: WorkspacePath - /// The child entry's kind. - public var kind: FileTree.Kind - /// The child entry's size in bytes. - public var size: UInt64 - /// The child entry's POSIX permissions. - public var permissions: POSIXPermissions + public var fileCount: Int + public var directoryCount: Int + public var symlinkCount: Int + public var totalBytes: UInt64 + public var children: [Entry] - /// Creates a summary entry. - public init(path: WorkspacePath, kind: FileTree.Kind, size: UInt64, permissions: POSIXPermissions) { + public init( + path: WorkspacePath, + fileCount: Int, + directoryCount: Int, + symlinkCount: Int, + totalBytes: UInt64, + children: [Entry] + ) { self.path = path - self.kind = kind - self.size = size - self.permissions = permissions + self.fileCount = fileCount + self.directoryCount = directoryCount + self.symlinkCount = symlinkCount + self.totalBytes = totalBytes + self.children = children } } - /// The root path that was summarized. - public var path: WorkspacePath - /// The number of files in the summarized subtree. - public var fileCount: Int - /// The number of directories in the summarized subtree. - public var directoryCount: Int - /// The number of symlinks in the summarized subtree. - public var symlinkCount: Int - /// The total size in bytes across the summarized subtree. - public var totalBytes: UInt64 - /// Direct child entries of the summarized root. - public var children: [Entry] + /// Aggregate information for this already-loaded metadata tree. + public var summary: Summary { + var fileCount = 0 + var directoryCount = 0 + var symlinkCount = 0 + var totalBytes: UInt64 = 0 - /// Creates a tree summary. - public init( - path: WorkspacePath, - fileCount: Int, - directoryCount: Int, - symlinkCount: Int, - totalBytes: UInt64, - children: [Entry] - ) { - self.path = path - self.fileCount = fileCount - self.directoryCount = directoryCount - self.symlinkCount = symlinkCount - self.totalBytes = totalBytes - self.children = children + func collect(_ node: FileTree) { + switch node.kind { + case .file: + fileCount += 1 + totalBytes += node.size + case .directory: + directoryCount += 1 + case .symlink: + symlinkCount += 1 + } + node.children?.forEach(collect) + } + collect(self) + + return Summary( + path: path, + fileCount: fileCount, + directoryCount: directoryCount, + symlinkCount: symlinkCount, + totalBytes: totalBytes, + children: (children ?? []).map { + Summary.Entry(path: $0.path, kind: $0.kind, size: $0.size, permissions: $0.permissions) + } + ) } } diff --git a/Sources/Workspace/MutationRecord.swift b/Sources/Workspace/MutationRecord.swift deleted file mode 100644 index 80c6c40..0000000 --- a/Sources/Workspace/MutationRecord.swift +++ /dev/null @@ -1,58 +0,0 @@ -import Foundation - -/// A recorded filesystem mutation emitted by ``Workspace``. -/// -/// Every tracked write appends one record (unless the workspace's ``Workspace/TrackingPolicy`` -/// disables history). Records are ordered by `sequence`, which the checkpoint store assigns -/// monotonically per workspace. -public struct MutationRecord: Sendable, Codable, Equatable { - /// The coarse operation kind for filtering and tooling. - public enum Kind: String, Sendable, Codable { - case writeFile - case appendFile - case writeData - case writeJSON - case createDirectory - case removeItem - case copyItem - case moveItem - case applyEdits - case applyReplacement - case restoreSnapshot - case rollback - case mergeWorkspace - } - - /// The store-assigned, per-workspace monotonic sequence number. - public internal(set) var sequence: Int - /// The workspace that performed the mutation. - public var workspaceId: UUID - /// When the mutation was recorded. - public var timestamp: Date - /// The coarse operation kind. - public var kind: Kind - /// The canonicalized paths the mutation touched. - public var touchedPaths: [WorkspacePath] - /// Per-file effects, including text diffs when the tracking policy records them. - public var fileChanges: [FileEdit.FileChange] - /// A convenience diff populated when the mutation changed exactly one text file. - public var diff: TextDiff? - - init( - sequence: Int, - workspaceId: UUID, - timestamp: Date = Date(), - kind: Kind, - touchedPaths: [WorkspacePath], - fileChanges: [FileEdit.FileChange], - diff: TextDiff? = nil - ) { - self.sequence = sequence - self.workspaceId = workspaceId - self.timestamp = timestamp - self.kind = kind - self.touchedPaths = touchedPaths - self.fileChanges = fileChanges - self.diff = diff - } -} diff --git a/Sources/Workspace/RevisionIndex.swift b/Sources/Workspace/RevisionIndex.swift new file mode 100644 index 0000000..1e02c73 --- /dev/null +++ b/Sources/Workspace/RevisionIndex.swift @@ -0,0 +1,88 @@ +import Foundation + +struct RevisionIndex: Sendable, Equatable { + indirect enum Entry: Sendable, Equatable { + case missing(path: WorkspacePath) + case file(path: WorkspacePath, size: UInt64, permissions: POSIXPermissions, contentID: String) + case directory(path: WorkspacePath, permissions: POSIXPermissions, children: [Entry]) + case symbolicLink(path: WorkspacePath, target: String, permissions: POSIXPermissions) + + var path: WorkspacePath { + switch self { + case let .missing(path), let .file(path, _, _, _), let .directory(path, _, _), + let .symbolicLink(path, _, _): + path + } + } + + var kind: FileTree.Kind? { + switch self { + case .missing: nil + case .file: .file + case .directory: .directory + case .symbolicLink: .symlink + } + } + + var permissions: POSIXPermissions? { + switch self { + case .missing: nil + case let .file(_, _, permissions, _), let .directory(_, permissions, _), + let .symbolicLink(_, _, permissions): + permissions + } + } + + var size: UInt64 { + if case let .file(_, size, _, _) = self { return size } + if case let .symbolicLink(_, target, _) = self { return UInt64(target.utf8.count) } + return 0 + } + } + + var id: UUID + var root: WorkspacePath + var entry: Entry + + init(id: UUID, root: WorkspacePath, entry: Entry) { + self.id = id + self.root = root + self.entry = entry + } + + init(snapshot: Snapshot) { + id = snapshot.id + root = snapshot.rootPath + entry = Self.index(snapshot.entry) + } + + private static func index(_ entry: Snapshot.Entry) -> Entry { + switch entry { + case let .missing(value): .missing(path: value.path) + case let .file(value): + .file( + path: value.path, + size: UInt64(value.data.count), + permissions: value.permissions, + contentID: SHA256.hexDigest(of: value.data) + ) + case let .directory(value): + .directory(path: value.path, permissions: value.permissions, children: value.children.map(index)) + case let .symlink(value): + .symbolicLink(path: value.path, target: value.target, permissions: value.permissions) + } + } + + func entry(at path: WorkspacePath) -> Entry? { + Self.entry(at: path, in: entry) + } + + private static func entry(at path: WorkspacePath, in entry: Entry) -> Entry? { + if entry.path == path { return entry } + guard case let .directory(_, _, children) = entry else { return nil } + for child in children where child.path == path || path.string.hasPrefix(child.path.string + "/") { + return self.entry(at: path, in: child) + } + return nil + } +} diff --git a/Sources/Workspace/Snapshot.swift b/Sources/Workspace/Snapshot.swift index 77bf122..a826c9c 100644 --- a/Sources/Workspace/Snapshot.swift +++ b/Sources/Workspace/Snapshot.swift @@ -1,17 +1,14 @@ import Foundation -/// A durable, in-memory snapshot of a workspace subtree. +/// The private materialized tree used by revisions, transactions, and archives. /// /// A snapshot captures the file contents, symbolic links, directory structure, and /// POSIX permissions of an entire tree at a single point in time. It is `Codable` /// so it can be persisted, transmitted, or compared to other snapshots. /// -/// Capture and restore snapshots through ``Workspace/captureSnapshot(at:)`` and -/// ``Workspace/restoreSnapshot(_:)``. To inspect the tree captured by a checkpoint, -/// use ``Workspace/snapshot(for:)``. -public struct Snapshot: Sendable, Codable, Equatable { +struct Snapshot: Sendable, Codable, Equatable { /// A node within a snapshot tree. - public indirect enum Entry: Sendable, Codable, Equatable { + indirect enum Entry: Sendable, Codable, Equatable { /// A path that did not exist when the snapshot was captured. case missing(Missing) /// A regular file with captured contents and permissions. @@ -22,7 +19,7 @@ public struct Snapshot: Sendable, Codable, Equatable { case symlink(Symlink) /// The workspace path of the entry. - public var path: WorkspacePath { + var path: WorkspacePath { switch self { case let .missing(entry): entry.path case let .file(entry): entry.path @@ -33,27 +30,27 @@ public struct Snapshot: Sendable, Codable, Equatable { } /// A snapshot entry representing a path that does not exist. - public struct Missing: Sendable, Codable, Equatable { + struct Missing: Sendable, Codable, Equatable { /// The captured path. - public var path: WorkspacePath + var path: WorkspacePath /// Creates a missing entry. - public init(path: WorkspacePath) { + init(path: WorkspacePath) { self.path = path } } /// A snapshot entry representing a regular file. - public struct File: Sendable, Codable, Equatable { + struct File: Sendable, Codable, Equatable { /// The captured path. - public var path: WorkspacePath + var path: WorkspacePath /// The file's contents at the time of capture. - public var data: Data + var data: Data /// The file's POSIX permissions at the time of capture. - public var permissions: POSIXPermissions + var permissions: POSIXPermissions /// Creates a file entry. - public init(path: WorkspacePath, data: Data, permissions: POSIXPermissions) { + init(path: WorkspacePath, data: Data, permissions: POSIXPermissions) { self.path = path self.data = data self.permissions = permissions @@ -61,16 +58,16 @@ public struct Snapshot: Sendable, Codable, Equatable { } /// A snapshot entry representing a directory. - public struct Directory: Sendable, Codable, Equatable { + struct Directory: Sendable, Codable, Equatable { /// The captured path. - public var path: WorkspacePath + var path: WorkspacePath /// The directory's POSIX permissions at the time of capture. - public var permissions: POSIXPermissions + var permissions: POSIXPermissions /// The directory's children, sorted by name. - public var children: [Entry] + var children: [Entry] /// Creates a directory entry. - public init(path: WorkspacePath, permissions: POSIXPermissions, children: [Entry]) { + init(path: WorkspacePath, permissions: POSIXPermissions, children: [Entry]) { self.path = path self.permissions = permissions self.children = children @@ -78,16 +75,16 @@ public struct Snapshot: Sendable, Codable, Equatable { } /// A snapshot entry representing a symbolic link. - public struct Symlink: Sendable, Codable, Equatable { + struct Symlink: Sendable, Codable, Equatable { /// The captured path. - public var path: WorkspacePath + var path: WorkspacePath /// The symbolic link's target string. - public var target: String + var target: String /// The symbolic link's POSIX permissions at the time of capture. - public var permissions: POSIXPermissions + var permissions: POSIXPermissions /// Creates a symlink entry. - public init(path: WorkspacePath, target: String, permissions: POSIXPermissions) { + init(path: WorkspacePath, target: String, permissions: POSIXPermissions) { self.path = path self.target = target self.permissions = permissions @@ -95,101 +92,20 @@ public struct Snapshot: Sendable, Codable, Equatable { } /// A stable identifier for the snapshot. - public var id: UUID + var id: UUID /// The path that was used as the snapshot root. - public var rootPath: WorkspacePath + var rootPath: WorkspacePath /// The captured root entry. - public var entry: Entry + var entry: Entry /// Creates a snapshot from a previously captured tree. - public init(id: UUID = UUID(), rootPath: WorkspacePath, entry: Entry) { + init(id: UUID = UUID(), rootPath: WorkspacePath, entry: Entry) { self.id = id self.rootPath = rootPath self.entry = entry } } -// MARK: - Summary - -extension Snapshot { - /// Returns a structural summary of the changes between this snapshot and `other`. - /// - /// Pass `nil` to summarize this snapshot as if every entry were newly created. - /// - /// This walks every node under the snapshot root twice (current and prior trees). For very large trees, - /// prefer caching summaries at checkpoint creation time rather than recomputing on hot paths. - /// - /// - Parameter other: The snapshot to compare against, typically a parent checkpoint. - /// - Returns: A summary describing how many paths changed and whether any of those - /// paths involve UTF-8 decodable text (and therefore have a meaningful textual diff). - public func summary(comparedTo other: Snapshot?) -> Checkpoint.Summary { - let currentNodes = Self.flattenNodes(entry, rootPath: rootPath) - let previousNodes = other.map { Self.flattenNodes($0.entry, rootPath: $0.rootPath) } ?? [:] - let allPaths = Set(currentNodes.keys).union(previousNodes.keys).sorted() - let changedPaths = allPaths.filter { currentNodes[$0] != previousNodes[$0] } - let hasTextDiffs = changedPaths.contains { Self.hasTextualChange(old: previousNodes[$0], new: currentNodes[$0]) } - - return Checkpoint.Summary( - changeCount: changedPaths.count, - touchedPaths: changedPaths, - hasTextDiffs: hasTextDiffs - ) - } - - private struct FlatNode: Equatable { - var kind: FileTree.Kind - var permissions: POSIXPermissions - var fileData: Data? - var symlinkTarget: String? - } - - private static func flattenNodes(_ entry: Entry, rootPath: WorkspacePath) -> [WorkspacePath: FlatNode] { - var nodes: [WorkspacePath: FlatNode] = [:] - collectNodes(entry, into: &nodes) - nodes.removeValue(forKey: rootPath) - return nodes - } - - private static func collectNodes(_ entry: Entry, into nodes: inout [WorkspacePath: FlatNode]) { - switch entry { - case .missing: - return - case let .file(f): - nodes[f.path] = FlatNode(kind: .file, permissions: f.permissions, fileData: f.data) - case let .symlink(s): - nodes[s.path] = FlatNode(kind: .symlink, permissions: s.permissions, symlinkTarget: s.target) - case let .directory(d): - nodes[d.path] = FlatNode(kind: .directory, permissions: d.permissions) - for child in d.children { - collectNodes(child, into: &nodes) - } - } - } - - private static func hasTextualChange(old: FlatNode?, new: FlatNode?) -> Bool { - let oldText: String? - if let data = old?.fileData { - oldText = String(data: data, encoding: .utf8) - } else if old == nil { - oldText = "" - } else { - oldText = nil - } - - let newText: String? - if let data = new?.fileData { - newText = String(data: data, encoding: .utf8) - } else if new == nil { - newText = "" - } else { - newText = nil - } - - guard let oldText, let newText else { return false } - return oldText != newText - } -} - // MARK: - Capture / Restore extension Snapshot { diff --git a/Sources/Workspace/Support/Permissions.swift b/Sources/Workspace/Support/Permissions.swift index 3e22247..363d9c0 100644 --- a/Sources/Workspace/Support/Permissions.swift +++ b/Sources/Workspace/Support/Permissions.swift @@ -35,7 +35,7 @@ public enum PermissionOperation: String, Sendable, Hashable, Codable { } /// A permission request describing the operation and paths involved. -public struct PermissionRequest: Sendable, Hashable { +public struct PermissionRequest: Sendable, Hashable, Codable { /// The operation being requested. public var operation: PermissionOperation /// The primary path associated with the request when applicable. @@ -73,10 +73,39 @@ public enum PermissionDecision: Sendable { case allow /// Allows the operation and caches the decision for equivalent future requests in the session. case allowForSession + /// Allows equivalent requests for the supplied duration. + case allowFor(Duration) /// Denies the operation, optionally providing a user-facing message. case deny(message: String?) } +public struct PermissionAuditRecord: Sendable, Codable, Equatable { + public enum Outcome: Sendable, Codable, Equatable { + case allowed + case denied(message: String?) + } + + public enum Source: Sendable, Codable, Equatable { + case handler + case sessionCache + case temporaryCache + case rule(Int) + case defaultRule + } + + public var timestamp: Date + public var request: PermissionRequest + public var outcome: Outcome + public var source: Source + + public init(timestamp: Date = Date(), request: PermissionRequest, outcome: Outcome, source: Source) { + self.timestamp = timestamp + self.request = request + self.outcome = outcome + self.source = source + } +} + /// An authorization policy for workspace filesystem operations. public protocol PermissionAuthorizing: Sendable { /// Returns the authorization decision for `request`. @@ -90,30 +119,149 @@ public actor PermissionAuthorizer: PermissionAuthorizing { private let handler: Handler private var sessionAllows: Set = [] + private var temporaryAllows: [PermissionRequest: ContinuousClock.Instant] = [:] + private var auditRecords: [PermissionAuditRecord] = [] + private let auditCapacity: Int + private let clock = ContinuousClock() /// Creates an authorizer from an asynchronous decision handler. - public init(handler: @escaping Handler) { + public init(auditCapacity: Int = 1_000, handler: @escaping Handler) { self.handler = handler + self.auditCapacity = max(0, auditCapacity) } /// Returns the authorization decision for `request`, reusing session approvals when available. public func authorize(_ request: PermissionRequest) async -> PermissionDecision { if sessionAllows.contains(request) { + record(request, outcome: .allowed, source: .sessionCache) return .allow } + let now = clock.now + if let expiration = temporaryAllows[request] { + if now < expiration { + record(request, outcome: .allowed, source: .temporaryCache) + return .allow + } + temporaryAllows.removeValue(forKey: request) + } + let decision = await handler(request) - if case .allowForSession = decision { + switch decision { + case .allowForSession: sessionAllows.insert(request) + record(request, outcome: .allowed, source: .handler) + return .allow + case let .allowFor(duration): + temporaryAllows[request] = now.advanced(by: duration) + record(request, outcome: .allowed, source: .handler) return .allow + case .allow: + record(request, outcome: .allowed, source: .handler) + case let .deny(message): + record(request, outcome: .denied(message: message), source: .handler) + } + + return decision + } + + public func auditLog() -> [PermissionAuditRecord] { auditRecords } + + public func clearAuditLog() { auditRecords.removeAll(keepingCapacity: true) } + + private func record( + _ request: PermissionRequest, + outcome: PermissionAuditRecord.Outcome, + source: PermissionAuditRecord.Source + ) { + guard auditCapacity > 0 else { return } + auditRecords.append(.init(request: request, outcome: outcome, source: source)) + if auditRecords.count > auditCapacity { + auditRecords.removeFirst(auditRecords.count - auditCapacity) + } + } +} + +public struct PermissionRule: Sendable, Codable, Equatable { + public enum Effect: Sendable, Codable, Equatable { + case allow + case deny(message: String?) + } + + public var operations: Set + public var pathPrefix: WorkspacePath + public var effect: Effect + + public init(operations: Set, pathPrefix: WorkspacePath, effect: Effect) { + self.operations = operations + self.pathPrefix = pathPrefix + self.effect = effect + } +} + +/// A deterministic first-match path-prefix authorization policy. +public actor RuleBasedPermissionAuthorizer: PermissionAuthorizing { + private let rules: [PermissionRule] + private let defaultEffect: PermissionRule.Effect + private let auditCapacity: Int + private var auditRecords: [PermissionAuditRecord] = [] + + public init( + rules: [PermissionRule], + defaultEffect: PermissionRule.Effect = .deny(message: nil), + auditCapacity: Int = 1_000 + ) { + self.rules = rules + self.defaultEffect = defaultEffect + self.auditCapacity = max(0, auditCapacity) + } + + public func authorize(_ request: PermissionRequest) async -> PermissionDecision { + for (index, rule) in rules.enumerated() + where rule.operations.contains(request.operation) && Self.matchesPaths(request, prefix: rule.pathPrefix) { + return decision(for: rule.effect, request: request, source: .rule(index)) } + return decision(for: defaultEffect, request: request, source: .defaultRule) + } + public func auditLog() -> [PermissionAuditRecord] { auditRecords } + public func clearAuditLog() { auditRecords.removeAll(keepingCapacity: true) } + + private static func matchesPaths(_ request: PermissionRequest, prefix: WorkspacePath) -> Bool { + let paths = [request.path, request.sourcePath, request.destinationPath].compactMap { $0 } + guard !paths.isEmpty else { return false } + return paths.allSatisfy { path in + prefix.isRoot || path == prefix || path.string.hasPrefix(prefix.string + "/") + } + } + + private func decision( + for effect: PermissionRule.Effect, + request: PermissionRequest, + source: PermissionAuditRecord.Source + ) -> PermissionDecision { + let decision: PermissionDecision + let outcome: PermissionAuditRecord.Outcome + switch effect { + case .allow: + decision = .allow + outcome = .allowed + case let .deny(message): + decision = .deny(message: message) + outcome = .denied(message: message) + } + if auditCapacity > 0 { + auditRecords.append(.init(request: request, outcome: outcome, source: source)) + if auditRecords.count > auditCapacity { + auditRecords.removeFirst(auditRecords.count - auditCapacity) + } + } return decision } } /// A filesystem wrapper that authorizes each operation before forwarding it to another filesystem. -public final class PermissionedFileSystem: FileSystem, Sendable { +public final class AuthorizedFileSystem: FileSystem, Sendable { private let base: any FileSystem private let authorizer: any PermissionAuthorizing @@ -126,11 +274,6 @@ public final class PermissionedFileSystem: FileSystem, Sendable { self.authorizer = authorizer } - /// See ``FileSystem/configure(rootDirectory:)``. - public func configure(rootDirectory: URL) async throws { - try await base.configure(rootDirectory: rootDirectory) - } - /// See ``FileSystem/stat(path:)``. public func stat(path: WorkspacePath) async throws -> FileInfo { try await authorize(.init(operation: .stat, path: path)) @@ -171,7 +314,7 @@ public final class PermissionedFileSystem: FileSystem, Sendable { } /// See ``FileSystem/capabilities()``. - public func capabilities() async -> FileSystemCapabilities { + public func capabilities() async -> FileSystemFeatures { await base.capabilities() } diff --git a/Sources/Workspace/Support/WorkspacePath.swift b/Sources/Workspace/Support/WorkspacePath.swift index bac3528..5637dc5 100644 --- a/Sources/Workspace/Support/WorkspacePath.swift +++ b/Sources/Workspace/Support/WorkspacePath.swift @@ -5,7 +5,7 @@ import Foundation /// `WorkspacePath` models shell-style paths independently of the host filesystem so the same value can /// be used with in-memory, mounted, overlay, and disk-backed filesystems. public struct WorkspacePath: Sendable, Hashable, Comparable, Codable, ExpressibleByStringLiteral, - LosslessStringConvertible, CustomStringConvertible + CustomStringConvertible { /// The root path, `/`. public static let root = WorkspacePath(unchecked: "/") @@ -14,19 +14,21 @@ public struct WorkspacePath: Sendable, Hashable, Comparable, Codable, Expressibl /// Creates a normalized path from a string literal. public init(stringLiteral value: StringLiteralType) { - self.init(normalizing: value) + precondition(!value.contains("\u{0}"), "workspace path contains a null byte") + storage = Self.normalizedString(path: value, currentDirectory: Self.root.storage) } - /// Creates a validated and normalized path from a string description. - /// - /// Returns `nil` when the input contains unsupported characters such as a null byte. - public init?(_ description: String) { - try? self.init(validating: description) + /// Creates a validated, normalized path from dynamic text. + public init(_ path: String, relativeTo currentDirectory: WorkspacePath = .root) throws { + if path.contains("\u{0}") { + throw WorkspaceError.invalidPath(path) + } + storage = Self.normalizedString(path: path, currentDirectory: currentDirectory.storage) } /// Creates a normalized absolute path from a string, resolving it relative to `currentDirectory` /// when the input is not already absolute. - public init(normalizing path: some StringProtocol, relativeTo currentDirectory: WorkspacePath = .root) { + init(normalizing path: some StringProtocol, relativeTo currentDirectory: WorkspacePath = .root) { storage = Self.normalizedString(path: String(path), currentDirectory: currentDirectory.storage) } @@ -36,7 +38,7 @@ public struct WorkspacePath: Sendable, Hashable, Comparable, Codable, Expressibl /// - path: The path text to validate and normalize. /// - currentDirectory: The base path used for relative inputs. /// - Throws: ``WorkspaceError/invalidPath(_:)`` when the input contains unsupported characters. - public init(validating path: some StringProtocol, relativeTo currentDirectory: WorkspacePath = .root) throws { + init(validating path: some StringProtocol, relativeTo currentDirectory: WorkspacePath = .root) throws { let string = String(path) if string.contains("\u{0}") { throw WorkspaceError.invalidPath(string) @@ -69,15 +71,21 @@ public struct WorkspacePath: Sendable, Hashable, Comparable, Codable, Expressibl } /// The final path component, or `/` for the root path. - public var basename: String { + var basename: String { Self.basename(storage) } + /// The final path component, or `/` for the root. + public var name: String { basename } + /// The parent directory of the path. - public var dirname: WorkspacePath { + var dirname: WorkspacePath { Self.dirname(storage) } + /// The containing path. The root is its own parent. + public var parent: WorkspacePath { dirname } + /// Returns a new path by appending `component` and normalizing the result. public func appending(_ component: some StringProtocol) -> WorkspacePath { Self.join(self, String(component)) @@ -131,12 +139,12 @@ public struct WorkspacePath: Sendable, Hashable, Comparable, Codable, Expressibl } /// Splits an absolute path string into components, excluding the leading `/`. - public static func splitComponents(_ absolutePath: some StringProtocol) -> [String] { + static func splitComponents(_ absolutePath: some StringProtocol) -> [String] { String(absolutePath).split(separator: "/", omittingEmptySubsequences: true).map(String.init) } /// Returns the last component of `path`, or `/` for the root path. - public static func basename(_ path: some StringProtocol) -> String { + static func basename(_ path: some StringProtocol) -> String { let string = String(path) let normalized = string == "/" ? "/" : string.trimmingCharacters(in: CharacterSet(charactersIn: "/")) if normalized == "/" || normalized.isEmpty { @@ -146,7 +154,7 @@ public struct WorkspacePath: Sendable, Hashable, Comparable, Codable, Expressibl } /// Returns the parent directory of `path`. - public static func dirname(_ path: some StringProtocol) -> WorkspacePath { + static func dirname(_ path: some StringProtocol) -> WorkspacePath { let normalized = normalizedString(path: String(path), currentDirectory: "/") if normalized == "/" { return .root @@ -161,7 +169,7 @@ public struct WorkspacePath: Sendable, Hashable, Comparable, Codable, Expressibl } /// Joins two path fragments and returns the normalized string result. - public static func join(_ lhs: some StringProtocol, _ rhs: some StringProtocol) -> String { + static func join(_ lhs: some StringProtocol, _ rhs: some StringProtocol) -> String { let left = String(lhs) let right = String(rhs) if right.hasPrefix("/") { @@ -173,12 +181,12 @@ public struct WorkspacePath: Sendable, Hashable, Comparable, Codable, Expressibl } /// Joins a base path and a path fragment, returning a normalized ``WorkspacePath``. - public static func join(_ lhs: WorkspacePath, _ rhs: some StringProtocol) -> WorkspacePath { + static func join(_ lhs: WorkspacePath, _ rhs: some StringProtocol) -> WorkspacePath { WorkspacePath(unchecked: join(lhs.storage, String(rhs))) } /// Returns `true` when the token contains glob metacharacters. - public static func containsGlob(_ token: some StringProtocol) -> Bool { + static func containsGlob(_ token: some StringProtocol) -> Bool { let string = String(token) return string.contains("*") || string.contains("?") || string.contains("[") } @@ -187,7 +195,7 @@ public struct WorkspacePath: Sendable, Hashable, Comparable, Codable, Expressibl /// /// `*` and `?` match within a single path component (they never cross `/`), `**` matches any /// characters including `/`, and character classes support shell-style negation (`[!abc]`). - public static func globToRegex(_ pattern: some StringProtocol) -> String { + static func globToRegex(_ pattern: some StringProtocol) -> String { let pattern = String(pattern) var regex = "^" var index = pattern.startIndex @@ -197,8 +205,14 @@ public struct WorkspacePath: Sendable, Hashable, Comparable, Codable, Expressibl if char == "*" { let nextIndex = pattern.index(after: index) if nextIndex < pattern.endIndex, pattern[nextIndex] == "*" { - regex += ".*" - index = nextIndex + let afterDoubleStar = pattern.index(after: nextIndex) + if afterDoubleStar < pattern.endIndex, pattern[afterDoubleStar] == "/" { + regex += "(?:.*/)?" + index = afterDoubleStar + } else { + regex += ".*" + index = nextIndex + } } else { regex += "[^/]*" } diff --git a/Sources/Workspace/TextDiff.swift b/Sources/Workspace/TextDiff.swift index 39059d3..fcf8678 100644 --- a/Sources/Workspace/TextDiff.swift +++ b/Sources/Workspace/TextDiff.swift @@ -1,21 +1,41 @@ import Foundation -/// A line-based diff between two text snapshots. +/// A structured line-based diff between two text values. public struct TextDiff: Sendable, Equatable, Codable { - /// A contiguous hunk in a structured text diff. + /// Aggregate line statistics for the complete values being compared. + public struct Statistics: Sendable, Equatable, Codable { + public var originalLineCount: Int + public var updatedLineCount: Int + public var additions: Int + public var deletions: Int + + public init(originalLineCount: Int, updatedLineCount: Int, additions: Int, deletions: Int) { + self.originalLineCount = originalLineCount + self.updatedLineCount = updatedLineCount + self.additions = additions + self.deletions = deletions + } + } + + /// A UTF-16 range suitable for editor and `NSRange` interop. + public struct IntralineRange: Sendable, Equatable, Codable { + public var location: Int + public var length: Int + + public init(location: Int, length: Int) { + self.location = location + self.length = length + } + } + + /// A contiguous diff hunk. public struct Hunk: Sendable, Equatable, Codable { - /// The 1-based starting line number in the original content. public var oldStartLine: Int - /// The number of original lines represented in the hunk. public var oldLineCount: Int - /// The 1-based starting line number in the updated content. public var newStartLine: Int - /// The number of updated lines represented in the hunk. public var newLineCount: Int - /// The context and changed lines in the hunk. public var lines: [Line] - /// Creates a diff hunk. public init( oldStartLine: Int, oldLineCount: Int, @@ -31,76 +51,70 @@ public struct TextDiff: Sendable, Equatable, Codable { } } - /// A single line in a structured text diff. + /// A single line in a hunk. public struct Line: Sendable, Equatable, Codable { - /// The line classification used within a text diff. public enum Kind: String, Sendable, Codable { - /// A context line that is unchanged between the old and new content. case context - /// A line present only in the new content. case added - /// A line present only in the old content. case removed } - /// The role of the line within the diff. public var kind: Kind - /// The line content without a trailing newline character. public var text: String - /// Whether the original line ended with a trailing newline. public var hasTrailingNewline: Bool - /// The original 1-based line number when present. public var oldLineNumber: Int? - /// The updated 1-based line number when present. public var newLineNumber: Int? + public var intralineRanges: [IntralineRange] - /// Creates a diff line. public init( kind: Kind, text: String, hasTrailingNewline: Bool, oldLineNumber: Int? = nil, - newLineNumber: Int? = nil + newLineNumber: Int? = nil, + intralineRanges: [IntralineRange] = [] ) { self.kind = kind self.text = text self.hasTrailingNewline = hasTrailingNewline self.oldLineNumber = oldLineNumber self.newLineNumber = newLineNumber + self.intralineRanges = intralineRanges } } - /// The hunks that make up the diff. + public var originalLineCount: Int + public var updatedLineCount: Int public var hunks: [Hunk] - /// Creates a text diff. - public init(hunks: [Hunk]) { - self.hunks = hunks + public var statistics: Statistics { + Statistics( + originalLineCount: originalLineCount, + updatedLineCount: updatedLineCount, + additions: hunks.flatMap(\.lines).count { $0.kind == .added }, + deletions: hunks.flatMap(\.lines).count { $0.kind == .removed } + ) } -} -// MARK: - Line-based diff (shared with ``Workspace`` previews) + public init(originalLineCount: Int, updatedLineCount: Int, hunks: [Hunk]) { + self.originalLineCount = originalLineCount + self.updatedLineCount = updatedLineCount + self.hunks = hunks + } -extension TextDiff { - /// A line-based diff between two UTF-8 text blobs, using the same algorithm as - /// ``Workspace`` replacement and batch-edit previews. + /// Builds a line diff with three context lines and UTF-16 intra-line ranges. public static func lineBased(from originalContent: String, to updatedContent: String) -> TextDiff { let originalLines = diffLineTokens(in: originalContent) let updatedLines = diffLineTokens(in: updatedContent) let changes = Array(updatedLines.difference(from: originalLines)) let removals = Dictionary(grouping: changes.compactMap { change -> (Int, DiffLineToken)? in - if case let .remove(offset, element, _) = change { - return (offset, element) - } - return nil + guard case let .remove(offset, element, _) = change else { return nil } + return (offset, element) }, by: \.0) - let insertions = Dictionary(grouping: changes.compactMap { change -> (Int, DiffLineToken)? in - if case let .insert(offset, element, _) = change { - return (offset, element) - } - return nil + guard case let .insert(offset, element, _) = change else { return nil } + return (offset, element) }, by: \.0) var lines: [Line] = [] @@ -125,7 +139,6 @@ extension TextDiff { } continue } - if let inserted = insertions[updatedIndex] { for (_, token) in inserted { lines.append( @@ -141,11 +154,7 @@ extension TextDiff { } continue } - - guard originalIndex < originalLines.count, updatedIndex < updatedLines.count else { - break - } - + guard originalIndex < originalLines.count, updatedIndex < updatedLines.count else { break } let updatedLine = updatedLines[updatedIndex] lines.append( Line( @@ -162,9 +171,10 @@ extension TextDiff { updatedLineNumber += 1 } + applyIntralineRanges(to: &lines) let changedIndices = lines.indices.filter { lines[$0].kind != .context } guard !changedIndices.isEmpty else { - return TextDiff(hunks: []) + return TextDiff(originalLineCount: originalLines.count, updatedLineCount: updatedLines.count, hunks: []) } var ranges: [Range] = [] @@ -180,18 +190,21 @@ extension TextDiff { let hunks = ranges.map { range in let hunkLines = Array(lines[range]) - let originalLineNumbers = hunkLines.compactMap(\.oldLineNumber) - let updatedLineNumbers = hunkLines.compactMap(\.newLineNumber) + let originalNumbers = hunkLines.compactMap(\.oldLineNumber) + let updatedNumbers = hunkLines.compactMap(\.newLineNumber) return Hunk( - oldStartLine: originalLineNumbers.first ?? 1, - oldLineCount: originalLineNumbers.count, - newStartLine: updatedLineNumbers.first ?? 1, - newLineCount: updatedLineNumbers.count, + oldStartLine: originalNumbers.first ?? 1, + oldLineCount: originalNumbers.count, + newStartLine: updatedNumbers.first ?? 1, + newLineCount: updatedNumbers.count, lines: hunkLines ) } - - return TextDiff(hunks: hunks) + return TextDiff( + originalLineCount: originalLines.count, + updatedLineCount: updatedLines.count, + hunks: hunks + ) } } @@ -201,10 +214,7 @@ private struct DiffLineToken: Hashable { } private func diffLineTokens(in text: String) -> [DiffLineToken] { - guard !text.isEmpty else { - return [] - } - + guard !text.isEmpty else { return [] } var tokens: [DiffLineToken] = [] var current = "" for character in text { @@ -215,10 +225,72 @@ private func diffLineTokens(in text: String) -> [DiffLineToken] { current.append(character) } } - if !current.isEmpty || text.last != "\n" { tokens.append(DiffLineToken(text: current, hasTrailingNewline: false)) } - return tokens } + +private func applyIntralineRanges(to lines: inout [TextDiff.Line]) { + var index = 0 + while index < lines.count { + guard lines[index].kind != .context else { + index += 1 + continue + } + let blockStart = index + while index < lines.count, lines[index].kind != .context { index += 1 } + let removed = (blockStart.. (old: [TextDiff.IntralineRange], new: [TextDiff.IntralineRange]) { + let oldCharacters = Array(original) + let newCharacters = Array(updated) + let difference = newCharacters.difference(from: oldCharacters) + let removed = difference.compactMap { change -> Int? in + guard case let .remove(offset, _, _) = change else { return nil } + return offset + } + let inserted = difference.compactMap { change -> Int? in + guard case let .insert(offset, _, _) = change else { return nil } + return offset + } + return ( + collapsedUTF16Ranges(characterOffsets: removed, in: original), + collapsedUTF16Ranges(characterOffsets: inserted, in: updated) + ) +} + +private func collapsedUTF16Ranges(characterOffsets: [Int], in text: String) -> [TextDiff.IntralineRange] { + guard !characterOffsets.isEmpty else { return [] } + let characters = Array(text) + let sorted = Array(Set(characterOffsets)).sorted() + var characterRanges: [Range] = [] + for offset in sorted where offset < characters.count { + if let last = characterRanges.last, offset == last.upperBound { + characterRanges[characterRanges.count - 1] = last.lowerBound..<(offset + 1) + } else { + characterRanges.append(offset..<(offset + 1)) + } + } + return characterRanges.map { range in + let prefix = String(characters[.. Workspace { - try await ensureLoaded() - try await reconcileCheckpointsWithStore() - - let baseCheckpointId = headCheckpointId - let snapshot = try await captureSnapshot() - let branchFilesystem = filesystem ?? InMemoryFilesystem() - try await Snapshot.restore(snapshot, to: branchFilesystem) - - let branch = Workspace( - workspaceId: UUID(), - filesystem: branchFilesystem, - store: store, - baseCheckpointId: baseCheckpointId, - baseMutationCursor: latestMutationSequence(), - tracking: tracking - ) - _ = try await branch.seedBranchCheckpoint( - snapshot: snapshot, - label: label, - baseCheckpointId: baseCheckpointId - ) - return branch - } - - /// 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() - try await other.reconcileCheckpointsWithStore() - - let expectedBase = await other.mergeBaseCheckpointId() - guard headCheckpointId == expectedBase else { - throw WorkspaceError.mergeConflict( - parentWorkspaceId: workspaceId, - expectedBase: expectedBase, - actualHead: headCheckpointId - ) - } - - // 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() - let delta = snapshotDelta(from: previousSnapshot.entry, to: incomingSnapshot.entry) - - try await untrackedRestore(incomingSnapshot) - - if delta.hasChanges { - try await appendMutation( - kind: .mergeWorkspace, - touchedPaths: Array(delta.touchedPaths).sorted(), - fileChanges: delta.fileChanges, - diff: delta.fileChanges.count == 1 ? delta.fileChanges[0].diff : nil - ) - } - - let mergedSnapshot = try await captureSnapshot() - return try await persistCheckpoint( - snapshot: mergedSnapshot, - label: label, - parentCheckpointId: headCheckpointId, - baseCheckpointId: baseCheckpointId, - mergedFromWorkspaceId: other.workspaceId, - mergedFromCheckpointId: incomingHead, - rollbackSourceCheckpointId: nil, - eventKind: .merged - ) - } - - /// A path where the workspace and a branch changed the same entry in incompatible ways - /// relative to their merge base. - public struct MergeConflict: Sendable, Codable, Equatable { - /// How the two sides disagree. - public enum Kind: String, Sendable, Codable { - /// Both sides modified the entry with different results. - case bothModified - /// One side modified the entry and the other deleted it. - case modifiedAndDeleted - /// Both sides created different entries at the same path. - case bothCreated - } - - /// The conflicting path. - public var path: WorkspacePath - /// The conflict classification. - public var kind: Kind - /// Diff from the base content to this workspace's content, when both sides are UTF-8 text. - public var oursDiff: TextDiff? - /// Diff from the base content to the branch's content, when both sides are UTF-8 text. - public var theirsDiff: TextDiff? - } - - /// The outcome of ``Workspace/mergeThreeWay(_:label:)``. - public struct ThreeWayMergeResult: Sendable { - /// The merge checkpoint, present when the merge applied cleanly. - public let checkpoint: Checkpoint? - /// The conflicts that prevented the merge; empty when `checkpoint` is present. - public let conflicts: [MergeConflict] - /// Whether the merge was applied to this workspace. - public var applied: Bool { checkpoint != nil } - } - - /// Merges a branch using per-path three-way resolution against the branch's base checkpoint. - /// - /// For every path, the side that changed relative to the base wins; identical changes on - /// both sides merge cleanly. When both sides changed a path differently the merge applies - /// nothing and returns the conflicts instead. Unlike ``merge(_:label:)``, this workspace may - /// have advanced (checkpointed or not) since the branch was created. - public func mergeThreeWay(_ other: Workspace, label: String? = nil) async throws -> ThreeWayMergeResult { - try await ensureLoaded() - try await reconcileCheckpointsWithStore() - - guard let baseCheckpointId = await other.mergeBaseCheckpointId() else { - throw WorkspaceError.unsupported( - "three-way merge requires a branch created from a checkpointed workspace" - ) - } - let baseCheckpoint = try checkpointOrThrow(id: baseCheckpointId) - let baseSnapshot = try await loadSnapshotOrThrow( - id: baseCheckpoint.snapshotId, - workspaceId: baseCheckpoint.workspaceId - ) - - let oursSnapshot = try await captureSnapshot() - let theirsSnapshot = try await other.captureSnapshot() - let incomingHead = try await other.currentHeadCheckpointId() - - var base: [WorkspacePath: MergeNode] = [:] - var ours: [WorkspacePath: MergeNode] = [:] - var theirs: [WorkspacePath: MergeNode] = [:] - Self.flattenMergeNodes(baseSnapshot.entry, into: &base) - Self.flattenMergeNodes(oursSnapshot.entry, into: &ours) - Self.flattenMergeNodes(theirsSnapshot.entry, into: &theirs) - base.removeValue(forKey: .root) - ours.removeValue(forKey: .root) - theirs.removeValue(forKey: .root) - - var merged = ours - var conflicts: [MergeConflict] = [] - let allPaths = Set(base.keys).union(ours.keys).union(theirs.keys).sorted() - - for path in allPaths { - let baseNode = base[path] - let oursNode = ours[path] - let theirsNode = theirs[path] - - if theirsNode == baseNode || oursNode == theirsNode { - continue // theirs unchanged, or both sides agree: keep ours. - } - if oursNode == baseNode { - // Only theirs changed: take it. - if let theirsNode { - merged[path] = theirsNode - } else { - merged.removeValue(forKey: path) - } - continue - } - - let kind: MergeConflict.Kind - if baseNode == nil { - kind = .bothCreated - } else if oursNode == nil || theirsNode == nil { - kind = .modifiedAndDeleted - } else { - kind = .bothModified - } - conflicts.append( - MergeConflict( - path: path, - kind: kind, - oursDiff: Self.conflictDiff(from: baseNode, to: oursNode), - theirsDiff: Self.conflictDiff(from: baseNode, to: theirsNode) - ) - ) - } - - guard conflicts.isEmpty else { - return ThreeWayMergeResult(checkpoint: nil, conflicts: conflicts) - } - - let rootPermissions: POSIXPermissions = - if case let .directory(directory) = oursSnapshot.entry { - directory.permissions - } else { - .defaultDirectory - } - let mergedSnapshot = Snapshot( - rootPath: .root, - entry: Self.buildEntryTree(from: merged, rootPermissions: rootPermissions) - ) - - try await untrackedRestore(mergedSnapshot) - - let delta = snapshotDelta(from: oursSnapshot.entry, to: mergedSnapshot.entry) - if delta.hasChanges { - try await appendMutation( - kind: .mergeWorkspace, - touchedPaths: Array(delta.touchedPaths).sorted(), - fileChanges: delta.fileChanges, - diff: delta.fileChanges.count == 1 ? delta.fileChanges[0].diff : nil - ) - } - - let checkpoint = try await persistCheckpoint( - snapshot: try await captureSnapshot(), - label: label, - parentCheckpointId: headCheckpointId, - baseCheckpointId: self.baseCheckpointId, - mergedFromWorkspaceId: other.workspaceId, - mergedFromCheckpointId: incomingHead, - rollbackSourceCheckpointId: nil, - eventKind: .merged - ) - return ThreeWayMergeResult(checkpoint: checkpoint, conflicts: []) - } - - /// A comparable flat view of one snapshot entry, used for three-way resolution. - struct MergeNode: Equatable { - var kind: FileTree.Kind - var permissions: POSIXPermissions - var fileData: Data? - var symlinkTarget: String? - } - - static func flattenMergeNodes(_ entry: Snapshot.Entry, into nodes: inout [WorkspacePath: MergeNode]) { - switch entry { - case .missing: - return - case let .file(file): - nodes[file.path] = MergeNode(kind: .file, permissions: file.permissions, fileData: file.data) - case let .symlink(symlink): - nodes[symlink.path] = MergeNode( - kind: .symlink, - permissions: symlink.permissions, - symlinkTarget: symlink.target - ) - case let .directory(directory): - nodes[directory.path] = MergeNode(kind: .directory, permissions: directory.permissions) - for child in directory.children { - flattenMergeNodes(child, into: &nodes) - } - } - } - - private static func conflictDiff(from base: MergeNode?, to side: MergeNode?) -> TextDiff? { - let baseData = base.map { $0.fileData ?? Data() } ?? Data() - let sideData = side.map { $0.fileData ?? Data() } ?? Data() - guard let baseText = String(data: baseData, encoding: .utf8), - let sideText = String(data: sideData, encoding: .utf8) - else { - return nil - } - let diff = TextDiff.lineBased(from: baseText, to: sideText) - return diff.hunks.isEmpty ? nil : diff - } - - /// Rebuilds a snapshot tree from a flat path-to-node map. - static func buildEntryTree( - from nodes: [WorkspacePath: MergeNode], - rootPermissions: POSIXPermissions - ) -> Snapshot.Entry { - var childNames: [WorkspacePath: [String]] = [:] - for path in nodes.keys { - childNames[path.dirname, default: []].append(path.basename) - } - - func subtree(at path: WorkspacePath, node: MergeNode?) -> Snapshot.Entry { - let kind = node?.kind ?? .directory - switch kind { - case .file: - return .file( - Snapshot.File( - path: path, - data: node?.fileData ?? Data(), - permissions: node?.permissions ?? POSIXPermissions(0o644) - ) - ) - case .symlink: - return .symlink( - Snapshot.Symlink( - path: path, - target: node?.symlinkTarget ?? "", - permissions: node?.permissions ?? POSIXPermissions(0o777) - ) - ) - case .directory: - let children = (childNames[path] ?? []) - .sorted() - .map { name -> Snapshot.Entry in - let childPath = path.appending(name) - return subtree(at: childPath, node: nodes[childPath]) - } - return .directory( - Snapshot.Directory( - path: path, - permissions: node?.permissions ?? POSIXPermissions(0o755), - children: children - ) - ) - } - } - - return subtree(at: .root, node: MergeNode(kind: .directory, permissions: rootPermissions)) - } - - func seedBranchCheckpoint( - snapshot: Snapshot, - label: String?, - baseCheckpointId: UUID? - ) async throws -> Checkpoint { - try await ensureLoaded() - return try await persistCheckpoint( - snapshot: snapshot, - label: label, - parentCheckpointId: nil, - baseCheckpointId: baseCheckpointId, - eventKind: .created, - comparisonSnapshot: snapshot - ) - } - - func mergeBaseCheckpointId() -> UUID? { - baseCheckpointId - } - - func mergeBaseMutationCursor() -> Int? { - baseMutationCursor - } - - func currentHeadCheckpointId() async throws -> UUID? { - try await ensureLoaded() - return headCheckpointId - } -} diff --git a/Sources/Workspace/Workspace+Checkpoints.swift b/Sources/Workspace/Workspace+Checkpoints.swift index 0e65ac7..2cd9ab1 100644 --- a/Sources/Workspace/Workspace+Checkpoints.swift +++ b/Sources/Workspace/Workspace+Checkpoints.swift @@ -5,14 +5,12 @@ extension Workspace { public func createCheckpoint(label: String? = nil) async throws -> Checkpoint { try await ensureLoaded() try await reconcileCheckpointsWithStore() - let snapshot = try await captureSnapshot() + let snapshot = try await Snapshot.capture(from: filesystem) return try await persistCheckpoint( snapshot: snapshot, label: label, parentCheckpointId: headCheckpointId, - baseCheckpointId: baseCheckpointId, - rollbackSourceCheckpointId: nil, - eventKind: .created + origin: .manual ) } @@ -20,7 +18,7 @@ extension Workspace { /// /// The filesystem changes performed by the rollback are also recorded as a `rollback` /// mutation, so the mutation log stays a complete account of tree changes. - public func rollback(to checkpointId: UUID, label: String? = nil) async throws -> Checkpoint { + func rollback(to checkpointId: UUID, label: String? = nil) async throws -> Checkpoint { try await ensureLoaded() try await reconcileCheckpointsWithStore() let checkpoint = try checkpointOrThrow(id: checkpointId) @@ -28,85 +26,38 @@ extension Workspace { id: checkpoint.snapshotId, workspaceId: checkpoint.workspaceId ) - let previousSnapshot = try await captureSnapshot() - try await untrackedRestore(targetSnapshot) - let restoredSnapshot = try await captureSnapshot() - - let delta = snapshotDelta(from: previousSnapshot.entry, to: restoredSnapshot.entry) - if delta.hasChanges { - try await appendMutation( - kind: .rollback, - touchedPaths: Array(delta.touchedPaths).sorted(), - fileChanges: delta.fileChanges, - diff: delta.fileChanges.count == 1 ? delta.fileChanges[0].diff : nil - ) + let previousSnapshot = try await Snapshot.capture(from: filesystem) + try await Snapshot.restore(targetSnapshot, to: filesystem) + let restoredSnapshot = try await Snapshot.capture(from: filesystem) + let changes = changeSet(from: previousSnapshot, to: restoredSnapshot) + + if !changes.isEmpty { + try await appendMutation(operation: .rollback, changes: changes) + emitWorkspaceEvent(.changes(changes)) } return try await persistCheckpoint( snapshot: restoredSnapshot, label: label, parentCheckpointId: headCheckpointId, - baseCheckpointId: baseCheckpointId, - rollbackSourceCheckpointId: checkpoint.id, - eventKind: .rolledBack + origin: .rollback(from: checkpoint.id) ) } - /// Removes tracked mutation records with `sequence <= sequence` from the store, always - /// retaining the newest record so future sequence numbers stay monotonic. Long-running - /// workspaces can use this to keep the mutation log bounded. - public func pruneMutationHistory(throughSequence sequence: Int) async throws { - try await ensureLoaded() - try await store.pruneMutationRecords(workspaceId: workspaceId, throughSequence: sequence) - mutations = try await store.listMutationRecords(workspaceId: workspaceId) - nextMutationSequence = (mutations.map(\.sequence).max() ?? 0) + 1 - } - - /// Lists checkpoints owned by this workspace. - public func listCheckpoints() async throws -> [Checkpoint] { - try await ensureLoaded() - return checkpoints - } - /// Returns one checkpoint owned by this workspace when present. public func checkpoint(id: UUID) async throws -> Checkpoint? { try await ensureLoaded() return checkpoints.first(where: { $0.id == id }) } - /// Loads the snapshot artifact persisted alongside `checkpoint`. - public func snapshot(for checkpoint: Checkpoint) async throws -> Snapshot { + /// Lists this workspace's checkpoints in stable creation order. + public func checkpoints() async throws -> [Checkpoint] { try await ensureLoaded() - return try await loadSnapshotOrThrow(id: checkpoint.snapshotId, workspaceId: checkpoint.workspaceId) + return checkpoints } - /// Watches for checkpoint events, including checkpoints created by other workspace instances - /// with the same `workspaceId` and shared store. Polling uses ``Workspace/checkpointEventPollInterval`` - /// (500 ms by default) while the stream is active; lower it in tests to reduce wait time. - public func watchCheckpointEvents() async throws -> AsyncStream { - try await ensureLoaded() - - let watcherId = UUID() - let deliveredIds = Set(checkpoints.map(\.id)) - var continuation: AsyncStream.Continuation? - let stream = AsyncStream { - continuation = $0 - } - - guard let continuation else { - return stream - } - - checkpointWatchers[watcherId] = CheckpointWatcher( - deliveredCheckpointIds: deliveredIds, - continuation: continuation - ) - continuation.onTermination = { [weak self] _ in - Task { - await self?.removeCheckpointWatcher(id: watcherId) - } - } - ensureCheckpointPolling() - return stream + /// Restores a checkpoint and records the restoration as a new checkpoint. + public func restore(to checkpoint: Checkpoint, label: String? = nil) async throws -> Checkpoint { + try await rollback(to: checkpoint.id, label: label) } } diff --git a/Sources/Workspace/Workspace+Edits.swift b/Sources/Workspace/Workspace+Edits.swift new file mode 100644 index 0000000..e57cccd --- /dev/null +++ b/Sources/Workspace/Workspace+Edits.swift @@ -0,0 +1,218 @@ +import Foundation + +extension Workspace { + /// Reads UTF-8 text from a workspace revision. + public func readText(_ path: WorkspacePath, at revision: Revision = .current) async throws -> String { + let data = try await readData(from: path, at: revision) + guard let text = String(data: data, encoding: .utf8) else { + throw WorkspaceError.invalidEncoding(path) + } + return text + } + + /// Previews edits against an isolated in-memory copy of the current workspace. + public func preview(_ edits: [Edit]) async throws -> ChangeSet { + let before = try await Snapshot.capture(from: filesystem) + let scratch = InMemoryFileSystem() + try await Snapshot.restore(before, to: scratch) + for edit in edits { + try await Self.apply(edit, to: scratch) + } + let after = try await Snapshot.capture(from: scratch) + return changeSet(from: before, to: after) + } + + /// Applies edits through one canonical execution and recording path. + @discardableResult + public func apply(_ edits: [Edit], policy: EditPolicy = .atomic) async throws -> EditResult { + try await ensureLoaded() + let before = try await Snapshot.capture(from: filesystem) + var failures: [EditFailure] = [] + + for (index, edit) in edits.enumerated() { + do { + try await Self.apply(edit, to: filesystem) + } catch { + let failure = EditFailure(index: index, edit: edit, message: String(describing: error)) + switch policy { + case .atomic: + try await Snapshot.restore(before, to: filesystem) + throw error + case .stopOnError: + failures.append(failure) + break + case .continueAfterError: + failures.append(failure) + continue + } + break + } + } + + let after = try await Snapshot.capture(from: filesystem) + let changes = changeSet(from: before, to: after) + if !changes.isEmpty { + try await appendMutation(operation: .edit, changes: changes) + emitWorkspaceEvent(.changes(changes)) + } + return EditResult(changes: changes, failures: failures) + } + + @discardableResult + public func writeText(_ path: WorkspacePath, _ content: String) async throws -> ChangeSet { + try await apply([.writeText(path, content)]).changes + } + + @discardableResult + public func appendText(_ path: WorkspacePath, _ content: String) async throws -> ChangeSet { + try await apply([.appendText(path, content)]).changes + } + + @discardableResult + public func appendData(_ path: WorkspacePath, _ data: Data) async throws -> ChangeSet { + try await apply([.appendData(path, data)]).changes + } + + @discardableResult + public func createDirectory(_ path: WorkspacePath, recursive: Bool = true) async throws -> ChangeSet { + try await apply([.createDirectory(path, recursive: recursive)]).changes + } + + @discardableResult + public func remove(_ path: WorkspacePath, recursive: Bool = true) async throws -> ChangeSet { + try await apply([.remove(path, recursive: recursive)]).changes + } + + @discardableResult + public func copy(from source: WorkspacePath, to destination: WorkspacePath, recursive: Bool = true) + async throws -> ChangeSet + { + try await apply([.copy(from: source, to: destination, recursive: recursive)]).changes + } + + @discardableResult + public func move(from source: WorkspacePath, to destination: WorkspacePath) async throws -> ChangeSet { + try await apply([.move(from: source, to: destination)]).changes + } + + @discardableResult + public func createSymbolicLink(_ path: WorkspacePath, target: String) async throws -> ChangeSet { + try await apply([.createSymbolicLink(path, target: target)]).changes + } + + @discardableResult + public func createHardLink(_ path: WorkspacePath, target: WorkspacePath) async throws -> ChangeSet { + try await apply([.createHardLink(path, target: target)]).changes + } + + @discardableResult + public func setPermissions(_ permissions: POSIXPermissions, at path: WorkspacePath) async throws -> ChangeSet { + try await apply([.setPermissions(path, permissions)]).changes + } + + func changeSet(from before: Snapshot, to after: Snapshot) -> ChangeSet { + let limit: Int? + switch recording { + case let .full(maxTextBytes): limit = maxTextBytes + case .changesOnly, .off: limit = 0 + } + var result = ChangeSet.compare(before: before, after: after, maxTextBytes: limit) + if case .full = recording { + return result + } + result.changes = result.changes.map { change in + var copy = change + copy.diff = nil + return copy + } + result.textDiffOmissions = [] + return result + } + + private static func apply(_ edit: Edit, to fileSystem: any FileSystem) async throws { + switch edit { + case let .writeText(path, content): + try await fileSystem.writeFile(path: path, data: Data(content.utf8), append: false) + case let .appendText(path, content): + try await fileSystem.writeFile(path: path, data: Data(content.utf8), append: true) + case let .writeData(path, data): + try await fileSystem.writeFile(path: path, data: data, append: false) + case let .appendData(path, data): + try await fileSystem.writeFile(path: path, data: data, append: true) + case let .createDirectory(path, recursive): + try await fileSystem.createDirectory(path: path, recursive: recursive) + case let .remove(path, recursive): + try await fileSystem.remove(path: path, recursive: recursive) + case let .copy(source, destination, recursive): + try await fileSystem.copy(from: source, to: destination, recursive: recursive) + case let .move(source, destination): + try await fileSystem.move(from: source, to: destination) + case let .createSymbolicLink(path, target): + try await fileSystem.createSymlink(path: path, target: target) + case let .createHardLink(path, target): + try await fileSystem.createHardLink(path: path, target: target) + case let .setPermissions(path, permissions): + try await fileSystem.setPermissions(path: path, permissions: permissions) + case let .replace(files, pattern, replacement): + try await replace(files: files, pattern: pattern, replacement: replacement, on: fileSystem) + } + } + + private static func replace( + files: FileSelection, + pattern: TextPattern, + replacement: String, + on fileSystem: any FileSystem + ) async throws { + let paths = try await selectedFiles(files, on: fileSystem) + for path in paths { + let data = try await fileSystem.readFile(path: path) + guard let text = String(data: data, encoding: .utf8), !data.contains(0) else { + throw WorkspaceError.invalidEncoding(path) + } + let updated: String + switch pattern { + case let .literal(value, caseSensitive): + guard !value.isEmpty else { throw WorkspaceError.unsupported("search pattern must not be empty") } + updated = text.replacingOccurrences( + of: value, + with: replacement, + options: caseSensitive ? [] : [.caseInsensitive] + ) + case let .regularExpression(value): + guard !value.isEmpty else { throw WorkspaceError.unsupported("search pattern must not be empty") } + let regex = try NSRegularExpression(pattern: value) + updated = regex.stringByReplacingMatches( + in: text, + range: NSRange(text.startIndex.. [WorkspacePath] + { + var included = Set() + for pattern in selection.include { + let resolved = WorkspacePath(normalizing: pattern, relativeTo: selection.root) + included.formUnion(try await fileSystem.glob(pattern: resolved.string, currentDirectory: selection.root)) + } + var excluded = Set() + for pattern in selection.exclude { + let resolved = WorkspacePath(normalizing: pattern, relativeTo: selection.root) + excluded.formUnion(try await fileSystem.glob(pattern: resolved.string, currentDirectory: selection.root)) + } + var files: [WorkspacePath] = [] + for path in included.subtracting(excluded).sorted() { + if try await fileSystem.stat(path: path).kind == .file { + files.append(path) + } + } + return files + } +} diff --git a/Sources/Workspace/Workspace+Internals.swift b/Sources/Workspace/Workspace+Internals.swift index 1d96bed..02438b7 100644 --- a/Sources/Workspace/Workspace+Internals.swift +++ b/Sources/Workspace/Workspace+Internals.swift @@ -1,26 +1,14 @@ import Foundation extension Workspace { - struct SnapshotDelta { - var touchedPaths: Set = [] - var fileChanges: [FileEdit.FileChange] = [] - - var hasChanges: Bool { - !touchedPaths.isEmpty || !fileChanges.isEmpty - } - } - func ensureLoaded() async throws { - if didLoadStoreState { - return - } + if didLoadStoreState { return } if let loadTask { try await loadTask.value return } - let task = Task { - try await self.loadStoreState() - } + + let task = Task { try await self.loadStoreState() } loadTask = task do { try await task.value @@ -33,41 +21,25 @@ extension Workspace { func loadStoreState() async throws { checkpoints = try await store.listCheckpoints(workspaceId: workspaceId) - mutations = try await store.listMutationRecords(workspaceId: workspaceId) - nextMutationSequence = (mutations.map(\.sequence).max() ?? 0) + 1 + mutations = try await store.listMutations(workspaceId: workspaceId) headCheckpointId = Self.lineageHeadId(in: checkpoints) didLoadStoreState = true } - /// Merges in-memory checkpoint state with the shared store, then recomputes the head from the - /// parent chain (leaves) instead of wall-clock order alone, so a remote checkpoint does not - /// silently reorder lineage when `createdAt` is skewed. func reconcileCheckpointsWithStore() async throws { try await ensureLoaded() - let fromStore = try await store.listCheckpoints(workspaceId: workspaceId) - checkpoints = fromStore.sorted(by: checkpointSort) + checkpoints = try await store.listCheckpoints(workspaceId: workspaceId).sorted(by: checkpointSort) headCheckpointId = Self.lineageHeadId(in: checkpoints) } - /// Picks the current checkpoint "tip": checkpoints whose ids never appear as - /// ``Checkpoint/parentCheckpointId`` (DAG leaves). If the graph is forked, the leaf with the - /// latest `(createdAt, id)` is used. static func lineageHeadId(in checkpoints: [Checkpoint]) -> UUID? { - guard !checkpoints.isEmpty else { - return nil - } - let parentReferences = Set(checkpoints.compactMap(\.parentCheckpointId)) - let tips = checkpoints.filter { !parentReferences.contains($0.id) } - if tips.isEmpty { - return checkpoints.max(by: orderCheckpoints)?.id - } - if tips.count == 1 { - return tips[0].id - } - return tips.max(by: orderCheckpoints)?.id + guard !checkpoints.isEmpty else { return nil } + let referencedParents = Set(checkpoints.compactMap(\.parentID)) + let tips = checkpoints.filter { !referencedParents.contains($0.id) } + return (tips.isEmpty ? checkpoints : tips).max(by: orderCheckpoints)?.id } - private static func orderCheckpoints(_ lhs: Checkpoint, _ rhs: Checkpoint) -> Bool { + static func orderCheckpoints(_ lhs: Checkpoint, _ rhs: Checkpoint) -> Bool { if lhs.createdAt == rhs.createdAt { return lhs.id.uuidString < rhs.id.uuidString } @@ -82,115 +54,48 @@ extension Workspace { } func loadSnapshotOrThrow(id: UUID, workspaceId: UUID? = nil) async throws -> Snapshot { - let ownerWorkspaceId = workspaceId ?? self.workspaceId - guard let snapshot = try await store.loadSnapshot(id: id, workspaceId: ownerWorkspaceId) else { - throw WorkspaceError.snapshotNotFound(id) + let owner = workspaceId ?? self.workspaceId + guard let snapshot = try await store.loadSnapshot(id: id, workspaceId: owner) else { + throw WorkspaceError.revisionDataNotFound(id) } return snapshot } - func performDirectEdit( - kind: MutationRecord.Kind, - edit: FileEdit, - apply: () async throws -> Void - ) async throws { - if case .disabled = tracking { - try await apply() - return - } - - try await ensureLoaded() - let preview = try await previewEdits([edit]) - try await apply() - - let appliedFileChanges = markApplied(preview.edits.first?.fileChanges ?? []) - try await appendMutation( - kind: kind, - touchedPaths: preview.touchedPaths, - fileChanges: appliedFileChanges, - diff: appliedFileChanges.count == 1 ? appliedFileChanges[0].diff : nil - ) - } - - func performBinaryWrite(data: Data, path: WorkspacePath) async throws { - if case .disabled = tracking { - try await untrackedWriteData(data, to: path) - return - } - - let effect: FileEdit.Effect - let kind: FileTree.Kind - - if await exists(path) { - let info = try await fileInfo(at: path) - if info.kind == .directory { - throw WorkspaceError.unsupported("cannot write data to a directory: \(path)") - } - kind = info.kind - if computesDiffs { - effect = try await readData(from: path) == data ? .unchanged : .modified - } else { - effect = .modified - } - } else { - kind = .file - effect = .created - } - - try await untrackedWriteData(data, to: path) - try await appendMutation( - kind: .writeData, - touchedPaths: [path], - fileChanges: [ - FileEdit.FileChange( - path: path, - kind: kind, - effect: effect, - status: .applied, - diff: nil - ), - ], - diff: nil - ) - } - func persistCheckpoint( snapshot: Snapshot, label: String?, parentCheckpointId: UUID?, - baseCheckpointId: UUID?, - mergedFromWorkspaceId: UUID? = nil, - mergedFromCheckpointId: UUID? = nil, - rollbackSourceCheckpointId: UUID? = nil, - eventKind: CheckpointEvent.Kind, + origin: Checkpoint.Origin, comparisonSnapshot: Snapshot? = nil ) async throws -> Checkpoint { try await store.saveSnapshot(snapshot, workspaceId: workspaceId) - let parentCheckpoint = parentCheckpointId.flatMap { id in - checkpoints.first(where: { $0.id == id }) - } - - let previousSnapshot: Snapshot? + let parent = parentCheckpointId.flatMap { id in checkpoints.first(where: { $0.id == id }) } + let previous: Snapshot? if let comparisonSnapshot { - previousSnapshot = comparisonSnapshot - } else if let parentCheckpoint { - previousSnapshot = try? await store.loadSnapshot(id: parentCheckpoint.snapshotId, workspaceId: workspaceId) + previous = comparisonSnapshot + } else if let parent { + previous = try? await store.loadSnapshot(id: parent.snapshotId, workspaceId: workspaceId) } else { - previousSnapshot = nil + previous = nil } - let summary = snapshot.summary(comparedTo: previousSnapshot) + let baseline = previous ?? Snapshot( + rootPath: snapshot.rootPath, + entry: .missing(.init(path: snapshot.rootPath)) + ) + let summary = ChangeSet.compare( + before: baseline, + after: snapshot, + maxTextBytes: 1_000_000 + ).summary - let previousCursor = parentCheckpoint?.mutationCursor ?? 0 + let previousCursor = parent?.mutationCursor ?? 0 let currentCursor = latestMutationSequence() let checkpoint = Checkpoint( workspaceId: workspaceId, label: label, parentCheckpointId: parentCheckpointId, - baseCheckpointId: baseCheckpointId, - mergedFromWorkspaceId: mergedFromWorkspaceId, - mergedFromCheckpointId: mergedFromCheckpointId, - rollbackSourceCheckpointId: rollbackSourceCheckpointId, + origin: origin, firstMutationSequence: currentCursor > previousCursor ? previousCursor + 1 : nil, lastMutationSequence: currentCursor > previousCursor ? currentCursor : nil, mutationCursor: currentCursor, @@ -199,61 +104,29 @@ extension Workspace { ) try await store.saveCheckpoint(checkpoint) - checkpoints.append(checkpoint) checkpoints.sort(by: checkpointSort) headCheckpointId = checkpoint.id - - emitCheckpointEvent(CheckpointEvent(kind: eventKind, checkpoint: checkpoint)) + emitWorkspaceEvent(.checkpoint(checkpoint)) return checkpoint } - func appendMutation( - kind: MutationRecord.Kind, - touchedPaths: [WorkspacePath], - fileChanges: [FileEdit.FileChange], - diff: TextDiff? - ) async throws { - if case .disabled = tracking { - return - } - let provisional = MutationRecord( - sequence: 0, - workspaceId: workspaceId, - kind: kind, - touchedPaths: Array(Set(touchedPaths)).sorted(), - fileChanges: fileChanges.sorted(by: fileChangeSort), - diff: diff + func appendMutation(operation: Mutation.Operation, changes: ChangeSet) async throws { + guard recording != .off else { return } + let persisted = try await store.appendMutation( + Mutation(sequence: 0, workspaceID: workspaceId, operation: operation, changes: changes) ) - let persisted = try await store.appendMutation(provisional) mutations.append(persisted) mutations.sort { $0.sequence < $1.sequence } - nextMutationSequence = (mutations.map(\.sequence).max() ?? 0) + 1 } func latestMutationSequence() -> Int { - mutations.map(\.sequence).max() ?? 0 - } - - /// Returns the tracked mutation history for this workspace, oldest first. - /// - /// The amount of detail per record is governed by the workspace's ``Workspace/TrackingPolicy``; - /// with ``Workspace/TrackingPolicy/disabled`` the history is empty. - public func mutationRecords() async throws -> [MutationRecord] { - try await ensureLoaded() - return mutations + mutations.last?.sequence ?? 0 } - /// Encodes `value` to bytes and a UTF-8 `String` with a **single trailing newline** for - /// line-oriented / POSIX-friendly file endings. Decoding via ``Workspace/readJSON(from:)`` - /// still works because the JSON comes first; keep this in mind if you read raw bytes with a - /// different decoder. func encodedJSONString(for value: T, prettyPrinted: Bool) throws -> String { let encoder = JSONEncoder() - if prettyPrinted { - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - } - + if prettyPrinted { encoder.outputFormatting = [.prettyPrinted, .sortedKeys] } var data = try encoder.encode(value) data.append(Data("\n".utf8)) guard let string = String(data: data, encoding: .utf8) else { @@ -262,30 +135,8 @@ extension Workspace { return string } - func markApplied(_ fileChanges: [FileEdit.FileChange]) -> [FileEdit.FileChange] { - fileChanges.map { change in - var copy = change - copy.status = .applied - return copy - } - } - - func untrackedRestore(_ snapshot: Snapshot) async throws { - try await Snapshot.restore(snapshot, to: filesystem) - } - - func removeCheckpointWatcher(id: UUID) { - checkpointWatchers.removeValue(forKey: id) - if checkpointWatchers.isEmpty { - checkpointPollingTask?.cancel() - checkpointPollingTask = nil - } - } - func ensureCheckpointPolling() { - guard checkpointPollingTask == nil else { - return - } + guard checkpointPollingTask == nil else { return } checkpointPollingTask = Task { [weak self] in while !Task.isCancelled, let workspace = self { let interval = await workspace.checkpointEventPollInterval @@ -296,215 +147,27 @@ extension Workspace { } func pollCheckpointEvents() async { - guard !checkpointWatchers.isEmpty else { - return - } - guard let loaded = try? await store.listCheckpoints(workspaceId: workspaceId) else { - return - } + guard eventWatchers.values.contains(where: { $0.filter.includeCheckpoints }) else { return } + guard (try? await ensureLoaded()) != nil else { return } + guard let stored = try? await store.listCheckpoints(workspaceId: workspaceId) else { return } - let newCheckpoints = loaded.filter { cp in - !checkpoints.contains(where: { $0.id == cp.id }) + let newCheckpoints = stored.filter { candidate in + !checkpoints.contains(where: { $0.id == candidate.id }) } + guard !newCheckpoints.isEmpty else { return } - if !newCheckpoints.isEmpty { - checkpoints.append(contentsOf: newCheckpoints) - checkpoints.sort(by: checkpointSort) - headCheckpointId = Self.lineageHeadId(in: checkpoints) - - if let refreshedMutations = try? await store.listMutationRecords(workspaceId: workspaceId) { - mutations = refreshedMutations.sorted { $0.sequence < $1.sequence } - nextMutationSequence = (mutations.map(\.sequence).max() ?? 0) + 1 - } + checkpoints.append(contentsOf: newCheckpoints) + checkpoints.sort(by: checkpointSort) + headCheckpointId = Self.lineageHeadId(in: checkpoints) + if let refreshed = try? await store.listMutations(workspaceId: workspaceId) { + mutations = refreshed.sorted { $0.sequence < $1.sequence } } - for checkpoint in newCheckpoints.sorted(by: checkpointSort) { - emitCheckpointEvent(CheckpointEvent(kind: checkpoint.inferredEventKind, checkpoint: checkpoint)) + emitWorkspaceEvent(.checkpoint(checkpoint)) } } - func emitCheckpointEvent(_ event: CheckpointEvent) { - guard !checkpointWatchers.isEmpty else { - return - } - - for id in Array(checkpointWatchers.keys) { - guard var watcher = checkpointWatchers[id] else { - continue - } - guard watcher.deliveredCheckpointIds.insert(event.checkpoint.id).inserted else { - continue - } - watcher.continuation.yield(event) - checkpointWatchers[id] = watcher - } - } - - func snapshotDelta(from original: Snapshot.Entry, to updated: Snapshot.Entry) -> SnapshotDelta { - var delta = SnapshotDelta() - collectDelta(from: original, to: updated, into: &delta) - delta.fileChanges.sort(by: fileChangeSort) - return delta - } - - func collectDelta( - from original: Snapshot.Entry, - to updated: Snapshot.Entry, - into delta: inout SnapshotDelta - ) { - switch (original, updated) { - case (.missing, .missing): - return - case (.missing, _): - collectCreated(updated, into: &delta) - case (_, .missing): - collectDeleted(original, into: &delta) - case let (.file(lhs), .file(rhs)): - if lhs.data != rhs.data || lhs.permissions != rhs.permissions { - delta.touchedPaths.insert(rhs.path) - let diff = lhs.data != rhs.data ? utf8TextFileDiff(oldData: lhs.data, newData: rhs.data) : nil - delta.fileChanges.append( - FileEdit.FileChange( - path: rhs.path, - kind: .file, - effect: .modified, - status: .applied, - diff: diff - ) - ) - } - case let (.symlink(lhs), .symlink(rhs)): - if lhs.target != rhs.target || lhs.permissions != rhs.permissions { - delta.touchedPaths.insert(rhs.path) - delta.fileChanges.append( - FileEdit.FileChange( - path: rhs.path, - kind: .symlink, - effect: .modified, - status: .applied, - diff: nil - ) - ) - } - case let (.directory(lhs), .directory(rhs)): - if lhs.permissions != rhs.permissions { - delta.touchedPaths.insert(rhs.path) - } - - let lhsChildren = Dictionary(uniqueKeysWithValues: lhs.children.map { ($0.path.basename, $0) }) - let rhsChildren = Dictionary(uniqueKeysWithValues: rhs.children.map { ($0.path.basename, $0) }) - let names = Set(lhsChildren.keys).union(rhsChildren.keys).sorted() - - for name in names { - switch (lhsChildren[name], rhsChildren[name]) { - case let (lhs?, rhs?): - collectDelta(from: lhs, to: rhs, into: &delta) - case let (lhs?, nil): - collectDeleted(lhs, into: &delta) - case let (nil, rhs?): - collectCreated(rhs, into: &delta) - case (nil, nil): - break - } - } - default: - collectDeleted(original, into: &delta) - collectCreated(updated, into: &delta) - } - } - - func collectCreated(_ entry: Snapshot.Entry, into delta: inout SnapshotDelta) { - switch entry { - case let .missing(entry): - delta.touchedPaths.insert(entry.path) - case let .file(entry): - delta.touchedPaths.insert(entry.path) - delta.fileChanges.append( - FileEdit.FileChange( - path: entry.path, - kind: .file, - effect: .created, - status: .applied, - diff: utf8TextFileDiff(oldData: Data(), newData: entry.data) - ) - ) - case let .directory(entry): - delta.touchedPaths.insert(entry.path) - for child in entry.children { - collectCreated(child, into: &delta) - } - case let .symlink(entry): - delta.touchedPaths.insert(entry.path) - delta.fileChanges.append( - FileEdit.FileChange( - path: entry.path, - kind: .symlink, - effect: .created, - status: .applied, - diff: nil - ) - ) - } - } - - func collectDeleted(_ entry: Snapshot.Entry, into delta: inout SnapshotDelta) { - switch entry { - case let .missing(entry): - delta.touchedPaths.insert(entry.path) - case let .file(entry): - delta.touchedPaths.insert(entry.path) - delta.fileChanges.append( - FileEdit.FileChange( - path: entry.path, - kind: .file, - effect: .deleted, - status: .applied, - diff: utf8TextFileDiff(oldData: entry.data, newData: Data()) - ) - ) - case let .directory(entry): - delta.touchedPaths.insert(entry.path) - for child in entry.children { - collectDeleted(child, into: &delta) - } - case let .symlink(entry): - delta.touchedPaths.insert(entry.path) - delta.fileChanges.append( - FileEdit.FileChange( - path: entry.path, - kind: .symlink, - effect: .deleted, - status: .applied, - diff: nil - ) - ) - } - } - - func utf8TextFileDiff(oldData: Data, newData: Data) -> TextDiff? { - guard case let .fullDiffs(maxDiffBytes) = tracking else { - return nil - } - if let maxDiffBytes, oldData.count > maxDiffBytes || newData.count > maxDiffBytes { - return nil - } - guard let oldString = String(data: oldData, encoding: .utf8), - let newString = String(data: newData, encoding: .utf8) - else { - return nil - } - let diff = TextDiff.lineBased(from: oldString, to: newString) - return diff.hunks.isEmpty ? nil : diff - } - func checkpointSort(lhs: Checkpoint, rhs: Checkpoint) -> Bool { Self.orderCheckpoints(lhs, rhs) } - - func fileChangeSort(lhs: FileEdit.FileChange, rhs: FileEdit.FileChange) -> Bool { - if lhs.path == rhs.path { - return lhs.effect.rawValue < rhs.effect.rawValue - } - return lhs.path < rhs.path - } } diff --git a/Sources/Workspace/Workspace+Merge.swift b/Sources/Workspace/Workspace+Merge.swift new file mode 100644 index 0000000..fb1d13d --- /dev/null +++ b/Sources/Workspace/Workspace+Merge.swift @@ -0,0 +1,74 @@ +import Foundation + +extension Workspace { + /// A flat tree node used by transaction three-way commits. + struct MergeNode: Equatable { + var kind: FileTree.Kind + var permissions: POSIXPermissions + var fileData: Data? + var symlinkTarget: String? + } + + static func flattenMergeNodes(_ entry: Snapshot.Entry, into nodes: inout [WorkspacePath: MergeNode]) { + switch entry { + case .missing: + return + case let .file(file): + nodes[file.path] = MergeNode(kind: .file, permissions: file.permissions, fileData: file.data) + case let .symlink(link): + nodes[link.path] = MergeNode( + kind: .symlink, + permissions: link.permissions, + symlinkTarget: link.target + ) + case let .directory(directory): + nodes[directory.path] = MergeNode(kind: .directory, permissions: directory.permissions) + for child in directory.children { + flattenMergeNodes(child, into: &nodes) + } + } + } + + static func buildEntryTree( + from nodes: [WorkspacePath: MergeNode], + rootPermissions: POSIXPermissions + ) -> Snapshot.Entry { + var childNames: [WorkspacePath: [String]] = [:] + for path in nodes.keys { + childNames[path.parent, default: []].append(path.name) + } + + func subtree(at path: WorkspacePath, node: MergeNode?) -> Snapshot.Entry { + switch node?.kind ?? .directory { + case .file: + return .file( + .init( + path: path, + data: node?.fileData ?? Data(), + permissions: node?.permissions ?? .defaultFile + ) + ) + case .symlink: + return .symlink( + .init( + path: path, + target: node?.symlinkTarget ?? "", + permissions: node?.permissions ?? POSIXPermissions(0o777) + ) + ) + case .directory: + return .directory( + .init( + path: path, + permissions: node?.permissions ?? .defaultDirectory, + children: (childNames[path] ?? []).sorted().map { name in + let child = path.appending(name) + return subtree(at: child, node: nodes[child]) + } + ) + ) + } + } + return subtree(at: .root, node: MergeNode(kind: .directory, permissions: rootPermissions)) + } +} diff --git a/Sources/Workspace/Workspace.swift b/Sources/Workspace/Workspace.swift index 4ddfe9c..04ba5c6 100644 --- a/Sources/Workspace/Workspace.swift +++ b/Sources/Workspace/Workspace.swift @@ -1,191 +1,98 @@ import Foundation -/// A high-level API for reading, editing, and summarizing a workspace. +/// A high-level API for reading, editing, versioning, and observing a workspace. public actor Workspace { - /// Where to persist checkpoint and snapshot data. - public enum Storage: Sendable { - /// In-memory storage that does not survive process exit. - case inMemory - /// File-backed JSON storage rooted at `url`. - /// - /// Checkpoints and snapshots are individual JSON files; mutations are recorded in - /// `mutations.jsonl` (append-friendly, one record per line). A legacy `mutations.json` array is - /// migrated automatically. The store assigns monotonic `MutationRecord.sequence` values under - /// `mutations.lock` using an advisory lock where the OS supports it, so writers sharing a - /// `workspaceId` do not reuse sequence numbers. Multiple processes should still treat the - /// directory as a single-writer domain when the underlying filesystem does not honor advisory locks. - case directory(at: URL) + /// Checkpoint and history persistence. + public enum Persistence: Sendable { + case memory + case directory(URL) } - /// Controls how much detail tracked writes record in the mutation log. - public enum TrackingPolicy: Sendable, Equatable { - /// Record full mutation records including per-file line diffs. `maxDiffBytes` caps the - /// content size for which diffs are computed; larger files are recorded without a diff. - case fullDiffs(maxDiffBytes: Int?) - /// Record mutation records with touched paths and effects but no text diffs. - case pathsOnly - /// Record no mutation history at all. Change events still fire, but ``Workspace/merge(_:label:)`` - /// cannot detect uncheckpointed edits made in this mode. - case disabled + /// Controls mutation-history detail without affecting explicit previews or diffs. + public enum RecordingPolicy: Sendable, Equatable { + case full(maxTextBytes: Int?) + case changesOnly + case off - /// Full diffs with no size cap. The default. - public static let full = TrackingPolicy.fullDiffs(maxDiffBytes: nil) + public static let `default` = RecordingPolicy.full(maxTextBytes: 1_000_000) } - struct CheckpointWatcher { - var deliveredCheckpointIds: Set - var continuation: AsyncStream.Continuation + struct EventWatcher { + var filter: WorkspaceEvent.Filter + var continuation: AsyncStream.Continuation } - public nonisolated let workspaceId: UUID - - /// The workspace's backing filesystem. - /// - /// The property is immutable after initialization, but the returned filesystem retains its normal - /// ``FileSystem`` capabilities. - public nonisolated let filesystem: any FileSystem - /// How much detail tracked writes record. See ``TrackingPolicy``. - public nonisolated let tracking: TrackingPolicy + nonisolated let workspaceId: UUID + public nonisolated var workspaceID: UUID { workspaceId } + nonisolated let filesystem: any FileSystem + public nonisolated let recording: RecordingPolicy 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 var checkpoints: [Checkpoint] = [] - var mutations: [MutationRecord] = [] + var mutations: [Mutation] = [] var headCheckpointId: UUID? - var nextMutationSequence = 1 - var checkpointWatchers: [UUID: CheckpointWatcher] = [:] + var eventWatchers: [UUID: EventWatcher] = [:] var checkpointPollingTask: Task? + var checkpointEventPollInterval: Duration = .milliseconds(500) - /// Sleep interval between polling the shared store for new checkpoints when - /// ``watchCheckpointEvents()`` has active subscribers. Tests may set a short value to reduce - /// wall-clock wait time. - public var checkpointEventPollInterval: Duration = .milliseconds(500) - - private var watchers: [UUID: WatchedChangeStream] = [:] - - private struct WatchedChangeStream { - var path: WorkspacePath - var recursive: Bool - var continuation: AsyncStream.Continuation - } - - /// Creates an in-memory workspace suitable for tests and ephemeral work. - public init() { - self.init(filesystem: InMemoryFilesystem()) - } - - /// Creates a workspace backed by `filesystem`. + /// Creates a workspace over a filesystem. public init( - workspaceId: UUID = UUID(), - filesystem: any FileSystem, - storage: Storage = .inMemory, - tracking: TrackingPolicy = .full - ) { - self.init( - workspaceId: workspaceId, - filesystem: filesystem, - store: Self.makeStore(for: storage), - baseCheckpointId: nil, - tracking: tracking - ) - } - - init( - workspaceId: UUID = UUID(), - filesystem: any FileSystem, - store: any CheckpointStore, - baseCheckpointId: UUID? = nil, - baseMutationCursor: Int? = nil, - tracking: TrackingPolicy = .full + workspaceID: UUID = UUID(), + fileSystem: any FileSystem = InMemoryFileSystem(), + persistence: Persistence = .memory, + recording: RecordingPolicy = .default ) { - self.workspaceId = workspaceId - self.filesystem = filesystem - self.store = store - self.baseCheckpointId = baseCheckpointId - self.baseMutationCursor = baseMutationCursor - self.tracking = tracking - } - - static func makeStore(for storage: Storage) -> any CheckpointStore { - switch storage { - case .inMemory: + self.workspaceId = workspaceID + self.filesystem = fileSystem + self.recording = recording + self.store = switch persistence { + case .memory: InMemoryCheckpointStore() - case .directory(at: let url): + case let .directory(url): FileCheckpointStore(rootDirectory: url) } } - /// Reads raw file contents from the workspace. - public func readData(from path: WorkspacePath) async throws -> Data { - try await filesystem.readFile(path: path) - } - - /// Reads up to `length` bytes starting at `offset` without loading the whole file when the - /// backing filesystem supports ranged reads. - public func readData(from path: WorkspacePath, offset: UInt64, length: Int? = nil) async throws -> Data { - try await filesystem.readFile(path: path, offset: offset, length: length) - } - - /// Writes raw file data to the workspace, replacing any existing contents. - public func writeData(_ data: Data, to path: WorkspacePath) async throws { - try await ensureLoaded() - try await performBinaryWrite(data: data, path: path) - } - - func untrackedWriteData(_ data: Data, to path: WorkspacePath, append: Bool = false) async throws { - let events = try await plannedWriteEvents(path: path, data: data, append: append, on: filesystem) - try await filesystem.writeFile(path: path, data: data, append: append) - emit(events) - } - - /// Reads a UTF-8 file from the workspace. - public func readFile(_ path: WorkspacePath) async throws -> String { - let data = try await filesystem.readFile(path: path) - guard let string = String(data: data, encoding: .utf8) else { - throw WorkspaceError.invalidEncoding(path) - } - return string - } - - /// Writes UTF-8 text to a file, replacing any existing contents. - public func writeFile(_ path: WorkspacePath, content: String) async throws { - try await performDirectEdit( - kind: .writeFile, - edit: .writeFile(path: path, content: content) - ) { - try await self.untrackedWriteFile(path, content: content) + /// Reads raw file contents from a workspace revision. + public func readData(from path: WorkspacePath, at revision: Revision = .current) async throws -> Data { + switch revision { + case .current: + try await filesystem.readFile(path: path) + case .checkpoint: + try await revisionData(at: path, revision: revision) } } - func untrackedWriteFile(_ path: WorkspacePath, content: String) async throws { - try await untrackedWriteData(Data(content.utf8), to: path, append: false) - } - - /// Appends UTF-8 text to a file. - public func appendFile(_ path: WorkspacePath, content: String) async throws { - try await performDirectEdit( - kind: .appendFile, - edit: .appendFile(path: path, content: content) - ) { - try await self.untrackedAppendFile(path, content: content) + /// Reads a byte range without loading the whole file when the backing implementation supports it. + public func readData( + from path: WorkspacePath, + at revision: Revision = .current, + offset: UInt64, + length: Int? = nil + ) async throws -> Data { + switch revision { + case .current: + try await filesystem.readFile(path: path, offset: offset, length: length) + case .checkpoint: + try await revisionData(at: path, revision: revision, offset: offset, length: length) } } - func untrackedAppendFile(_ path: WorkspacePath, content: String) async throws { - let data = Data(content.utf8) - let events = try await plannedWriteEvents(path: path, data: data, append: true, on: filesystem) - try await filesystem.writeFile(path: path, data: data, append: true) - emit(events) + /// Writes raw data, replacing any existing contents. + @discardableResult + public func writeData(_ data: Data, to path: WorkspacePath) async throws -> ChangeSet { + try await apply([.writeData(path, data)]).changes } - /// Reads and decodes JSON from a UTF-8 file. - public func readJSON(_ type: T.Type = T.self, from path: WorkspacePath) async throws -> T { - let data = try await filesystem.readFile(path: path) + /// Reads and decodes JSON from a file. + public func readJSON( + _ type: T.Type = T.self, + from path: WorkspacePath, + at revision: Revision = .current + ) async throws -> T { + let data = try await readData(from: path, at: revision) do { return try JSONDecoder().decode(T.self, from: data) } catch { @@ -193,1400 +100,50 @@ public actor Workspace { } } - /// Encodes a value as JSON and writes it to a file. The on-disk file includes a single trailing - /// newline after the JSON for line-oriented tools; ``readJSON`` still decodes the value correctly. - public func writeJSON(_ value: T, to path: WorkspacePath, prettyPrinted: Bool = true) async throws { + /// Encodes JSON with a single trailing newline and writes it through the edit pipeline. + @discardableResult + public func writeJSON( + _ value: T, + to path: WorkspacePath, + prettyPrinted: Bool = true + ) async throws -> ChangeSet { let content = try encodedJSONString(for: value, prettyPrinted: prettyPrinted) - try await performDirectEdit( - kind: .writeJSON, - edit: .writeFile(path: path, content: content) - ) { - try await self.untrackedWriteFile(path, content: content) - } - } - - /// Watches for future changes affecting `path`. - public func watchChanges(at path: WorkspacePath, recursive: Bool = true) -> AsyncStream { - let id = UUID() - var continuation: AsyncStream.Continuation? - let stream = AsyncStream { - continuation = $0 - } - - guard let continuation else { - return stream - } - - watchers[id] = WatchedChangeStream( - path: path, - recursive: recursive, - continuation: continuation - ) - continuation.onTermination = { [weak self] _ in - Task { - await self?.removeWatcher(id: id) - } - } - return stream - } - - /// Returns whether an entry exists at `path`. - public func exists(_ path: WorkspacePath) async -> Bool { - await filesystem.exists(path: path) - } - - /// Returns metadata for the entry at `path`. - public func fileInfo(at path: WorkspacePath) async throws -> FileInfo { - try await filesystem.stat(path: path) - } - - /// Lists the direct children of the directory at `path`. - public func listDirectory(at path: WorkspacePath) async throws -> [DirectoryEntry] { - try await filesystem.listDirectory(path: path) - } - - /// Expands a glob pattern relative to `currentDirectory`. - public func glob(_ pattern: String, currentDirectory: WorkspacePath = .root) async throws -> [WorkspacePath] { - try await filesystem.glob( - pattern: pattern, - currentDirectory: currentDirectory - ) - } - - /// Creates a directory at `path`. - public func createDirectory(at path: WorkspacePath, recursive: Bool = true) async throws { - try await performDirectEdit( - kind: .createDirectory, - edit: .createDirectory(path: path, recursive: recursive) - ) { - try await self.untrackedCreateDirectory(at: path, recursive: recursive) - } - } - - func untrackedCreateDirectory(at path: WorkspacePath, recursive: Bool = true) async throws { - let events = try await plannedDirectoryCreationEvents(path: path, recursive: recursive, on: filesystem) - try await filesystem.createDirectory(path: path, recursive: recursive) - emit(events) - } - - /// Removes the entry at `path`. - public func removeItem(at path: WorkspacePath, recursive: Bool = true) async throws { - try await performDirectEdit( - kind: .removeItem, - edit: .delete(path: path, recursive: recursive) - ) { - try await self.untrackedRemoveItem(at: path, recursive: recursive) - } - } - - func untrackedRemoveItem(at path: WorkspacePath, recursive: Bool = true) async throws { - let events = try await plannedDeletionEvents(at: path, on: filesystem) - try await filesystem.remove(path: path, recursive: recursive) - emit(events) - } - - /// Copies an entry from `source` to `destination`. - public func copyItem(from source: WorkspacePath, to destination: WorkspacePath, recursive: Bool = true) - async throws - { - try await performDirectEdit( - kind: .copyItem, - edit: .copy(from: source, to: destination, recursive: recursive) - ) { - try await self.untrackedCopyItem(from: source, to: destination, recursive: recursive) - } - } - - func untrackedCopyItem(from source: WorkspacePath, to destination: WorkspacePath, recursive: Bool = true) - async throws - { - let events = try await plannedTransferEvents( - from: source, - to: destination, - kind: .copied, - on: filesystem - ) - try await filesystem.copy( - from: source, - to: destination, - recursive: recursive - ) - emit(events) - } - - /// Moves or renames an entry from `source` to `destination`. - public func moveItem(from source: WorkspacePath, to destination: WorkspacePath) async throws { - try await performDirectEdit( - kind: .moveItem, - edit: .move(from: source, to: destination) - ) { - try await self.untrackedMoveItem(from: source, to: destination) - } - } - - func untrackedMoveItem(from source: WorkspacePath, to destination: WorkspacePath) async throws { - let events = try await plannedTransferEvents( - from: source, - to: destination, - kind: .moved, - on: filesystem - ) - try await filesystem.move( - from: source, - to: destination - ) - emit(events) - } - - /// Builds a recursive tree representation for the entry at `path`. - public func walkTree(_ path: WorkspacePath, maxDepth: Int? = nil) async throws -> FileTree { - try await buildTree(path: path, depth: 0, maxDepth: maxDepth) - } - - /// Summarizes the subtree rooted at `path`. - public func summarizeTree(_ path: WorkspacePath, maxDepth: Int? = nil) async throws -> FileTreeSummary { - try await buildSummary(path: path, depth: 0, maxDepth: maxDepth) - } - - /// Captures a durable snapshot of the subtree rooted at `path`. - /// - /// The returned ``Snapshot`` records file contents, symlinks, directory structure, - /// and POSIX permissions, and can later be replayed with ``restoreSnapshot(_:)``. - /// - /// - Parameter path: The subtree to capture. Defaults to the workspace root. - public func captureSnapshot(at path: WorkspacePath = .root) async throws -> Snapshot { - try await Snapshot.capture(from: filesystem, at: path) - } - - /// Restores `snapshot` onto the workspace, removing any extra entries beneath the - /// snapshot's root so that the workspace exactly matches the captured tree. - public func restoreSnapshot(_ snapshot: Snapshot) async throws { - try await ensureLoaded() - let previousSnapshot = try await captureSnapshot() - try await untrackedRestore(snapshot) - let restoredSnapshot = try await captureSnapshot() - let delta = snapshotDelta(from: previousSnapshot.entry, to: restoredSnapshot.entry) - let topDiff: TextDiff? = delta.fileChanges.count == 1 ? delta.fileChanges[0].diff : nil - try await appendMutation( - kind: .restoreSnapshot, - touchedPaths: Array(delta.touchedPaths).sorted(), - fileChanges: delta.fileChanges, - diff: topDiff - ) - } - - private struct PlannedReplacement { - var change: ReplacementResult.Change - var updatedContent: String - var nodeKind: FileTree.Kind - } - - private struct PlannedBatchEdit { - var edit: FileEdit - var entry: FileEdit.Entry - var changeEvents: [ChangeEvent] - } - - /// Returns a preview of a replacement request without mutating the workspace. - public func previewReplacement(_ request: ReplacementRequest) async throws -> ReplacementResult { - let changes = try await plannedReplacementChanges(for: request).map(\.change) - return ReplacementResult( - mode: .preview, - touchedPaths: canonicalizedTouchedPaths(for: changes), - changes: changes, - rolledBack: false - ) - } - - /// Applies a replacement request across matching files. - /// - /// - Parameters: - /// - request: The replacement request to execute. - /// - failurePolicy: The behavior to use when a write fails. - public func applyReplacement( - _ request: ReplacementRequest, - failurePolicy: MutationFailurePolicy = .rollback - ) async throws -> ReplacementResult { - try await ensureLoaded() - let result = try await untrackedApplyReplacement(request, failurePolicy: failurePolicy) - if !result.changes.isEmpty || !result.failures.isEmpty { - try await appendMutation( - kind: .applyReplacement, - touchedPaths: result.touchedPaths, - fileChanges: result.changes.map { - FileEdit.FileChange( - path: $0.path, - kind: .file, - effect: .modified, - status: $0.status, - diff: $0.diff - ) - }, - diff: result.changes.count == 1 ? result.changes[0].diff : nil - ) - } - return result - } - - func untrackedApplyReplacement( - _ request: ReplacementRequest, - failurePolicy: MutationFailurePolicy = .rollback - ) async throws -> ReplacementResult { - let plannedChanges = try await plannedReplacementChanges(for: request) - var changes = plannedChanges.map(\.change) - let touchedPaths = canonicalizedTouchedPaths(for: changes) - var bufferedEvents: [ChangeEvent] = [] - - guard !changes.isEmpty else { - return ReplacementResult( - mode: .execution, - touchedPaths: touchedPaths, - changes: changes, - rolledBack: false - ) - } - - let captures = failurePolicy == .rollback ? try await rollbackCaptures(for: touchedPaths) : [] - var failures: [ReplacementResult.Failure] = [] - var appliedIndices: [Int] = [] - - for (index, plannedChange) in plannedChanges.enumerated() { - do { - try await write(change: plannedChange, to: filesystem) - changes[index].status = .applied - appliedIndices.append(index) - bufferedEvents += changeEvents(for: plannedChange) - } catch { - changes[index].status = .failed - let failure = ReplacementResult.Failure(path: plannedChange.change.path, message: describe(error)) - if failurePolicy == .rollback { - try await rollback(captures) - for appliedIndex in appliedIndices { - changes[appliedIndex].status = .rolledBack - } - for skippedIndex in changes.indices where skippedIndex > index { - changes[skippedIndex].status = .skipped - } - return ReplacementResult( - mode: .execution, - touchedPaths: touchedPaths, - changes: changes, - failures: [failure], - rolledBack: true - ) - } - - failures.append(failure) - if failurePolicy == .failFast { - for skippedIndex in changes.indices where skippedIndex > index { - changes[skippedIndex].status = .skipped - } - emit(bufferedEvents) - return ReplacementResult( - mode: .execution, - touchedPaths: touchedPaths, - changes: changes, - failures: failures, - rolledBack: false - ) - } - } - } - - emit(bufferedEvents) - return ReplacementResult( - mode: .execution, - touchedPaths: touchedPaths, - changes: changes, - failures: failures, - rolledBack: false - ) - } - - /// Returns a preview of a batch edit request without mutating the workspace. - public func previewEdits(_ edits: [FileEdit]) async throws -> FileEdit.BatchResult { - let plannedEdits = try await planBatchEdits(edits) - return FileEdit.BatchResult( - mode: .preview, - touchedPaths: canonicalizedTouchedPaths(for: edits), - edits: plannedEdits.map(\.entry), - rolledBack: false - ) - } - - /// Applies a batch of filesystem edits. - /// - /// - Parameters: - /// - edits: The edits to execute. - /// - failurePolicy: The behavior to use when an edit fails. - public func applyEdits( - _ edits: [FileEdit], - failurePolicy: MutationFailurePolicy = .rollback - ) async throws -> FileEdit.BatchResult { - try await ensureLoaded() - let result = try await untrackedApplyEdits(edits, failurePolicy: failurePolicy) - if !edits.isEmpty { - let topDiff: TextDiff? = - if result.edits.count == 1, let single = result.edits.first, single.fileChanges.count == 1 { - single.fileChanges[0].diff - } else { - nil - } - try await appendMutation( - kind: .applyEdits, - touchedPaths: result.touchedPaths, - fileChanges: result.edits.flatMap(\.fileChanges), - diff: topDiff - ) - } - return result - } - - func untrackedApplyEdits( - _ edits: [FileEdit], - failurePolicy: MutationFailurePolicy = .rollback - ) async throws -> FileEdit.BatchResult { - let touchedPaths = canonicalizedTouchedPaths(for: edits) - let plannedEdits = try await planBatchEdits(edits) - var executionEntries = plannedEdits.map(\.entry) - var bufferedEvents: [ChangeEvent] = [] - - guard !plannedEdits.isEmpty else { - return FileEdit.BatchResult( - mode: .execution, - touchedPaths: touchedPaths, - edits: executionEntries, - rolledBack: false - ) - } - - let captures = failurePolicy == .rollback ? try await rollbackCaptures(for: touchedPaths) : [] - var failures: [FileEdit.Failure] = [] - var appliedIndices: [Int] = [] - - for (index, plannedEdit) in plannedEdits.enumerated() { - do { - try await apply(plannedEdit.edit, on: filesystem) - setStatus(.applied, for: &executionEntries[index]) - appliedIndices.append(index) - bufferedEvents += plannedEdit.changeEvents - } catch { - setStatus(.failed, for: &executionEntries[index]) - let failure = FileEdit.Failure( - index: index, - edit: plannedEdit.edit, - message: describe(error) - ) - if failurePolicy == .rollback { - try await rollback(captures) - for appliedIndex in appliedIndices { - setStatus(.rolledBack, for: &executionEntries[appliedIndex]) - } - for skippedIndex in executionEntries.indices where skippedIndex > index { - setStatus(.skipped, for: &executionEntries[skippedIndex]) - } - return FileEdit.BatchResult( - mode: .execution, - touchedPaths: touchedPaths, - edits: executionEntries, - failures: [failure], - rolledBack: true - ) - } - - failures.append(failure) - if failurePolicy == .failFast { - for skippedIndex in executionEntries.indices where skippedIndex > index { - setStatus(.skipped, for: &executionEntries[skippedIndex]) - } - emit(bufferedEvents) - return FileEdit.BatchResult( - mode: .execution, - touchedPaths: touchedPaths, - edits: executionEntries, - failures: failures, - rolledBack: false - ) - } - } - } - - emit(bufferedEvents) - return FileEdit.BatchResult( - mode: .execution, - touchedPaths: touchedPaths, - edits: executionEntries, - failures: failures, - rolledBack: false - ) - } - - private func readUTF8IfPresent( - _ path: WorkspacePath, - on target: any FileSystem, - strictInvalidUTF8: Bool = true - ) async throws -> String? { - guard await target.exists(path: path) else { - return nil - } - let info = try await target.stat(path: path) - guard info.kind != .directory else { - return nil - } - let data = try await target.readFile(path: path) - guard let string = String(data: data, encoding: .utf8) else { - if strictInvalidUTF8 { - throw WorkspaceError.invalidEncoding(path) - } - return nil - } - return string - } - - private func buildTree(path: WorkspacePath, depth: Int, maxDepth: Int?) async throws -> FileTree { - let info = try await filesystem.stat(path: path) - let children: [FileTree]? - - if info.kind == .directory, maxDepth.map({ depth < $0 }) ?? true { - let entries = try await filesystem.listDirectory(path: path) - children = try await entries - .sorted { $0.name < $1.name } - .asyncMap { [self] entry in - try await self.buildTree( - path: path.appending(entry.name), - depth: depth + 1, - maxDepth: maxDepth - ) - } - } else { - children = nil - } - - return FileTree( - path: path, - kind: info.kind, - size: info.size, - permissions: info.permissions, - modificationDate: info.modificationDate, - children: children - ) - } - - private func buildSummary(path: WorkspacePath, depth: Int, maxDepth: Int?) async throws -> FileTreeSummary { - let info = try await filesystem.stat(path: path) - var fileCount = info.kind == .directory ? 0 : 1 - var directoryCount = info.kind == .directory ? 1 : 0 - var symlinkCount = info.kind == .symlink ? 1 : 0 - var totalBytes = info.size - var childSummaries: [FileTreeSummary.Entry] = [] - - if info.kind == .directory, maxDepth.map({ depth < $0 }) ?? true { - let entries = try await filesystem.listDirectory(path: path) - for entry in entries.sorted(by: { $0.name < $1.name }) { - let childPath = path.appending(entry.name) - let childSummary = try await buildSummary(path: childPath, depth: depth + 1, maxDepth: maxDepth) - fileCount += childSummary.fileCount - directoryCount += childSummary.directoryCount - symlinkCount += childSummary.symlinkCount - totalBytes += childSummary.totalBytes - childSummaries.append( - FileTreeSummary.Entry( - path: childPath, - kind: entry.info.kind, - size: entry.info.size, - permissions: entry.info.permissions - ) - ) - } - } - - return FileTreeSummary( - path: path, - fileCount: fileCount, - directoryCount: directoryCount, - symlinkCount: symlinkCount, - totalBytes: totalBytes, - children: childSummaries - ) - } - - private func touchedPaths(for edit: FileEdit) -> [WorkspacePath] { - switch edit { - case let .writeFile(path, _): - return [path] - case let .appendFile(path, _): - return [path] - case let .delete(path, _): - return [path] - case let .createDirectory(path, _): - return [path] - case let .move(from, to): - return [from, to] - case let .copy(from, to, _): - return [from, to] - } - } - - private func canonicalizedTouchedPaths(for changes: [ReplacementResult.Change]) -> [WorkspacePath] { - Array(Set(changes.map(\.path))).sorted() - } - - private func canonicalizedTouchedPaths(for edits: [FileEdit]) -> [WorkspacePath] { - let paths = Set(edits.flatMap(touchedPaths(for:))) - return paths.sorted().filter { candidate in - !paths.contains { other in - other != candidate && candidate.string.hasPrefix(other.string + "/") - } - } - } - - private func removeWatcher(id: UUID) { - watchers.removeValue(forKey: id) - } - - private func emit(_ events: [ChangeEvent]) { - guard !events.isEmpty, !watchers.isEmpty else { - return - } - - let activeWatchers = Array(watchers.values) - for event in events { - for watcher in activeWatchers where shouldDeliver(event, to: watcher) { - watcher.continuation.yield(event) - } - } - } - - private func shouldDeliver(_ event: ChangeEvent, to watcher: WatchedChangeStream) -> Bool { - watchedPathMatches(event.path, watchedPath: watcher.path, recursive: watcher.recursive) - || watchedPathMatches(event.sourcePath, watchedPath: watcher.path, recursive: watcher.recursive) - } - - private func watchedPathMatches( - _ candidate: WorkspacePath?, - watchedPath: WorkspacePath, - recursive: Bool - ) -> Bool { - guard let candidate else { - return false - } - - if recursive { - return isEqualOrDescendant(candidate, of: watchedPath) - } - - return candidate == watchedPath - } - - private func isEqualOrDescendant(_ candidate: WorkspacePath, of ancestor: WorkspacePath) -> Bool { - if ancestor.isRoot { - return true - } - return candidate == ancestor || candidate.string.hasPrefix(ancestor.string + "/") - } - - private func statIfPresent(_ path: WorkspacePath, on target: any FileSystem) async throws -> FileInfo? { - guard await target.exists(path: path) else { - return nil - } - return try await target.stat(path: path) - } - - private func plannedReplacementChanges(for request: ReplacementRequest) async throws -> [PlannedReplacement] { - let paths = try await matchedPaths(for: request) - var changes: [PlannedReplacement] = [] - - for path in paths { - let info = try await filesystem.stat(path: path) - guard let originalContent = try await readUTF8IfPresent(path, on: filesystem) else { - continue - } - - let replacement = try replacement( - in: originalContent, - matching: request.search, - replacement: request.replacement - ) - guard replacement.count > 0 else { - continue - } - - changes.append( - PlannedReplacement( - change: .init( - path: path, - replacements: replacement.count, - diff: textDiff(from: originalContent, to: replacement.updatedContent) - ?? TextDiff(hunks: []) - ), - updatedContent: replacement.updatedContent, - nodeKind: info.kind - ) - ) - } - - return changes + return try await apply([.writeText(path, content)]).changes } - private func matchedPaths(for request: ReplacementRequest) async throws -> [WorkspacePath] { - let included = try await expandedPaths(for: request.include, currentDirectory: request.scope) - guard !included.isEmpty else { - return [] + /// Returns whether an entry exists at a revision. + public func exists(_ path: WorkspacePath, at revision: Revision = .current) async -> Bool { + switch revision { + case .current: + return await filesystem.exists(path: path) + case .checkpoint: + guard let resolved = try? await resolvedRevision(revision) else { return false } + return resolved.index.entry(at: path) != nil } - - let excluded = Set(try await expandedPaths(for: request.exclude, currentDirectory: request.scope)) - return included.filter { !excluded.contains($0) } } - private func expandedPaths( - for patterns: [String], - currentDirectory: WorkspacePath + /// Expands a glob relative to `currentDirectory` at a revision. + public func glob( + _ pattern: String, + currentDirectory: WorkspacePath = .root, + at revision: Revision = .current ) async throws -> [WorkspacePath] { - var matched: Set = [] - for pattern in patterns { - let paths = try await filesystem.glob(pattern: pattern, currentDirectory: currentDirectory) - matched.formUnion(paths) - } - return matched.sorted() - } - - private func replacement( - in content: String, - matching pattern: ReplacementRequest.Pattern, - replacement: String - ) throws -> (count: Int, updatedContent: String) { - switch pattern { - case let .literal(search, caseSensitive): - guard !search.isEmpty else { - throw WorkspaceError.unsupported("search pattern must not be empty") - } - - if caseSensitive { - let count = content.components(separatedBy: search).count - 1 - let updated = content.replacingOccurrences(of: search, with: replacement) - return (count, updated) - } - - let regex = try NSRegularExpression( - pattern: NSRegularExpression.escapedPattern(for: search), - options: [.caseInsensitive] - ) - let range = NSRange(content.startIndex.. [PlannedBatchEdit] { - let planningFilesystem = try await makePlanningFilesystem(for: canonicalizedTouchedPaths(for: edits)) - var plannedEdits: [PlannedBatchEdit] = [] - - for edit in edits { - let entry = try await planEntry(for: edit, on: planningFilesystem) - let changeEvents = try await plannedChangeEvents(for: edit, on: planningFilesystem) - plannedEdits.append(PlannedBatchEdit(edit: edit, entry: entry, changeEvents: changeEvents)) - try? await apply(edit, on: planningFilesystem) - } - - return plannedEdits - } - - private func makePlanningFilesystem(for paths: [WorkspacePath]) async throws -> InMemoryFilesystem { - let planningFilesystem = InMemoryFilesystem() - let ancestors = Set(paths.flatMap(ancestorPaths(for:))).sorted() - for ancestor in ancestors { - let capture = try await shallowRollbackCapture(path: ancestor, on: filesystem) - try await restore(capture, on: planningFilesystem) - } - - let captures = try await rollbackCaptures(for: paths) - for capture in captures { - try await restore(capture, on: planningFilesystem) - } - return planningFilesystem - } - - private func ancestorPaths(for path: WorkspacePath) -> [WorkspacePath] { - var ancestors: [WorkspacePath] = [] - var current = path.dirname - while !current.isRoot { - ancestors.append(current) - current = current.dirname - } - return ancestors.reversed() - } - - private func planEntry( - for edit: FileEdit, - on target: any FileSystem - ) async throws -> FileEdit.Entry { - switch edit { - case let .writeFile(path, content): - let info = try await statIfPresent(path, on: target) - let original = computesDiffs ? try await readUTF8IfPresent(path, on: target) : nil - let effect: FileEdit.Effect = - if info == nil { - .created - } else if computesDiffs { - effect(forOriginalContent: original, updatedContent: content) - } else { - .modified - } - return FileEdit.Entry( - edit: edit, - changeState: changeState(for: effect), - touchedPaths: [path], - fileChanges: [ - textFileChange( - path: path, - kind: info.map(\.kind) ?? .file, - originalContent: original, - updatedContent: content, - effect: effect - ) - ] - ) - case let .appendFile(path, content): - let info = try await statIfPresent(path, on: target) - let original = computesDiffs ? try await readUTF8IfPresent(path, on: target) : nil - let updated = (original ?? "") + content - let effect: FileEdit.Effect = - if info == nil { - .created - } else if computesDiffs { - effect(forOriginalContent: original, updatedContent: updated) - } else { - .modified - } - return FileEdit.Entry( - edit: edit, - changeState: changeState(for: effect), - touchedPaths: [path], - fileChanges: [ - textFileChange( - path: path, - kind: info.map(\.kind) ?? .file, - originalContent: original, - updatedContent: updated, - effect: effect - ) - ] - ) - case let .delete(path, _): - let existing = try await statIfPresent(path, on: target) - return FileEdit.Entry( - edit: edit, - changeState: existing == nil ? .unchanged : .changed, - touchedPaths: [path], - fileChanges: try await deletionFileChanges(at: path, on: target) - ) - case let .createDirectory(path, _): - let existing = try await statIfPresent(path, on: target) - return FileEdit.Entry( - edit: edit, - changeState: existing == nil ? .changed : .unchanged, - touchedPaths: [path] - ) - case let .move(from, to): - return FileEdit.Entry( - edit: edit, - changeState: from == to ? .unchanged : .changed, - touchedPaths: [from, to], - fileChanges: from == to ? [] : try await transferFileChanges(from: from, to: to, effect: .moved, on: target) - ) - case let .copy(from, to, _): - return FileEdit.Entry( - edit: edit, - changeState: .changed, - touchedPaths: [from, to], - fileChanges: try await transferFileChanges(from: from, to: to, effect: .copied, on: target) - ) - } - } - - private func changeState(for effect: FileEdit.Effect) -> FileEdit.ChangeState { - effect == .unchanged ? .unchanged : .changed - } - - private func effect(forOriginalContent originalContent: String?, updatedContent: String) -> FileEdit.Effect { - guard let originalContent else { - return .created - } - return originalContent == updatedContent ? .unchanged : .modified - } - - private func textFileChange( - path: WorkspacePath, - kind: FileTree.Kind, - originalContent: String?, - updatedContent: String, - effect: FileEdit.Effect - ) -> FileEdit.FileChange { - FileEdit.FileChange( - path: path, - kind: kind, - effect: effect, - diff: textDiff(from: originalContent ?? "", to: updatedContent) - ) - } - - private func deletionFileChanges( - at path: WorkspacePath, - on target: any FileSystem - ) async throws -> [FileEdit.FileChange] { - guard let info = try await statIfPresent(path, on: target) else { - return [] - } - - if info.kind == .directory { - let entries = try await target.listDirectory(path: path) - var fileChanges: [FileEdit.FileChange] = [] - for entry in entries.sorted(by: { $0.name < $1.name }) { - fileChanges += try await deletionFileChanges(at: path.appending(entry.name), on: target) - } - return fileChanges - } - - let diff: TextDiff? - if info.kind == .symlink || !computesDiffs { - diff = nil - } else if let originalContent = try await readUTF8IfPresent( - path, - on: target, - strictInvalidUTF8: false - ) { - diff = textDiff(from: originalContent, to: "") - } else { - diff = nil - } - - return [ - FileEdit.FileChange( - path: path, - kind: info.kind, - effect: .deleted, - diff: diff - ) - ] - } - - private func transferFileChanges( - from sourcePath: WorkspacePath, - to destinationPath: WorkspacePath, - effect: FileEdit.Effect, - on target: any FileSystem - ) async throws -> [FileEdit.FileChange] { - guard let info = try await statIfPresent(sourcePath, on: target) else { - return [] - } - - if info.kind == .directory { - let entries = try await target.listDirectory(path: sourcePath) - var fileChanges: [FileEdit.FileChange] = [] - for entry in entries.sorted(by: { $0.name < $1.name }) { - fileChanges += try await transferFileChanges( - from: sourcePath.appending(entry.name), - to: destinationPath.appending(entry.name), - effect: effect, - on: target - ) - } - return fileChanges - } - - return [ - FileEdit.FileChange( - path: destinationPath, - sourcePath: sourcePath, - kind: info.kind, - effect: effect - ) - ] - } - - private func plannedChangeEvents( - for edit: FileEdit, - on target: any FileSystem - ) async throws -> [ChangeEvent] { - switch edit { - case let .writeFile(path, content): - return try await plannedWriteEvents( - path: path, - data: Data(content.utf8), - append: false, - on: target - ) - case let .appendFile(path, content): - return try await plannedWriteEvents( - path: path, - data: Data(content.utf8), - append: true, - on: target - ) - case let .delete(path, _): - return try await plannedDeletionEvents(at: path, on: target) - case let .createDirectory(path, recursive): - return try await plannedDirectoryCreationEvents(path: path, recursive: recursive, on: target) - case let .move(from, to): - return try await plannedTransferEvents(from: from, to: to, kind: .moved, on: target) - case let .copy(from, to, _): - return try await plannedTransferEvents(from: from, to: to, kind: .copied, on: target) - } - } - - private func plannedWriteEvents( - path: WorkspacePath, - data: Data, - append: Bool, - on target: any FileSystem - ) async throws -> [ChangeEvent] { - guard let info = try await statIfPresent(path, on: target) else { - return [ - ChangeEvent( - kind: .created, - path: path, - nodeKind: .file - ), - ] - } - - guard info.kind != .directory else { - return [] - } - - let existingData = try await target.readFile(path: path) - let updatedData = append ? existingData + data : data - guard existingData != updatedData else { - return [] - } - - return [ - ChangeEvent( - kind: .modified, - path: path, - nodeKind: info.kind - ), - ] - } - - private func plannedDirectoryCreationEvents( - path: WorkspacePath, - recursive: Bool, - on target: any FileSystem - ) async throws -> [ChangeEvent] { - guard !path.isRoot else { - return [] - } - - if recursive { - var events: [ChangeEvent] = [] - var current = WorkspacePath.root - for component in path.components { - current = current.appending(component) - if try await statIfPresent(current, on: target) == nil { - events.append( - ChangeEvent( - kind: .created, - path: current, - nodeKind: .directory - ) - ) - } - } - return events - } - - guard try await statIfPresent(path, on: target) == nil else { - return [] - } - - return [ - ChangeEvent( - kind: .created, - path: path, - nodeKind: .directory - ), - ] - } - - /// A content-free tree capture used only to plan change events. Unlike ``RollbackCapture`` - /// it never reads file bytes, so planning deletion or transfer events for a large subtree - /// costs stats and listings, not file contents. - private indirect enum StructureCapture: Sendable { - case missing - case file(path: WorkspacePath) - case symlink(path: WorkspacePath) - case directory(path: WorkspacePath, children: [StructureCapture]) - } - - private func structureCapture(path: WorkspacePath, on target: any FileSystem) async throws -> StructureCapture { - guard await target.exists(path: path) else { - return .missing - } - - let info = try await target.stat(path: path) - switch info.kind { - case .directory: - let entries = try await target.listDirectory(path: path) - let children = try await entries - .sorted { $0.name < $1.name } - .asyncMap { [self] entry in - try await self.structureCapture(path: path.appending(entry.name), on: target) - } - return .directory(path: path, children: children) - case .symlink: - return .symlink(path: path) - case .file: - return .file(path: path) - } - } - - private func plannedDeletionEvents( - at path: WorkspacePath, - on target: any FileSystem - ) async throws -> [ChangeEvent] { - let capture = try await structureCapture(path: path, on: target) - return flattenDeletionEvents(from: capture) - } - - private func plannedTransferEvents( - from sourcePath: WorkspacePath, - to destinationPath: WorkspacePath, - kind: ChangeEvent.Kind, - on target: any FileSystem - ) async throws -> [ChangeEvent] { - let capture = try await structureCapture(path: sourcePath, on: target) - return flattenTransferEvents( - from: capture, - sourceRoot: sourcePath, - destinationRoot: destinationPath, - kind: kind - ) - } - - private func changeEvents(for replacement: PlannedReplacement) -> [ChangeEvent] { - guard replacement.change.replacements > 0 else { - return [] - } - - return [ - ChangeEvent( - kind: .modified, - path: replacement.change.path, - nodeKind: replacement.nodeKind - ), - ] - } - - private func flattenDeletionEvents(from capture: StructureCapture) -> [ChangeEvent] { - switch capture { - case .missing: - return [] - case let .file(path): - return [ChangeEvent(kind: .deleted, path: path, nodeKind: .file)] - case let .symlink(path): - return [ChangeEvent(kind: .deleted, path: path, nodeKind: .symlink)] - case let .directory(path, children): - var events: [ChangeEvent] = [] - for child in children { - events += flattenDeletionEvents(from: child) - } - events.append( - ChangeEvent( - kind: .deleted, - path: path, - nodeKind: .directory - ) - ) - return events - } - } - - private func flattenTransferEvents( - from capture: StructureCapture, - sourceRoot: WorkspacePath, - destinationRoot: WorkspacePath, - kind: ChangeEvent.Kind - ) -> [ChangeEvent] { - switch capture { - case .missing: - return [] - case let .file(path): - return [ - ChangeEvent( - kind: kind, - path: remappedPath(path, from: sourceRoot, to: destinationRoot), - sourcePath: path, - nodeKind: .file - ), - ] - case let .symlink(path): - return [ - ChangeEvent( - kind: kind, - path: remappedPath(path, from: sourceRoot, to: destinationRoot), - sourcePath: path, - nodeKind: .symlink - ), - ] - case let .directory(path, children): - var events: [ChangeEvent] = [ - ChangeEvent( - kind: kind, - path: remappedPath(path, from: sourceRoot, to: destinationRoot), - sourcePath: path, - nodeKind: .directory - ), - ] - for child in children { - events += flattenTransferEvents( - from: child, - sourceRoot: sourceRoot, - destinationRoot: destinationRoot, - kind: kind - ) - } - return events - } - } - - private func remappedPath( - _ path: WorkspacePath, - from sourceRoot: WorkspacePath, - to destinationRoot: WorkspacePath - ) -> WorkspacePath { - guard path != sourceRoot else { - return destinationRoot - } - - let suffix = path.components.dropFirst(sourceRoot.components.count) - return suffix.reduce(destinationRoot) { partialResult, component in - partialResult.appending(component) - } - } - - private func write(change: PlannedReplacement, to target: any FileSystem) async throws { - try await target.writeFile( - path: change.change.path, - data: Data(change.updatedContent.utf8), - append: false - ) - } - - private func apply(_ edit: FileEdit, on target: any FileSystem) async throws { - switch edit { - case let .writeFile(path, content): - try await target.writeFile(path: path, data: Data(content.utf8), append: false) - case let .appendFile(path, content): - try await target.writeFile(path: path, data: Data(content.utf8), append: true) - case let .delete(path, recursive): - try await target.remove(path: path, recursive: recursive) - case let .createDirectory(path, recursive): - try await target.createDirectory(path: path, recursive: recursive) - case let .move(from, to): - try await target.move(from: from, to: to) - case let .copy(from, to, recursive): - try await target.copy(from: from, to: to, recursive: recursive) - } - } - - private func setStatus(_ status: MutationStatus, for entry: inout FileEdit.Entry) { - entry.status = status - for index in entry.fileChanges.indices { - entry.fileChanges[index].status = status - } - } - - /// Whether the tracking policy calls for line diffs at all. - var computesDiffs: Bool { - if case .fullDiffs = tracking { - return true - } - return false - } - - private func textDiff(from originalContent: String, to updatedContent: String) -> TextDiff? { - guard case let .fullDiffs(maxDiffBytes) = tracking else { - return nil - } - if let maxDiffBytes, - originalContent.utf8.count > maxDiffBytes || updatedContent.utf8.count > maxDiffBytes - { - return nil - } - return TextDiff.lineBased(from: originalContent, to: updatedContent) - } - - private func describe(_ error: any Error) -> String { - if let error = error as? WorkspaceError { - return error.description - } - return String(describing: error) - } - - /// A short-lived capture used only to restore failed batched mutations. - private enum RollbackCapture: Sendable { - case missing(path: WorkspacePath) - case file(path: WorkspacePath, data: Data, permissions: POSIXPermissions) - case directory(path: WorkspacePath, permissions: POSIXPermissions, children: [RollbackCapture]) - case symlink(path: WorkspacePath, target: String, permissions: POSIXPermissions) - } - - private func rollbackCaptures(for paths: [WorkspacePath]) async throws -> [RollbackCapture] { - try await rollbackCaptures(paths, on: filesystem) - } - - private func rollbackCaptures( - _ paths: [WorkspacePath], - on target: any FileSystem - ) async throws -> [RollbackCapture] { - try await paths.asyncMap { path in - try await self.rollbackCapture(path: path, on: target) - } - } - - private func shallowRollbackCapture(path: WorkspacePath, on target: any FileSystem) async throws -> RollbackCapture { - guard await target.exists(path: path) else { - return .missing(path: path) - } - - let info = try await target.stat(path: path) - switch info.kind { - case .directory: - return .directory(path: path, permissions: info.permissions, children: []) - case .symlink: - let symlinkTarget = try await target.readSymlink(path: path) - return .symlink(path: path, target: symlinkTarget, permissions: info.permissions) - case .file: - return .file(path: path, data: Data(), permissions: info.permissions) - } - } - - private func rollbackCapture(path: WorkspacePath, on target: any FileSystem) async throws -> RollbackCapture { - guard await target.exists(path: path) else { - return .missing(path: path) - } - - let info = try await target.stat(path: path) - if info.kind == .directory { - let entries = try await target.listDirectory(path: path) - let children = try await entries - .sorted { $0.name < $1.name } - .asyncMap { [self] entry in - try await self.rollbackCapture(path: path.appending(entry.name), on: target) - } - return .directory(path: path, permissions: info.permissions, children: children) - } - - if info.kind == .symlink { - let symlinkTarget = try await target.readSymlink(path: path) - return .symlink(path: path, target: symlinkTarget, permissions: info.permissions) - } - - return .file( - path: path, - data: try await target.readFile(path: path), - permissions: info.permissions - ) - } - - private func rollback(_ captures: [RollbackCapture]) async throws { - for capture in captures.sorted(by: { path(of: $0).string.count < path(of: $1).string.count }) { - try await restore(capture, on: filesystem) - } - } - - private func restore(_ capture: RollbackCapture, on target: any FileSystem) async throws { - switch capture { - case let .missing(path): - if await target.exists(path: path) { - try await target.remove(path: path, recursive: true) - } - case let .file(path, data, permissions): - if await target.exists(path: path) { - try await target.remove(path: path, recursive: true) - } - if !path.dirname.isRoot { - try await target.createDirectory(path: path.dirname, recursive: true) - } - try await target.writeFile(path: path, data: data, append: false) - try await target.setPermissions(path: path, permissions: permissions) - case let .symlink(path, symlinkTarget, permissions): - if await target.exists(path: path) { - try await target.remove(path: path, recursive: true) - } - if !path.dirname.isRoot { - try await target.createDirectory(path: path.dirname, recursive: true) - } - try await target.createSymlink(path: path, target: symlinkTarget) - try await target.setPermissions(path: path, permissions: permissions) - case let .directory(path, permissions, children): - if path.isRoot { - if await target.exists(path: path) { - let entries = try await target.listDirectory(path: path) - for entry in entries { - try await target.remove(path: path.appending(entry.name), recursive: true) - } - } - try await target.setPermissions(path: path, permissions: permissions) - for child in children { - try await restore(child, on: target) - } - return - } - - if await target.exists(path: path) { - try await target.remove(path: path, recursive: true) - } - try await target.createDirectory(path: path, recursive: true) - try await target.setPermissions(path: path, permissions: permissions) - for child in children { - try await restore(child, on: target) - } - } - } - - private func path(of capture: RollbackCapture) -> WorkspacePath { - switch capture { - case let .missing(path), - let .file(path, _, _), - let .directory(path, _, _), - let .symlink(path, _, _): - return path - } - } -} - -private extension Array { - func asyncMap( - _ transform: @escaping @Sendable (Element) async throws -> T - ) async throws -> [T] { - var values: [T] = [] - values.reserveCapacity(count) - for element in self { - values.append(try await transform(element)) + switch revision { + case .current: + return try await filesystem.glob(pattern: pattern, currentDirectory: currentDirectory) + case .checkpoint: + let resolved = try await resolvedRevision(revision) + let normalized = try WorkspacePath(validating: pattern, relativeTo: currentDirectory) + if !WorkspacePath.containsGlob(normalized.string) { + return resolved.index.entry(at: normalized) == nil ? [] : [normalized] + } + let regex = try NSRegularExpression(pattern: WorkspacePath.globToRegex(normalized.string)) + return Self.indexPaths(resolved.index.entry).filter { path in + regex.firstMatch( + in: path.string, + range: NSRange(path.string.startIndex.. Archive { + let snapshot: Snapshot + switch revision { + case .current: + snapshot = try await Snapshot.capture(from: filesystem, at: path) + case .checkpoint: + let full = try await revisionSnapshot(revision) + snapshot = Snapshot(rootPath: path, entry: Self.entry(at: path, in: full.entry)) + } + return Archive(root: path, entry: Self.archiveEntry(snapshot.entry)) + } + + /// Restores a portable archive and records the resulting changes. + @discardableResult + public func restore(_ archive: Archive, at destination: WorkspacePath? = nil) async throws -> ChangeSet { + try await ensureLoaded() + let before = try await Snapshot.capture(from: filesystem) + let destination = destination ?? archive.root + let entry = Self.snapshotEntry(archive.entry, replacing: archive.root, with: destination) + try await Snapshot.restore(Snapshot(rootPath: destination, entry: entry), to: filesystem) + let after = try await Snapshot.capture(from: filesystem) + let changes = changeSet(from: before, to: after) + if !changes.isEmpty { + try await appendMutation(operation: .archiveRestore, changes: changes) + emitWorkspaceEvent(.changes(changes)) + } + return changes + } + + private static func archiveEntry(_ entry: Snapshot.Entry) -> Archive.Entry { + switch entry { + case let .missing(value): .missing(value.path) + case let .file(value): .file(path: value.path, data: value.data, permissions: value.permissions) + case let .directory(value): + .directory( + path: value.path, + permissions: value.permissions, + children: value.children.map(archiveEntry) + ) + case let .symlink(value): + .symbolicLink(path: value.path, target: value.target, permissions: value.permissions) + } + } + + private static func snapshotEntry( + _ entry: Archive.Entry, + replacing sourceRoot: WorkspacePath, + with destinationRoot: WorkspacePath + ) -> Snapshot.Entry { + let path = rebased(entry.path, from: sourceRoot, to: destinationRoot) + switch entry { + case .missing: + return .missing(.init(path: path)) + case let .file(_, data, permissions): + return .file(.init(path: path, data: data, permissions: permissions)) + case let .directory(_, permissions, children): + return .directory( + .init( + path: path, + permissions: permissions, + children: children.map { + snapshotEntry($0, replacing: sourceRoot, with: destinationRoot) + } + ) + ) + case let .symbolicLink(_, target, permissions): + return .symlink(.init(path: path, target: target, permissions: permissions)) + } + } + + private static func rebased( + _ path: WorkspacePath, + from sourceRoot: WorkspacePath, + to destinationRoot: WorkspacePath + ) -> WorkspacePath { + guard path != sourceRoot else { return destinationRoot } + let prefix = sourceRoot.isRoot ? "/" : sourceRoot.string + "/" + guard path.string.hasPrefix(prefix) else { return path } + return destinationRoot.appending(String(path.string.dropFirst(prefix.count))) + } + + private static func entry(at path: WorkspacePath, in entry: Snapshot.Entry) -> Snapshot.Entry { + if entry.path == path { return entry } + guard case let .directory(directory) = entry else { return .missing(.init(path: path)) } + for child in directory.children { + if child.path == path || path.string.hasPrefix(child.path.string + "/") { + return self.entry(at: path, in: child) + } + } + return .missing(.init(path: path)) + } + +} diff --git a/Sources/Workspace/WorkspaceChanges.swift b/Sources/Workspace/WorkspaceChanges.swift new file mode 100644 index 0000000..b8eb923 --- /dev/null +++ b/Sources/Workspace/WorkspaceChanges.swift @@ -0,0 +1,129 @@ +import Foundation + +/// A single filesystem entry change. +public struct FileChange: Sendable, Codable, Equatable { + public enum Effect: String, Sendable, Codable { + case created + case modified + case deleted + case moved + case copied + } + + public var path: WorkspacePath + public var sourcePath: WorkspacePath? + public var kind: FileTree.Kind + public var effect: Effect + public var diff: TextDiff? + + public init( + path: WorkspacePath, + sourcePath: WorkspacePath? = nil, + kind: FileTree.Kind, + effect: Effect, + diff: TextDiff? = nil + ) { + self.path = path + self.sourcePath = sourcePath + self.kind = kind + self.effect = effect + self.diff = diff + } +} + +/// Why a changed regular file has no textual diff. +public struct TextDiffOmission: Sendable, Codable, Equatable { + public enum Reason: String, Sendable, Codable { + case binary + case sizeLimitExceeded + } + + public var path: WorkspacePath + public var reason: Reason + + public init(path: WorkspacePath, reason: Reason) { + self.path = path + self.reason = reason + } +} + +/// A canonical set of workspace changes shared by previews, mutations, events, revisions, and transactions. +public struct ChangeSet: Sendable, Codable, Equatable { + public struct Summary: Sendable, Codable, Equatable { + public var changedPathCount: Int + public var touchedPaths: [WorkspacePath] + public var hasTextChanges: Bool + public var statistics: Statistics + + public init( + changedPathCount: Int, + touchedPaths: [WorkspacePath], + hasTextChanges: Bool, + statistics: Statistics + ) { + self.changedPathCount = changedPathCount + self.touchedPaths = touchedPaths.sorted() + self.hasTextChanges = hasTextChanges + self.statistics = statistics + } + + } + + public struct Statistics: Sendable, Codable, Equatable { + public var changedPathCount: Int + public var changedFileCount: Int + public var additions: Int + public var deletions: Int + + public init(changedPathCount: Int, changedFileCount: Int, additions: Int, deletions: Int) { + self.changedPathCount = changedPathCount + self.changedFileCount = changedFileCount + self.additions = additions + self.deletions = deletions + } + } + + public var changes: [FileChange] + public var textDiffOmissions: [TextDiffOmission] + + public var touchedPaths: [WorkspacePath] { + Array(Set(changes.flatMap { change in + if let sourcePath = change.sourcePath { + [change.path, sourcePath] + } else { + [change.path] + } + })).sorted() + } + + public var statistics: Statistics { + Statistics( + changedPathCount: touchedPaths.count, + changedFileCount: changes.count { $0.kind == .file }, + additions: changes.compactMap(\.diff).reduce(0) { $0 + $1.statistics.additions }, + deletions: changes.compactMap(\.diff).reduce(0) { $0 + $1.statistics.deletions } + ) + } + + public var summary: Summary { + Summary( + changedPathCount: statistics.changedPathCount, + touchedPaths: touchedPaths, + hasTextChanges: changes.contains { $0.diff != nil }, + statistics: statistics + ) + } + + public var isEmpty: Bool { changes.isEmpty } + + public init(changes: [FileChange] = [], textDiffOmissions: [TextDiffOmission] = []) { + self.changes = changes.sorted(by: Self.changeOrder) + self.textDiffOmissions = textDiffOmissions.sorted { $0.path < $1.path } + } + + private static func changeOrder(_ lhs: FileChange, _ rhs: FileChange) -> Bool { + if lhs.path != rhs.path { return lhs.path < rhs.path } + if lhs.effect != rhs.effect { return lhs.effect.rawValue < rhs.effect.rawValue } + return lhs.kind.rawValue < rhs.kind.rawValue + } +} diff --git a/Sources/Workspace/WorkspaceEdit.swift b/Sources/Workspace/WorkspaceEdit.swift new file mode 100644 index 0000000..a5d1037 --- /dev/null +++ b/Sources/Workspace/WorkspaceEdit.swift @@ -0,0 +1,68 @@ +import Foundation + +/// A reusable file selection shared by search and replacement. +public struct FileSelection: Sendable, Codable, Equatable { + public var root: WorkspacePath + public var include: [String] + public var exclude: [String] + + public init( + root: WorkspacePath = .root, + include: [String] = ["**/*"], + exclude: [String] = [] + ) { + self.root = root + self.include = include + self.exclude = exclude + } +} +/// A literal or Foundation regular-expression text pattern. +public enum TextPattern: Sendable, Codable, Equatable { + case literal(String, caseSensitive: Bool = true) + case regularExpression(String) +} + +/// A filesystem mutation command. +public enum Edit: Sendable, Codable, Equatable { + case writeText(WorkspacePath, String) + case appendText(WorkspacePath, String) + case writeData(WorkspacePath, Data) + case appendData(WorkspacePath, Data) + case createDirectory(WorkspacePath, recursive: Bool = true) + case remove(WorkspacePath, recursive: Bool = true) + case copy(from: WorkspacePath, to: WorkspacePath, recursive: Bool = true) + case move(from: WorkspacePath, to: WorkspacePath) + case createSymbolicLink(WorkspacePath, target: String) + case createHardLink(WorkspacePath, target: WorkspacePath) + case setPermissions(WorkspacePath, POSIXPermissions) + case replace(files: FileSelection, pattern: TextPattern, with: String) +} + +/// Failure behavior for a multi-edit application. +public enum EditPolicy: String, Sendable, Codable { + case atomic + case stopOnError + case continueAfterError +} + +public struct EditFailure: Sendable, Codable, Equatable { + public var index: Int + public var edit: Edit + public var message: String + + public init(index: Int, edit: Edit, message: String) { + self.index = index + self.edit = edit + self.message = message + } +} + +public struct EditResult: Sendable, Codable, Equatable { + public var changes: ChangeSet + public var failures: [EditFailure] + + public init(changes: ChangeSet, failures: [EditFailure] = []) { + self.changes = changes + self.failures = failures + } +} diff --git a/Sources/Workspace/WorkspaceEvent.swift b/Sources/Workspace/WorkspaceEvent.swift new file mode 100644 index 0000000..4253db2 --- /dev/null +++ b/Sources/Workspace/WorkspaceEvent.swift @@ -0,0 +1,69 @@ +import Foundation + +/// A unified workspace event stream value. +public enum WorkspaceEvent: Sendable, Codable, Equatable { + case changes(ChangeSet) + case checkpoint(Checkpoint) + + public struct Filter: Sendable, Equatable { + public var path: WorkspacePath? + public var recursive: Bool + public var includeCheckpoints: Bool + + public static let all = Filter() + + public init(path: WorkspacePath? = nil, recursive: Bool = true, includeCheckpoints: Bool = true) { + self.path = path + self.recursive = recursive + self.includeCheckpoints = includeCheckpoints + } + } +} + +extension Workspace { + /// Observes workspace changes and checkpoints through one stream. + public func events(_ filter: WorkspaceEvent.Filter = .all) -> AsyncStream { + let id = UUID() + var continuation: AsyncStream.Continuation? + let stream = AsyncStream { continuation = $0 } + guard let continuation else { return stream } + eventWatchers[id] = EventWatcher(filter: filter, continuation: continuation) + continuation.onTermination = { [weak self] _ in + Task { await self?.removeEventWatcher(id) } + } + if filter.includeCheckpoints { + ensureCheckpointPolling() + } + return stream + } + + func removeEventWatcher(_ id: UUID) { + eventWatchers.removeValue(forKey: id) + let hasCheckpointEventWatcher = eventWatchers.values.contains { $0.filter.includeCheckpoints } + if !hasCheckpointEventWatcher { + checkpointPollingTask?.cancel() + checkpointPollingTask = nil + } + } + + func emitWorkspaceEvent(_ event: WorkspaceEvent) { + for watcher in eventWatchers.values where Self.matches(event, filter: watcher.filter) { + watcher.continuation.yield(event) + } + } + + private static func matches(_ event: WorkspaceEvent, filter: WorkspaceEvent.Filter) -> Bool { + switch event { + case .checkpoint: + return filter.includeCheckpoints + case let .changes(changes): + guard let path = filter.path else { return true } + return changes.touchedPaths.contains { candidate in + if filter.recursive { + return candidate == path || candidate.string.hasPrefix(path.isRoot ? "/" : path.string + "/") + } + return candidate == path + } + } + } +} diff --git a/Sources/Workspace/WorkspaceHistory.swift b/Sources/Workspace/WorkspaceHistory.swift new file mode 100644 index 0000000..4452728 --- /dev/null +++ b/Sources/Workspace/WorkspaceHistory.swift @@ -0,0 +1,39 @@ +import Foundation + +/// A recorded high-level workspace mutation. +public struct Mutation: Sendable, Codable, Equatable { + public enum Operation: String, Sendable, Codable { + case edit + case rollback + case transaction + case archiveRestore + } + + public internal(set) var sequence: Int + public var workspaceID: UUID + public var timestamp: Date + public var operation: Operation + public var changes: ChangeSet + + public init( + sequence: Int, + workspaceID: UUID, + timestamp: Date = Date(), + operation: Operation, + changes: ChangeSet + ) { + self.sequence = sequence + self.workspaceID = workspaceID + self.timestamp = timestamp + self.operation = operation + self.changes = changes + } +} + +extension Workspace { + /// Returns recorded mutations in ascending sequence order. + public func history() async throws -> [Mutation] { + try await ensureLoaded() + return mutations + } +} diff --git a/Sources/Workspace/WorkspaceMutation.swift b/Sources/Workspace/WorkspaceMutation.swift deleted file mode 100644 index f8ecbc6..0000000 --- a/Sources/Workspace/WorkspaceMutation.swift +++ /dev/null @@ -1,33 +0,0 @@ -import Foundation - -/// Whether a workspace mutation was previewed or executed. -public enum MutationMode: String, Sendable, Codable { - /// The workspace mutation was only previewed. - case preview - /// The workspace mutation was executed against the backing filesystem. - case execution -} - -/// The failure handling strategy used when applying a workspace mutation. -public enum MutationFailurePolicy: String, Sendable, Codable { - /// Restore the original state when any execution step fails. - case rollback - /// Stop at the first failure and leave any already-applied changes in place. - case failFast - /// Continue after failures and report all failed steps. - case bestEffort -} - -/// The execution status for a planned or applied workspace change. -public enum MutationStatus: String, Sendable, Codable { - /// The change was only planned during preview. - case planned - /// The change was successfully applied. - case applied - /// The change failed while being applied. - case failed - /// The change was applied, then reverted due to rollback. - case rolledBack - /// The change was never attempted because execution stopped earlier. - case skipped -} diff --git a/Sources/Workspace/WorkspaceReplace.swift b/Sources/Workspace/WorkspaceReplace.swift deleted file mode 100644 index 2a2b035..0000000 --- a/Sources/Workspace/WorkspaceReplace.swift +++ /dev/null @@ -1,156 +0,0 @@ -import Foundation - -/// A request describing a multi-file text replacement operation. -public struct ReplacementRequest: Sendable, Equatable, Codable { - /// The text matching strategy to apply to each candidate file. - public enum Pattern: Sendable, Equatable, Codable { - /// Match a literal substring. - case literal(String, caseSensitive: Bool = true) - /// Match a regular expression pattern using Foundation regular expression syntax. - case regularExpression(String) - } - - /// The base directory used to resolve relative include and exclude patterns. - public var scope: WorkspacePath - /// Glob patterns selecting candidate files. - public var include: [String] - /// Glob patterns removed from the include set after expansion. - public var exclude: [String] - /// The text matching strategy to apply to each candidate file. - public var search: Pattern - /// The replacement string or regular expression template. - public var replacement: String - - /// Creates a replacement request. - public init( - scope: WorkspacePath = .root, - include: [String], - exclude: [String] = [], - search: Pattern, - replacement: String - ) { - self.scope = scope - self.include = include - self.exclude = exclude - self.search = search - self.replacement = replacement - } - - /// Creates a replacement request for a single include pattern and a literal search term. - public init( - pattern: String, - search: String, - replacement: String, - scope: WorkspacePath = .root, - exclude: [String] = [] - ) { - self.init( - scope: scope, - include: [pattern], - exclude: exclude, - search: .literal(search), - replacement: replacement - ) - } - - /// Creates a replacement request for a single include pattern. - public init( - pattern: String, - search: Pattern, - replacement: String, - scope: WorkspacePath = .root, - exclude: [String] = [] - ) { - self.init( - scope: scope, - include: [pattern], - exclude: exclude, - search: search, - replacement: replacement - ) - } -} - -/// The result of a multi-file text replacement operation. -public struct ReplacementResult: Sendable, Codable { - /// Whether the operation was previewed or executed. - public var mode: MutationMode - /// The distinct paths touched by the operation. - public var touchedPaths: [WorkspacePath] - /// Per-file change details. - public var changes: [Change] - /// Execution failures encountered while applying the replacement. - public var failures: [Failure] - /// Whether the operation rolled back after an error. - public var rolledBack: Bool - - /// A single-file text replacement outcome from ``Workspace/previewReplacement(_:)`` or - /// ``Workspace/applyReplacement(_:failurePolicy:)``. - public struct Change: Sendable, Codable { - /// The file path that was changed. - public var path: WorkspacePath - /// The number of replacements applied to the file. - public var replacements: Int - /// The execution status of the file change. - public var status: MutationStatus - /// A line-based diff describing the text change. - public var diff: TextDiff - - /// Creates a replacement change value. - public init( - path: WorkspacePath, - replacements: Int, - status: MutationStatus = .planned, - diff: TextDiff - ) { - self.path = path - self.replacements = replacements - self.status = status - self.diff = diff - } - - /// Convenience initializer that accepts a string path. - public init( - path: String, - replacements: Int, - status: MutationStatus = .planned, - diff: TextDiff - ) { - self.init( - path: WorkspacePath(normalizing: path), - replacements: replacements, - status: status, - diff: diff - ) - } - } - - /// A failure encountered while executing a single replacement write. - public struct Failure: Sendable, Codable { - /// The path whose replacement write failed. - public var path: WorkspacePath - /// A human-readable failure message. - public var message: String - - /// Creates a replacement failure. - public init(path: WorkspacePath, message: String) { - self.path = path - self.message = message - } - } - - /// Creates a replacement result. - public init( - mode: MutationMode, - touchedPaths: [WorkspacePath], - changes: [Change], - failures: [Failure] = [], - rolledBack: Bool - ) { - self.mode = mode - self.touchedPaths = touchedPaths - self.changes = changes - self.failures = failures - self.rolledBack = rolledBack - } -} diff --git a/Sources/Workspace/WorkspaceRevision.swift b/Sources/Workspace/WorkspaceRevision.swift new file mode 100644 index 0000000..00a3e39 --- /dev/null +++ b/Sources/Workspace/WorkspaceRevision.swift @@ -0,0 +1,256 @@ +import Foundation + +/// A readable workspace revision. +public enum Revision: Sendable, Codable, Equatable { + case current + case checkpoint(UUID) +} + +public struct DiffOptions: Sendable, Codable, Equatable { + public var maxTextBytes: Int? + + public static let `default` = DiffOptions(maxTextBytes: 1_000_000) + + public init(maxTextBytes: Int?) { + self.maxTextBytes = maxTextBytes + } +} + +extension Workspace { + /// Compares any two workspace revisions. + public func diff( + from source: Revision, + to destination: Revision, + options: DiffOptions = .default + ) async throws -> ChangeSet { + if let maxTextBytes = options.maxTextBytes, maxTextBytes < 0 { + throw WorkspaceError.unsupported("text diff byte limit must not be negative") + } + let currentSnapshot: Snapshot? = if source == .current || destination == .current { + try await Snapshot.capture(from: filesystem) + } else { + nil + } + async let before = resolvedRevision(source, currentSnapshot: currentSnapshot) + async let after = resolvedRevision(destination, currentSnapshot: currentSnapshot) + let (beforeRevision, afterRevision) = try await (before, after) + return try await ChangeSet.compare( + before: beforeRevision.index, + after: afterRevision.index, + maxTextBytes: options.maxTextBytes, + loadBefore: beforeRevision.load, + loadAfter: afterRevision.load + ) + } + + func revisionSnapshot(_ revision: Revision) async throws -> Snapshot { + switch revision { + case .current: + return try await Snapshot.capture(from: filesystem) + case let .checkpoint(id): + try await ensureLoaded() + try await reconcileCheckpointsWithStore() + let checkpoint = try checkpointOrThrow(id: id) + return try await loadSnapshotOrThrow(id: checkpoint.snapshotId, workspaceId: checkpoint.workspaceId) + } + } + + struct ResolvedRevision: Sendable { + var index: RevisionIndex + var load: ChangeSet.RevisionLoader + } + + func resolvedRevision(_ revision: Revision, currentSnapshot: Snapshot? = nil) async throws -> ResolvedRevision { + switch revision { + case .current: + let snapshot: Snapshot + if let currentSnapshot { + snapshot = currentSnapshot + } else { + snapshot = try await Snapshot.capture(from: filesystem) + } + return ResolvedRevision( + index: RevisionIndex(snapshot: snapshot), + load: { path in + guard let entry = Self.snapshotEntry(at: path, in: snapshot.entry), + case let .file(file) = entry + else { throw NSError(domain: NSPOSIXErrorDomain, code: Int(ENOENT)) } + return file.data + } + ) + case let .checkpoint(id): + try await ensureLoaded() + try await reconcileCheckpointsWithStore() + let checkpoint = try checkpointOrThrow(id: id) + guard let index = try await store.loadRevisionIndex( + id: checkpoint.snapshotId, + workspaceId: checkpoint.workspaceId + ) else { + throw WorkspaceError.revisionDataNotFound(checkpoint.snapshotId) + } + let store = store + let workspaceID = checkpoint.workspaceId + let snapshotID = checkpoint.snapshotId + return ResolvedRevision( + index: index, + load: { path in + guard let data = try await store.readSnapshotFile( + id: snapshotID, + workspaceId: workspaceID, + path: path, + offset: 0, + length: nil + ) else { + throw NSError(domain: NSPOSIXErrorDomain, code: Int(ENOENT)) + } + return data + } + ) + } + } + + func revisionData( + at path: WorkspacePath, + revision: Revision, + offset: UInt64 = 0, + length: Int? = nil + ) async throws -> Data { + let resolved = try await resolvedRevision(revision) + var current = path + for _ in 0..<64 { + guard let entry = resolved.index.entry(at: current) else { + throw NSError(domain: NSPOSIXErrorDomain, code: Int(ENOENT)) + } + switch entry { + case .file: + let data = try await resolved.load(current) + guard offset < UInt64(data.count) else { return Data() } + let start = data.index(data.startIndex, offsetBy: Int(offset)) + let end = length.map { data.index(start, offsetBy: $0, limitedBy: data.endIndex) ?? data.endIndex } + ?? data.endIndex + return Data(data[start.. FileInfo { + switch revision { + case .current: + return try await filesystem.stat(path: path) + case .checkpoint: + let resolved = try await resolvedRevision(revision) + guard let entry = resolved.index.entry(at: path) else { + throw NSError(domain: NSPOSIXErrorDomain, code: Int(ENOENT)) + } + return Self.info(for: entry) + } + } + + public func list(_ path: WorkspacePath, at revision: Revision = .current) async throws -> [DirectoryEntry] { + switch revision { + case .current: + return try await filesystem.listDirectory(path: path) + case .checkpoint: + let resolved = try await resolvedRevision(revision) + guard let entry = resolved.index.entry(at: path), + case let .directory(_, _, children) = entry + else { + throw NSError(domain: NSPOSIXErrorDomain, code: Int(ENOTDIR)) + } + return children.map { DirectoryEntry(name: $0.path.name, info: Self.info(for: $0)) } + .sorted { $0.name < $1.name } + } + } + + public func tree( + at path: WorkspacePath = .root, + revision: Revision = .current, + maxDepth: Int? = nil + ) async throws -> FileTree { + switch revision { + case .current: + return try await currentTree(at: path, depth: 0, maxDepth: maxDepth) + case .checkpoint: + let resolved = try await resolvedRevision(revision) + guard let entry = resolved.index.entry(at: path) else { + throw NSError(domain: NSPOSIXErrorDomain, code: Int(ENOENT)) + } + return Self.fileTree(entry, depth: 0, maxDepth: maxDepth) + } + } + + private func currentTree(at path: WorkspacePath, depth: Int, maxDepth: Int?) async throws -> FileTree { + let metadata = try await filesystem.stat(path: path) + let children: [FileTree]? + if metadata.kind == .directory, maxDepth.map({ depth < $0 }) ?? true { + var nested: [FileTree] = [] + for entry in try await filesystem.listDirectory(path: path) { + nested.append(try await currentTree(at: entry.info.path, depth: depth + 1, maxDepth: maxDepth)) + } + children = nested + } else { + children = nil + } + return FileTree( + path: metadata.path, + kind: metadata.kind, + size: metadata.size, + permissions: metadata.permissions, + modificationDate: metadata.modificationDate, + children: children + ) + } + + static func snapshotEntry(at path: WorkspacePath, in entry: Snapshot.Entry) -> Snapshot.Entry? { + if entry.path == path { return entry } + guard case let .directory(directory) = entry else { return nil } + for child in directory.children { + if child.path == path || path.string.hasPrefix(child.path.string + "/") { + return snapshotEntry(at: path, in: child) + } + } + return nil + } + + private static func info(for entry: RevisionIndex.Entry) -> FileInfo { + FileInfo( + path: entry.path, + kind: entry.kind ?? .file, + size: entry.size, + permissions: entry.permissions ?? .defaultFile, + modificationDate: nil + ) + } + + private static func fileTree(_ entry: RevisionIndex.Entry, depth: Int, maxDepth: Int?) -> FileTree { + let metadata = info(for: entry) + let children: [FileTree]? + if case let .directory(_, _, entries) = entry, maxDepth.map({ depth < $0 }) ?? true { + children = entries.map { fileTree($0, depth: depth + 1, maxDepth: maxDepth) } + } else { + children = nil + } + return FileTree( + path: metadata.path, + kind: metadata.kind, + size: metadata.size, + permissions: metadata.permissions, + modificationDate: nil, + children: children + ) + } + + static func indexPaths(_ entry: RevisionIndex.Entry) -> [WorkspacePath] { + if case let .directory(_, _, children) = entry { + return [entry.path] + children.flatMap(indexPaths) + } + return [entry.path] + } +} diff --git a/Sources/Workspace/WorkspaceSearch.swift b/Sources/Workspace/WorkspaceSearch.swift new file mode 100644 index 0000000..c110dfa --- /dev/null +++ b/Sources/Workspace/WorkspaceSearch.swift @@ -0,0 +1,178 @@ +import Foundation + +public struct SearchRequest: Sendable, Codable, Equatable { + public struct Limits: Sendable, Codable, Equatable { + public var maxFileBytes: UInt64 + public var maxFiles: Int + public var maxMatches: Int + + public static let `default` = Limits(maxFileBytes: 1_000_000, maxFiles: 10_000, maxMatches: 1_000) + + public init(maxFileBytes: UInt64, maxFiles: Int, maxMatches: Int) { + self.maxFileBytes = maxFileBytes + self.maxFiles = maxFiles + self.maxMatches = maxMatches + } + } + + public var files: FileSelection + public var pattern: TextPattern + public var contextLines: Int + public var limits: Limits + + public init( + pattern: TextPattern, + files: FileSelection = FileSelection(), + contextLines: Int = 0, + limits: Limits = .default + ) { + self.files = files + self.pattern = pattern + self.contextLines = contextLines + self.limits = limits + } +} +public struct SearchResult: Sendable, Codable, Equatable { + public struct ContextLine: Sendable, Codable, Equatable { + public var number: Int + public var text: String + + public init(number: Int, text: String) { + self.number = number + self.text = text + } + } + + public struct Match: Sendable, Codable, Equatable { + public var path: WorkspacePath + public var lineNumber: Int + public var line: String + public var before: [ContextLine] + public var after: [ContextLine] + + public init( + path: WorkspacePath, + lineNumber: Int, + line: String, + before: [ContextLine] = [], + after: [ContextLine] = [] + ) { + self.path = path + self.lineNumber = lineNumber + self.line = line + self.before = before + self.after = after + } + } + + public struct SkippedFile: Sendable, Codable, Equatable { + public enum Reason: String, Sendable, Codable { + case binary + case sizeLimitExceeded + } + + public var path: WorkspacePath + public var reason: Reason + + public init(path: WorkspacePath, reason: Reason) { + self.path = path + self.reason = reason + } + } + + public enum Truncation: String, Sendable, Codable { + case fileLimit + case matchLimit + } + + public var matches: [Match] + public var skippedFiles: [SkippedFile] + public var searchedFileCount: Int + public var truncation: Truncation? + + public init( + matches: [Match], + skippedFiles: [SkippedFile], + searchedFileCount: Int, + truncation: Truncation? + ) { + self.matches = matches + self.skippedFiles = skippedFiles + self.searchedFileCount = searchedFileCount + self.truncation = truncation + } +} + +extension Workspace { + /// Searches UTF-8 regular files in deterministic path and line order. + public func search(_ request: SearchRequest) async throws -> SearchResult { + try await Self.search(request, on: filesystem) + } + + static func search(_ request: SearchRequest, on fileSystem: any FileSystem) async throws -> SearchResult { + guard request.contextLines >= 0, request.limits.maxFiles >= 0, request.limits.maxMatches >= 0 else { + throw WorkspaceError.unsupported("search limits and context must not be negative") + } + switch request.pattern { + case let .literal(value, _), let .regularExpression(value): + guard !value.isEmpty else { throw WorkspaceError.unsupported("search pattern must not be empty") } + } + + let allFiles = try await selectedFiles(request.files, on: fileSystem) + let files = Array(allFiles.prefix(request.limits.maxFiles)) + var truncation: SearchResult.Truncation? = allFiles.count > files.count ? .fileLimit : nil + var matches: [SearchResult.Match] = [] + var skipped: [SearchResult.SkippedFile] = [] + var searchedFileCount = 0 + let regex: NSRegularExpression? = if case let .regularExpression(pattern) = request.pattern { + try NSRegularExpression(pattern: pattern) + } else { + nil + } + + fileLoop: for path in files { + let info = try await fileSystem.stat(path: path) + if info.size > request.limits.maxFileBytes { + skipped.append(.init(path: path, reason: .sizeLimitExceeded)) + continue + } + let data = try await fileSystem.readFile(path: path) + guard !data.contains(0), let text = String(data: data, encoding: .utf8) else { + skipped.append(.init(path: path, reason: .binary)) + continue + } + searchedFileCount += 1 + let lines = text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + for (index, line) in lines.enumerated() where Self.matches(line, request.pattern, regex: regex) { + if matches.count >= request.limits.maxMatches { + truncation = .matchLimit + break fileLoop + } + let lower = max(0, index - request.contextLines) + let upper = min(lines.count, index + request.contextLines + 1) + let before = (lower.. Bool { + switch pattern { + case let .literal(value, caseSensitive): + return line.range(of: value, options: caseSensitive ? [] : [.caseInsensitive]) != nil + case .regularExpression: + guard let regex else { return false } + return regex.firstMatch(in: line, range: NSRange(line.startIndex.. Data { + try requireActive() + return try await draft.readData(from: path) + } + + public func readText(_ path: WorkspacePath) async throws -> String { + try requireActive() + return try await draft.readText(path) + } + + public func readJSON( + _ type: T.Type = T.self, + from path: WorkspacePath + ) async throws -> T { + try requireActive() + return try await draft.readJSON(type, from: path) + } + + public func exists(_ path: WorkspacePath) async throws -> Bool { + try requireActive() + return await draft.exists(path) + } + + public func info(_ path: WorkspacePath) async throws -> FileInfo { + try requireActive() + return try await draft.info(path) + } + + public func list(_ path: WorkspacePath) async throws -> [DirectoryEntry] { + try requireActive() + return try await draft.list(path) + } + + public func glob(_ pattern: String, currentDirectory: WorkspacePath = .root) async throws -> [WorkspacePath] { + try requireActive() + return try await draft.glob(pattern, currentDirectory: currentDirectory) + } + + public func tree(at path: WorkspacePath = .root, maxDepth: Int? = nil) async throws -> FileTree { + try requireActive() + return try await draft.tree(at: path, maxDepth: maxDepth) + } + + public func search(_ request: SearchRequest) async throws -> SearchResult { + try requireActive() + return try await draft.search(request) + } + + public func preview(_ edits: [Edit]) async throws -> ChangeSet { + try requireActive() + return try await draft.preview(edits) + } + + @discardableResult + public func apply(_ edits: [Edit], policy: EditPolicy = .atomic) async throws -> EditResult { + try requireActive() + return try await draft.apply(edits, policy: policy) + } + + @discardableResult + public func writeText(_ path: WorkspacePath, _ content: String) async throws -> ChangeSet { + try requireActive() + return try await draft.writeText(path, content) + } + + @discardableResult + public func appendText(_ path: WorkspacePath, _ content: String) async throws -> ChangeSet { + try requireActive() + return try await draft.appendText(path, content) + } + + @discardableResult + public func writeData(_ data: Data, to path: WorkspacePath) async throws -> ChangeSet { + try requireActive() + return try await draft.writeData(data, to: path) + } + + @discardableResult + public func appendData(_ path: WorkspacePath, _ data: Data) async throws -> ChangeSet { + try requireActive() + return try await draft.appendData(path, data) + } + + @discardableResult + public func writeJSON( + _ value: T, + to path: WorkspacePath, + prettyPrinted: Bool = true + ) async throws -> ChangeSet { + try requireActive() + return try await draft.writeJSON(value, to: path, prettyPrinted: prettyPrinted) + } + + @discardableResult + public func createDirectory(_ path: WorkspacePath, recursive: Bool = true) async throws -> ChangeSet { + try requireActive() + return try await draft.createDirectory(path, recursive: recursive) + } + + @discardableResult + public func remove(_ path: WorkspacePath, recursive: Bool = true) async throws -> ChangeSet { + try requireActive() + return try await draft.remove(path, recursive: recursive) + } + + @discardableResult + public func copy(from source: WorkspacePath, to destination: WorkspacePath, recursive: Bool = true) + async throws -> ChangeSet + { + try requireActive() + return try await draft.copy(from: source, to: destination, recursive: recursive) + } + + @discardableResult + public func move(from source: WorkspacePath, to destination: WorkspacePath) async throws -> ChangeSet { + try requireActive() + return try await draft.move(from: source, to: destination) + } + + @discardableResult + public func createSymbolicLink(_ path: WorkspacePath, target: String) async throws -> ChangeSet { + try requireActive() + return try await draft.createSymbolicLink(path, target: target) + } + + @discardableResult + public func createHardLink(_ path: WorkspacePath, target: WorkspacePath) async throws -> ChangeSet { + try requireActive() + return try await draft.createHardLink(path, target: target) + } + + @discardableResult + public func setPermissions(_ permissions: POSIXPermissions, at path: WorkspacePath) async throws -> ChangeSet { + try requireActive() + return try await draft.setPermissions(permissions, at: path) + } + + /// Returns all base-to-draft changes without mutating the parent workspace. + public func preview() async throws -> ChangeSet { + try requireActive() + let current = try await draft.revisionSnapshot(.current) + return ChangeSet.compare(before: base, after: current, maxTextBytes: 1_000_000) + } + + /// Attempts to commit this transaction. Conflicted transactions remain active. + public func commit(strategy: CommitStrategy = .threeWay) async throws -> Commit { + try requireActive() + let draftSnapshot = try await draft.revisionSnapshot(.current) + let result = try await parent.commitTransaction( + base: base, + draft: draftSnapshot, + baseCheckpointID: baseCheckpointID, + label: label, + strategy: strategy + ) + if result.conflicts.isEmpty { + state = .committed + } + return result + } + + /// Permanently discards the transaction. + public func discard() throws { + try requireActive() + state = .discarded + } + + private func requireActive() throws { + guard state == .active else { throw TransactionError.inactive } + } + } + + public struct TransactionResult: Sendable { + public var value: Value + public var commit: Transaction.Commit + } + + /// Starts an isolated transaction from the workspace's complete current state. + public func beginTransaction(label: String? = nil) async throws -> Transaction { + try await ensureLoaded() + try await reconcileCheckpointsWithStore() + let base = try await Snapshot.capture(from: filesystem) + let draftFileSystem = InMemoryFileSystem() + try await Snapshot.restore(base, to: draftFileSystem) + let draft = Workspace( + fileSystem: draftFileSystem, + persistence: .memory, + recording: recording + ) + return Transaction( + parent: self, + draft: draft, + base: base, + baseCheckpointID: headCheckpointId, + label: label + ) + } + + /// Runs a scoped transaction, committing on success and discarding on failure or conflict. + public func transaction( + label: String? = nil, + _ body: @Sendable (Transaction) async throws -> Value + ) async throws -> TransactionResult { + let transaction = try await beginTransaction(label: label) + do { + let value = try await body(transaction) + let commit = try await transaction.commit() + guard commit.conflicts.isEmpty else { + try await transaction.discard() + throw Transaction.TransactionError.conflicts(commit.conflicts) + } + return TransactionResult(value: value, commit: commit) + } catch { + try? await transaction.discard() + throw error + } + } + + func commitTransaction( + base: Snapshot, + draft: Snapshot, + baseCheckpointID: UUID?, + label: String?, + strategy: Transaction.CommitStrategy + ) async throws -> Transaction.Commit { + try await ensureLoaded() + try await reconcileCheckpointsWithStore() + let current = try await Snapshot.capture(from: filesystem) + let preview = ChangeSet.compare(before: base, after: draft, maxTextBytes: 1_000_000) + + var baseNodes: [WorkspacePath: MergeNode] = [:] + var currentNodes: [WorkspacePath: MergeNode] = [:] + var draftNodes: [WorkspacePath: MergeNode] = [:] + Self.flattenMergeNodes(base.entry, into: &baseNodes) + Self.flattenMergeNodes(current.entry, into: ¤tNodes) + Self.flattenMergeNodes(draft.entry, into: &draftNodes) + baseNodes.removeValue(forKey: .root) + currentNodes.removeValue(forKey: .root) + draftNodes.removeValue(forKey: .root) + + if strategy == .strict, currentNodes != baseNodes { + let changed = Set(baseNodes.keys).union(currentNodes.keys).filter { baseNodes[$0] != currentNodes[$0] } + return Transaction.Commit( + preview: preview, + appliedChanges: ChangeSet(), + checkpoint: nil, + conflicts: changed.sorted().map { .init(path: $0, kind: .parentChanged) } + ) + } + + var merged = currentNodes + var conflicts: [Transaction.Conflict] = [] + for path in Set(baseNodes.keys).union(currentNodes.keys).union(draftNodes.keys).sorted() { + let baseNode = baseNodes[path] + let parentNode = currentNodes[path] + let transactionNode = draftNodes[path] + if transactionNode == baseNode || parentNode == transactionNode { continue } + if parentNode == baseNode { + if let transactionNode { merged[path] = transactionNode } else { merged.removeValue(forKey: path) } + continue + } + let kind: Transaction.Conflict.Kind + if baseNode == nil { + kind = .bothCreated + } else if parentNode == nil || transactionNode == nil { + kind = .modifiedAndDeleted + } else { + kind = .bothModified + } + conflicts.append( + .init( + path: path, + kind: kind, + parentDiff: Self.conflictDiffForTransaction(from: baseNode, to: parentNode), + transactionDiff: Self.conflictDiffForTransaction(from: baseNode, to: transactionNode) + ) + ) + } + guard conflicts.isEmpty else { + return Transaction.Commit( + preview: preview, + appliedChanges: ChangeSet(), + checkpoint: nil, + conflicts: conflicts + ) + } + + let rootPermissions: POSIXPermissions = if case let .directory(directory) = current.entry { + directory.permissions + } else { + .defaultDirectory + } + let mergedSnapshot = Snapshot( + rootPath: .root, + entry: Self.buildEntryTree(from: merged, rootPermissions: rootPermissions) + ) + let applied = ChangeSet.compare(before: current, after: mergedSnapshot, maxTextBytes: 1_000_000) + guard !applied.isEmpty else { + return Transaction.Commit(preview: preview, appliedChanges: applied, checkpoint: nil, conflicts: []) + } + + do { + try await Snapshot.restore(mergedSnapshot, to: filesystem) + } catch { + try? await Snapshot.restore(current, to: filesystem) + throw error + } + try await appendMutation(operation: .transaction, changes: applied) + let checkpoint = try await persistCheckpoint( + snapshot: mergedSnapshot, + label: label, + parentCheckpointId: headCheckpointId, + origin: .transaction(base: baseCheckpointID) + ) + emitWorkspaceEvent(.changes(applied)) + return Transaction.Commit(preview: preview, appliedChanges: applied, checkpoint: checkpoint, conflicts: []) + } + + private static func conflictDiffForTransaction(from base: MergeNode?, to side: MergeNode?) -> TextDiff? { + let baseData = base?.fileData ?? Data() + let sideData = side?.fileData ?? Data() + guard let baseText = String(data: baseData, encoding: .utf8), + let sideText = String(data: sideData, encoding: .utf8) + else { return nil } + let diff = TextDiff.lineBased(from: baseText, to: sideText) + return diff.hunks.isEmpty ? nil : diff + } + +} diff --git a/Tests/WorkspaceTests/CheckpointStoreTests.swift b/Tests/WorkspaceTests/CheckpointStoreTests.swift index 1c30db9..2a93ec9 100644 --- a/Tests/WorkspaceTests/CheckpointStoreTests.swift +++ b/Tests/WorkspaceTests/CheckpointStoreTests.swift @@ -2,624 +2,164 @@ import Foundation import Testing @testable import Workspace -@Suite("CheckpointStore") +@Suite("Checkpoint store") struct CheckpointStoreTests { @Test - func `InMemoryCheckpointStore persists checkpoints snapshots and mutations per workspace`() async throws { + func `in-memory store isolates workspaces and assigns mutation sequences`() async throws { let store = InMemoryCheckpointStore() - let workspaceA = UUID() - let workspaceB = UUID() - let snapshotId = UUID() - let summary = Checkpoint.Summary(changeCount: 0, touchedPaths: [], hasTextDiffs: false) + let firstID = UUID() + let secondID = UUID() + let snapshot = Self.sampleSnapshot() + let checkpoint = Self.sampleCheckpoint(workspaceID: firstID, snapshotID: snapshot.id) - let snapshot = Snapshot( - id: snapshotId, - rootPath: .root, - entry: .directory( - Snapshot.Directory(path: .root, permissions: .defaultDirectory, children: []) - ) - ) - - let cpEarly = Checkpoint( - id: UUID(), - workspaceId: workspaceA, - label: "a", - createdAt: Date(timeIntervalSince1970: 100), - parentCheckpointId: nil, - firstMutationSequence: nil, - lastMutationSequence: nil, - mutationCursor: 0, - snapshotId: snapshotId, - summary: summary - ) + try await store.saveSnapshot(snapshot, workspaceId: firstID) + try await store.saveCheckpoint(checkpoint) + let first = try await store.appendMutation(Self.sampleMutation(workspaceID: firstID, path: "/a")) + let second = try await store.appendMutation(Self.sampleMutation(workspaceID: firstID, path: "/b")) - let cpLate = Checkpoint( - id: UUID(), - workspaceId: workspaceA, - label: "b", - createdAt: Date(timeIntervalSince1970: 200), - parentCheckpointId: cpEarly.id, - firstMutationSequence: 1, - lastMutationSequence: 1, - mutationCursor: 1, - snapshotId: snapshotId, - summary: summary - ) - - try await store.saveSnapshot(snapshot, workspaceId: workspaceA) - try await store.saveCheckpoint(cpLate) - try await store.saveCheckpoint(cpEarly) - - let listed = try await store.listCheckpoints(workspaceId: workspaceA) - #expect(listed.map(\.id) == [cpEarly.id, cpLate.id]) - - let loadedEarly = try await store.loadCheckpoint(id: cpEarly.id, workspaceId: workspaceA) - #expect(loadedEarly == cpEarly) - - let loadedMissing = try await store.loadCheckpoint(id: UUID(), workspaceId: workspaceA) - #expect(loadedMissing == nil) - - #expect(try await store.listCheckpoints(workspaceId: workspaceB).isEmpty) - - let m3 = MutationRecord( - sequence: 3, - workspaceId: workspaceA, - kind: .writeFile, - touchedPaths: ["/x"], - fileChanges: [] - ) - let m1 = MutationRecord( - sequence: 1, - workspaceId: workspaceA, - kind: .writeFile, - touchedPaths: ["/y"], - fileChanges: [] - ) - try await store.appendMutation(m3) - try await store.appendMutation(m1) - - let mutations = try await store.listMutationRecords(workspaceId: workspaceA) - #expect(mutations.map(\.sequence) == [1, 2]) - - let reloadedSnapshot = try await store.loadSnapshot(id: snapshotId, workspaceId: workspaceA) - #expect(reloadedSnapshot == snapshot) - #expect(try await store.loadSnapshot(id: UUID(), workspaceId: workspaceA) == nil) + #expect(first.sequence == 1) + #expect(second.sequence == 2) + #expect(try await store.loadCheckpoint(id: checkpoint.id, workspaceId: firstID) == checkpoint) + #expect(try await store.loadSnapshot(id: snapshot.id, workspaceId: firstID) == snapshot) + #expect(try await store.listMutations(workspaceId: firstID).map(\.sequence) == [1, 2]) + #expect(try await store.listCheckpoints(workspaceId: secondID).isEmpty) + #expect(try await store.listMutations(workspaceId: secondID).isEmpty) } @Test - func `FileCheckpointStore roundtrips data on disk across actor instances`() async throws { - let root = try makeTempDirectory() - defer { removeTempDirectory(root) } - - let workspaceId = UUID() - let snapshotId = UUID() - let summary = Checkpoint.Summary(changeCount: 1, touchedPaths: ["/f"], hasTextDiffs: true) - + func `file store persists manifests and deduplicated content-addressed blobs`() async throws { + let root = try TestSupport.temporaryDirectory("StoreCAS") + defer { TestSupport.remove(root) } + let workspaceID = UUID() + let data = Data("shared".utf8) let snapshot = Snapshot( - id: snapshotId, rootPath: .root, - entry: .file( - Snapshot.File(path: "/f", data: Data("hi".utf8), permissions: .defaultFile) + entry: .directory( + .init( + path: .root, + permissions: .defaultDirectory, + children: [ + .file(.init(path: "/a", data: data, permissions: .defaultFile)), + .file(.init(path: "/b", data: data, permissions: .defaultFile)), + ] + ) ) ) - - let checkpoint = Checkpoint( - workspaceId: workspaceId, - label: "disk", - parentCheckpointId: nil, - firstMutationSequence: 1, - lastMutationSequence: 2, - mutationCursor: 2, - snapshotId: snapshotId, - summary: summary - ) - - let mutation = MutationRecord( - sequence: 1, - workspaceId: workspaceId, - kind: .writeFile, - touchedPaths: ["/f"], - fileChanges: [] - ) - + let checkpoint = Self.sampleCheckpoint(workspaceID: workspaceID, snapshotID: snapshot.id) let writer = FileCheckpointStore(rootDirectory: root) - try await writer.saveSnapshot(snapshot, workspaceId: workspaceId) + try await writer.saveSnapshot(snapshot, workspaceId: workspaceID) try await writer.saveCheckpoint(checkpoint) - try await writer.appendMutation(mutation) let reader = FileCheckpointStore(rootDirectory: root) - let checkpoints = try await reader.listCheckpoints(workspaceId: workspaceId) - #expect(checkpoints == [checkpoint]) - - let loaded = try await reader.loadCheckpoint(id: checkpoint.id, workspaceId: workspaceId) - #expect(loaded == checkpoint) - - let loadedSnap = try await reader.loadSnapshot(id: snapshotId, workspaceId: workspaceId) - #expect(loadedSnap == snapshot) - - let mutations = try await reader.listMutationRecords(workspaceId: workspaceId) - #expect(mutations == [mutation]) - - #expect(try await reader.listCheckpoints(workspaceId: UUID()).isEmpty) - #expect(try await reader.listMutationRecords(workspaceId: UUID()).isEmpty) - } - - @Test - func `InMemoryCheckpointStore saveSnapshot replaces an existing snapshot id`() async throws { - let store = InMemoryCheckpointStore() - let workspaceId = UUID() - let snapshotId = UUID() - - let first = Snapshot( - id: snapshotId, - rootPath: .root, - entry: .file(Snapshot.File(path: "/a", data: Data("1".utf8), permissions: .defaultFile)) - ) - let second = Snapshot( - id: snapshotId, - rootPath: .root, - entry: .file(Snapshot.File(path: "/a", data: Data("2".utf8), permissions: .defaultFile)) + #expect(try await reader.loadSnapshot(id: snapshot.id, workspaceId: workspaceID) == snapshot) + #expect(try await reader.loadCheckpoint(id: checkpoint.id, workspaceId: workspaceID) == checkpoint) + #expect( + try await reader.readSnapshotFile( + id: snapshot.id, + workspaceId: workspaceID, + path: "/a", + offset: 1, + length: 3 + ) == Data("har".utf8) ) - try await store.saveSnapshot(first, workspaceId: workspaceId) - try await store.saveSnapshot(second, workspaceId: workspaceId) - - let loaded = try await store.loadSnapshot(id: snapshotId, workspaceId: workspaceId) - #expect(loaded == second) + let blobs = root.appendingPathComponent(workspaceID.uuidString).appendingPathComponent("blobs") + let names = try FileManager.default.contentsOfDirectory(atPath: blobs.path) + #expect(names == [SHA256.hexDigest(of: data)]) } @Test - func `InMemoryCheckpointStore saveCheckpoint overwrites an existing checkpoint id`() async throws { - let store = InMemoryCheckpointStore() - let workspaceId = UUID() - let sharedId = UUID() - let snapshotId = UUID() - let summary = Checkpoint.Summary(changeCount: 0, touchedPaths: [], hasTextDiffs: false) - - let first = Checkpoint( - id: sharedId, - workspaceId: workspaceId, - label: "first", - createdAt: Date(timeIntervalSince1970: 50), - parentCheckpointId: nil, - firstMutationSequence: nil, - lastMutationSequence: nil, - mutationCursor: 0, - snapshotId: snapshotId, - summary: summary - ) - var second = first - second.label = "second" - second.createdAt = Date(timeIntervalSince1970: 150) - - try await store.saveCheckpoint(first) - try await store.saveCheckpoint(second) - - let loaded = try await store.loadCheckpoint(id: sharedId, workspaceId: workspaceId) - #expect(loaded == second) - - let listed = try await store.listCheckpoints(workspaceId: workspaceId) - #expect(listed.count == 1) - #expect(listed[0].label == "second") - } - - @Test - func `FileCheckpointStore appendMutation merges and sorts existing log on disk`() async throws { - let root = try makeTempDirectory() - defer { removeTempDirectory(root) } - - let workspaceId = UUID() - let storeA = FileCheckpointStore(rootDirectory: root) - - let m2 = MutationRecord( - sequence: 2, - workspaceId: workspaceId, - kind: .writeFile, - touchedPaths: ["/b"], - fileChanges: [] - ) - let m1 = MutationRecord( - sequence: 1, - workspaceId: workspaceId, - kind: .writeFile, - touchedPaths: ["/a"], - fileChanges: [] - ) - - try await storeA.appendMutation(m2) - try await storeA.appendMutation(m1) - - let storeB = FileCheckpointStore(rootDirectory: root) - let merged = try await storeB.listMutationRecords(workspaceId: workspaceId) - #expect(merged.map(\.sequence) == [1, 2]) - #expect(merged.map(\.touchedPaths) == [["/b"], ["/a"]]) - - let m3 = MutationRecord( - sequence: 3, - workspaceId: workspaceId, - kind: .appendFile, - touchedPaths: ["/c"], - fileChanges: [] - ) - try await storeB.appendMutation(m3) - - let storeC = FileCheckpointStore(rootDirectory: root) - #expect(try await storeC.listMutationRecords(workspaceId: workspaceId).map(\.sequence) == [1, 2, 3]) - } - - @Test - func `FileCheckpointStore saveCheckpoint overwrites the same id file`() async throws { - let root = try makeTempDirectory() - defer { removeTempDirectory(root) } - - let workspaceId = UUID() - let checkpointId = UUID() - let snapshotId = UUID() - let summary = Checkpoint.Summary(changeCount: 0, touchedPaths: [], hasTextDiffs: false) - - let first = Checkpoint( - id: checkpointId, - workspaceId: workspaceId, - label: "v1", - parentCheckpointId: nil, - firstMutationSequence: nil, - lastMutationSequence: nil, - mutationCursor: 0, - snapshotId: snapshotId, - summary: summary - ) - var second = first - second.label = "v2" - - let writer = FileCheckpointStore(rootDirectory: root) - try await writer.saveCheckpoint(first) - try await writer.saveCheckpoint(second) - - let reader = FileCheckpointStore(rootDirectory: root) - let loaded = try await reader.loadCheckpoint(id: checkpointId, workspaceId: workspaceId) - #expect(loaded?.label == "v2") - - let listed = try await reader.listCheckpoints(workspaceId: workspaceId) - #expect(listed.count == 1) - #expect(listed[0].label == "v2") - } - - @Test - func `FileCheckpointStore listMutationRecords returns empty array when no log file exists`() async throws { - let root = try makeTempDirectory() - defer { removeTempDirectory(root) } - - let workspaceId = UUID() - let store = FileCheckpointStore(rootDirectory: root) - - let mutations = try await store.listMutationRecords(workspaceId: workspaceId) - #expect(mutations.isEmpty) - - let workspaceRoot = root.appendingPathComponent(workspaceId.uuidString, isDirectory: true) - let mutationsFile = workspaceRoot.appendingPathComponent("mutations.jsonl") - #expect(!FileManager.default.fileExists(atPath: mutationsFile.path)) - } - - @Test - func `FileCheckpointStore migrates legacy mutations json array to jsonl`() async throws { - let root = try makeTempDirectory() - defer { removeTempDirectory(root) } - - let workspaceId = UUID() - let workspaceRoot = root.appendingPathComponent(workspaceId.uuidString, isDirectory: true) - try FileManager.default.createDirectory(at: workspaceRoot, withIntermediateDirectories: true) - let legacyURL = workspaceRoot.appendingPathComponent("mutations.json", isDirectory: false) - let jsonlURL = workspaceRoot.appendingPathComponent("mutations.jsonl", isDirectory: false) - - let stored = MutationRecord( - sequence: 99, - workspaceId: workspaceId, - kind: .writeFile, - touchedPaths: ["/legacy.txt"], - fileChanges: [] - ) - let data = try JSONEncoder().encode([stored]) - try data.write(to: legacyURL) - - let store = FileCheckpointStore(rootDirectory: root) - let loaded = try await store.listMutationRecords(workspaceId: workspaceId) - #expect(loaded.count == 1) - #expect(loaded[0].touchedPaths == ["/legacy.txt"]) - #expect(loaded[0].sequence == 99) - - #expect(!FileManager.default.fileExists(atPath: legacyURL.path)) - #expect(FileManager.default.fileExists(atPath: jsonlURL.path)) - } - - @Test - func `FileCheckpointStore listMutationRecords treats an empty mutations file as no records`() async throws { - let root = try makeTempDirectory() - defer { removeTempDirectory(root) } - - let workspaceId = UUID() - let workspaceRoot = root.appendingPathComponent(workspaceId.uuidString, isDirectory: true) - try FileManager.default.createDirectory(at: workspaceRoot, withIntermediateDirectories: true) - let mutationsFile = workspaceRoot.appendingPathComponent("mutations.jsonl") - try Data().write(to: mutationsFile) - - let store = FileCheckpointStore(rootDirectory: root) - - let mutations = try await store.listMutationRecords(workspaceId: workspaceId) - #expect(mutations.isEmpty) - - let appended = MutationRecord( - sequence: 0, - workspaceId: workspaceId, - kind: .writeFile, - touchedPaths: ["/x"], - fileChanges: [] - ) - let written = try await store.appendMutation(appended) - - let after = try await store.listMutationRecords(workspaceId: workspaceId) - #expect(after == [written]) - #expect(written.sequence == 1) - } - - @Test - func `FileCheckpointStore appendMutation serializes concurrent writers without losing records`() async throws { - let root = try makeTempDirectory() - defer { removeTempDirectory(root) } - - let workspaceId = UUID() - let writerCount = 8 - let perWriter = 25 - let stores = (0.. Snapshot.Entry { - .file(Snapshot.File(path: path, data: Data(text.utf8), permissions: POSIXPermissions(0o644))) + await #expect(throws: WorkspaceError.self) { + _ = try await store.listMutations(workspaceId: workspaceID) } - func tree(_ children: [Snapshot.Entry]) -> Snapshot { - Snapshot( - rootPath: .root, - entry: .directory( - Snapshot.Directory(path: .root, permissions: .defaultDirectory, children: children) - ) - ) - } - - let first = tree([fileEntry("/a.txt", "alpha"), fileEntry("/b.txt", "beta")]) - let second = tree([fileEntry("/a.txt", "alpha"), fileEntry("/b.txt", "changed")]) - try await store.saveSnapshot(first, workspaceId: workspaceId) - try await store.saveSnapshot(second, workspaceId: workspaceId) - - // "alpha" is shared between the snapshots and stored once: three blobs, not four. - let blobsDirectory = root - .appendingPathComponent(workspaceId.uuidString, isDirectory: true) - .appendingPathComponent("blobs", isDirectory: true) - let blobs = try FileManager.default.contentsOfDirectory(atPath: blobsDirectory.path) - #expect(blobs.count == 3) - - // The manifest carries hashes, not base64-inflated contents. - let manifestURL = root - .appendingPathComponent(workspaceId.uuidString, isDirectory: true) - .appendingPathComponent("snapshots", isDirectory: true) - .appendingPathComponent("\(first.id.uuidString).json", isDirectory: false) - let manifestText = try String(contentsOf: manifestURL, encoding: .utf8) - #expect(!manifestText.contains(Data("alpha".utf8).base64EncodedString())) - #expect(manifestText.contains("\"version\"")) - #expect(manifestText.contains(SHA256.hexDigest(of: Data("alpha".utf8)))) - - // Loading reconstructs full snapshots, including contents and permissions. - #expect(try await store.loadSnapshot(id: first.id, workspaceId: workspaceId) == first) - #expect(try await store.loadSnapshot(id: second.id, workspaceId: workspaceId) == second) - - // A fresh store instance reads the same artifacts. - let reader = FileCheckpointStore(rootDirectory: root) - #expect(try await reader.loadSnapshot(id: second.id, workspaceId: workspaceId) == second) } - @Test - func `FileCheckpointStore loads legacy inline snapshots`() async throws { - let root = try makeTempDirectory() - defer { removeTempDirectory(root) } - - let workspaceId = UUID() - let snapshot = Snapshot( + private static func sampleSnapshot() -> Snapshot { + Snapshot( rootPath: .root, entry: .directory( - Snapshot.Directory( + .init( path: .root, permissions: .defaultDirectory, - children: [ - .file(Snapshot.File(path: "/x.txt", data: Data("legacy".utf8), permissions: POSIXPermissions(0o600))), - ] + children: [.file(.init(path: "/note", data: Data("note".utf8), permissions: .defaultFile))] ) ) ) + } - // Write the legacy format (full snapshot with inline contents) directly. - let snapshotsDirectory = root - .appendingPathComponent(workspaceId.uuidString, isDirectory: true) - .appendingPathComponent("snapshots", isDirectory: true) - try FileManager.default.createDirectory(at: snapshotsDirectory, withIntermediateDirectories: true) - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - try encoder.encode(snapshot).write( - to: snapshotsDirectory.appendingPathComponent("\(snapshot.id.uuidString).json", isDirectory: false) + private static func sampleCheckpoint(workspaceID: UUID, snapshotID: UUID) -> Checkpoint { + Checkpoint( + workspaceId: workspaceID, + label: "test", + parentCheckpointId: nil, + firstMutationSequence: nil, + lastMutationSequence: nil, + mutationCursor: 0, + snapshotId: snapshotID, + summary: ChangeSet().summary ) - - let store = FileCheckpointStore(rootDirectory: root) - #expect(try await store.loadSnapshot(id: snapshot.id, workspaceId: workspaceId) == snapshot) } - @Test - func `pruneMutationRecords bounds the log and keeps the newest record`() async throws { - let root = try makeTempDirectory() - defer { removeTempDirectory(root) } - - let store = FileCheckpointStore(rootDirectory: root) - let workspaceId = UUID() - for index in 0..<5 { - try await store.appendMutation( - MutationRecord( - sequence: 0, - workspaceId: workspaceId, - kind: .writeFile, - touchedPaths: [WorkspacePath(normalizing: "/file-\(index).txt")], - fileChanges: [] - ) - ) - } - - try await store.pruneMutationRecords(workspaceId: workspaceId, throughSequence: 3) - #expect(try await store.listMutationRecords(workspaceId: workspaceId).map(\.sequence) == [4, 5]) - - // Pruning everything still retains the newest record so sequences stay monotonic. - try await store.pruneMutationRecords(workspaceId: workspaceId, throughSequence: 100) - #expect(try await store.listMutationRecords(workspaceId: workspaceId).map(\.sequence) == [5]) - - let appended = try await store.appendMutation( - MutationRecord( - sequence: 0, - workspaceId: workspaceId, - kind: .writeFile, - touchedPaths: ["/next.txt"], - fileChanges: [] + private static func sampleMutation(workspaceID: UUID, path: WorkspacePath) -> Mutation { + Mutation( + sequence: 0, + workspaceID: workspaceID, + operation: .edit, + changes: ChangeSet( + changes: [.init(path: path, kind: .file, effect: .created)] ) ) - #expect(appended.sequence == 6) - } - - 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) - try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) - return url - } - - private func removeTempDirectory(_ url: URL) { - try? FileManager.default.removeItem(at: url) } } diff --git a/Tests/WorkspaceTests/CheckpointTests.swift b/Tests/WorkspaceTests/CheckpointTests.swift deleted file mode 100644 index 3e45d4a..0000000 --- a/Tests/WorkspaceTests/CheckpointTests.swift +++ /dev/null @@ -1,126 +0,0 @@ -import Foundation -import Testing -@testable import Workspace - -@Suite("Checkpoint") -struct CheckpointTests { - @Test - func `Checkpoint inferredEventKind classifies created rollback and merge`() async throws { - let workspaceId = UUID() - let snapshotId = UUID() - let summary = Checkpoint.Summary(changeCount: 0, touchedPaths: [], hasTextDiffs: false) - - let created = Checkpoint( - workspaceId: workspaceId, - label: nil, - parentCheckpointId: nil, - baseCheckpointId: nil, - firstMutationSequence: nil, - lastMutationSequence: nil, - mutationCursor: 0, - snapshotId: snapshotId, - summary: summary - ) - #expect(created.inferredEventKind == .created) - - let rolledBack = Checkpoint( - workspaceId: workspaceId, - label: nil, - parentCheckpointId: nil, - baseCheckpointId: nil, - rollbackSourceCheckpointId: UUID(), - firstMutationSequence: nil, - lastMutationSequence: nil, - mutationCursor: 0, - snapshotId: snapshotId, - summary: summary - ) - #expect(rolledBack.inferredEventKind == .rolledBack) - - let merged = Checkpoint( - workspaceId: workspaceId, - label: nil, - parentCheckpointId: nil, - baseCheckpointId: nil, - mergedFromWorkspaceId: UUID(), - mergedFromCheckpointId: UUID(), - firstMutationSequence: nil, - lastMutationSequence: nil, - mutationCursor: 0, - snapshotId: snapshotId, - summary: summary - ) - #expect(merged.inferredEventKind == .merged) - } - - @Test - func `CheckpointEvent and MutationRecord roundtrip through Codable`() async throws { - let workspaceId = UUID() - let summary = Checkpoint.Summary(changeCount: 0, touchedPaths: [], hasTextDiffs: false) - let checkpoint = Checkpoint( - workspaceId: workspaceId, - label: "x", - parentCheckpointId: nil, - baseCheckpointId: nil, - firstMutationSequence: nil, - lastMutationSequence: nil, - mutationCursor: 0, - snapshotId: UUID(), - summary: summary - ) - - let encoder = JSONEncoder() - let decoder = JSONDecoder() - - let event = CheckpointEvent(kind: .created, checkpoint: checkpoint) - #expect(try decoder.decode(CheckpointEvent.self, from: encoder.encode(event)) == event) - - let kinds: [MutationRecord.Kind] = [ - .writeFile, - .appendFile, - .writeData, - .writeJSON, - .createDirectory, - .removeItem, - .copyItem, - .moveItem, - .applyEdits, - .applyReplacement, - .restoreSnapshot, - .mergeWorkspace, - ] - - for kind in kinds { - let mutation = MutationRecord( - sequence: 1, - workspaceId: workspaceId, - kind: kind, - touchedPaths: ["/a.txt"], - fileChanges: [] - ) - let decoded = try decoder.decode(MutationRecord.self, from: encoder.encode(mutation)) - #expect(decoded == mutation) - } - } - - @Test - func `WorkspaceError descriptions are stable and include identifiers`() async throws { - let checkpointId = UUID() - let snapshotId = UUID() - let workspaceId = UUID() - let headId = UUID() - - let cases: [(WorkspaceError, String)] = [ - (.checkpointNotFound(checkpointId), checkpointId.uuidString), - (.snapshotNotFound(snapshotId), snapshotId.uuidString), - (.mergeConflict(parentWorkspaceId: workspaceId, expectedBase: nil, actualHead: headId), workspaceId.uuidString), - (.mergeConflict(parentWorkspaceId: workspaceId, expectedBase: nil, actualHead: headId), headId.uuidString), - (.mutationFailed("boom"), "boom"), - ] - - for (error, needle) in cases { - let description = String(describing: error) - #expect(description.contains(needle), "missing '\(needle)' in: \(description)") - } - } -} diff --git a/Tests/WorkspaceTests/CoreTests.swift b/Tests/WorkspaceTests/CoreTests.swift deleted file mode 100644 index 9721616..0000000 --- a/Tests/WorkspaceTests/CoreTests.swift +++ /dev/null @@ -1,1241 +0,0 @@ -import Foundation -import Testing -@testable import Workspace - -private enum CoreTestSupport { - static func makeTempDirectory(prefix: String = "CoreTests") throws -> URL { - let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) - let url = base.appendingPathComponent("\(prefix)-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) - return url - } - - static func removeDirectory(_ url: URL) { - try? FileManager.default.removeItem(at: url) - } -} - -private final class FailOnceFilesystem: FileSystem, @unchecked Sendable { - private let base: any FileSystem - private let failingWritePaths: Set - private var failedWritePaths: Set = [] - - init(base: any FileSystem, failingWritePaths: Set) { - self.base = base - self.failingWritePaths = failingWritePaths - } - - func configure(rootDirectory: URL) async throws { - try await base.configure(rootDirectory: rootDirectory) - } - - func stat(path: WorkspacePath) async throws -> FileInfo { - try await base.stat(path: path) - } - - func listDirectory(path: WorkspacePath) async throws -> [DirectoryEntry] { - try await base.listDirectory(path: path) - } - - func readFile(path: WorkspacePath) async throws -> Data { - try await base.readFile(path: path) - } - - func writeFile(path: WorkspacePath, data: Data, append: Bool) async throws { - if failingWritePaths.contains(path), !failedWritePaths.contains(path) { - failedWritePaths.insert(path) - throw WorkspaceError.unsupported("forced write failure") - } - try await base.writeFile(path: path, data: data, append: append) - } - - func createDirectory(path: WorkspacePath, recursive: Bool) async throws { - try await base.createDirectory(path: path, recursive: recursive) - } - - func remove(path: WorkspacePath, recursive: Bool) async throws { - try await base.remove(path: path, recursive: recursive) - } - - func move(from sourcePath: WorkspacePath, to destinationPath: WorkspacePath) async throws { - try await base.move(from: sourcePath, to: destinationPath) - } - - func copy(from sourcePath: WorkspacePath, to destinationPath: WorkspacePath, recursive: Bool) async throws { - try await base.copy(from: sourcePath, to: destinationPath, recursive: recursive) - } - - func createSymlink(path: WorkspacePath, target: String) async throws { - try await base.createSymlink(path: path, target: target) - } - - func createHardLink(path: WorkspacePath, target: WorkspacePath) async throws { - try await base.createHardLink(path: path, target: target) - } - - func readSymlink(path: WorkspacePath) async throws -> String { - try await base.readSymlink(path: path) - } - - func setPermissions(path: WorkspacePath, permissions: POSIXPermissions) async throws { - try await base.setPermissions(path: path, permissions: permissions) - } - - func resolveRealPath(path: WorkspacePath) async throws -> WorkspacePath { - try await base.resolveRealPath(path: path) - } - - func exists(path: WorkspacePath) async -> Bool { - await base.exists(path: path) - } - - func glob(pattern: String, currentDirectory: WorkspacePath) async throws -> [WorkspacePath] { - try await base.glob(pattern: pattern, currentDirectory: currentDirectory) - } -} - -private final class NSErrorFailOnceFilesystem: FileSystem, @unchecked Sendable { - private let base: any FileSystem - private let failingWritePaths: Set - private var failedWritePaths: Set = [] - - init(base: any FileSystem, failingWritePaths: Set) { - self.base = base - self.failingWritePaths = failingWritePaths - } - - func configure(rootDirectory: URL) async throws { - try await base.configure(rootDirectory: rootDirectory) - } - - func stat(path: WorkspacePath) async throws -> FileInfo { - try await base.stat(path: path) - } - - func listDirectory(path: WorkspacePath) async throws -> [DirectoryEntry] { - try await base.listDirectory(path: path) - } - - func readFile(path: WorkspacePath) async throws -> Data { - try await base.readFile(path: path) - } - - func writeFile(path: WorkspacePath, data: Data, append: Bool) async throws { - if failingWritePaths.contains(path), !failedWritePaths.contains(path) { - failedWritePaths.insert(path) - throw NSError(domain: "CoreTests", code: 42, userInfo: [NSLocalizedDescriptionKey: "forced NSError write failure"]) - } - try await base.writeFile(path: path, data: data, append: append) - } - - func createDirectory(path: WorkspacePath, recursive: Bool) async throws { - try await base.createDirectory(path: path, recursive: recursive) - } - - func remove(path: WorkspacePath, recursive: Bool) async throws { - try await base.remove(path: path, recursive: recursive) - } - - func move(from sourcePath: WorkspacePath, to destinationPath: WorkspacePath) async throws { - try await base.move(from: sourcePath, to: destinationPath) - } - - func copy(from sourcePath: WorkspacePath, to destinationPath: WorkspacePath, recursive: Bool) async throws { - try await base.copy(from: sourcePath, to: destinationPath, recursive: recursive) - } - - func createSymlink(path: WorkspacePath, target: String) async throws { - try await base.createSymlink(path: path, target: target) - } - - func createHardLink(path: WorkspacePath, target: WorkspacePath) async throws { - try await base.createHardLink(path: path, target: target) - } - - func readSymlink(path: WorkspacePath) async throws -> String { - try await base.readSymlink(path: path) - } - - func setPermissions(path: WorkspacePath, permissions: POSIXPermissions) async throws { - try await base.setPermissions(path: path, permissions: permissions) - } - - func resolveRealPath(path: WorkspacePath) async throws -> WorkspacePath { - try await base.resolveRealPath(path: path) - } - - func exists(path: WorkspacePath) async -> Bool { - await base.exists(path: path) - } - - func glob(pattern: String, currentDirectory: WorkspacePath) async throws -> [WorkspacePath] { - try await base.glob(pattern: pattern, currentDirectory: currentDirectory) - } -} - -private final class MinimalFilesystem: FileSystem, @unchecked Sendable { - private let base = InMemoryFilesystem() - - func stat(path: WorkspacePath) async throws -> FileInfo { - try await base.stat(path: path) - } - - func listDirectory(path: WorkspacePath) async throws -> [DirectoryEntry] { - try await base.listDirectory(path: path) - } - - func readFile(path: WorkspacePath) async throws -> Data { - try await base.readFile(path: path) - } - - func writeFile(path: WorkspacePath, data: Data, append: Bool) async throws { - try await base.writeFile(path: path, data: data, append: append) - } - - func createDirectory(path: WorkspacePath, recursive: Bool) async throws { - try await base.createDirectory(path: path, recursive: recursive) - } - - func remove(path: WorkspacePath, recursive: Bool) async throws { - try await base.remove(path: path, recursive: recursive) - } - - func move(from sourcePath: WorkspacePath, to destinationPath: WorkspacePath) async throws { - try await base.move(from: sourcePath, to: destinationPath) - } - - func copy(from sourcePath: WorkspacePath, to destinationPath: WorkspacePath, recursive: Bool) async throws { - try await base.copy(from: sourcePath, to: destinationPath, recursive: recursive) - } - - func exists(path: WorkspacePath) async -> Bool { - await base.exists(path: path) - } - - func glob(pattern: String, currentDirectory: WorkspacePath) async throws -> [WorkspacePath] { - try await base.glob(pattern: pattern, currentDirectory: currentDirectory) - } -} - -private actor ChangeEventRecorder { - private var events: [ChangeEvent] = [] - - func append(_ event: ChangeEvent) { - events.append(event) - } - - func snapshot() -> [ChangeEvent] { - events - } -} - -private enum ChangeWatchTestError: Error { - case timeout(expected: Int, actual: Int) -} - -private func startRecording( - _ stream: AsyncStream, - into recorder: ChangeEventRecorder -) -> Task { - Task { - for await event in stream { - await recorder.append(event) - } - } -} - -private func waitForRecordedEvents( - _ expectedCount: Int, - recorder: ChangeEventRecorder, - timeout: Duration = .seconds(1) -) async throws -> [ChangeEvent] { - let clock = ContinuousClock() - let deadline = clock.now.advanced(by: timeout) - - while clock.now < deadline { - let snapshot = await recorder.snapshot() - if snapshot.count >= expectedCount { - return Array(snapshot.prefix(expectedCount)) - } - try await Task.sleep(for: .milliseconds(10)) - } - - let snapshot = await recorder.snapshot() - throw ChangeWatchTestError.timeout(expected: expectedCount, actual: snapshot.count) -} - -private func waitForSettledEvents( - recorder: ChangeEventRecorder, - settling: Duration = .milliseconds(50) -) async throws -> [ChangeEvent] { - try await Task.sleep(for: settling) - return await recorder.snapshot() -} - -private struct DemoConfig: Codable, Equatable, Sendable { - var name: String - var enabled: Bool -} - -private func diffLines(_ diff: TextDiff?) -> [TextDiff.Line] { - diff?.hunks.flatMap(\.lines) ?? [] -} - -private func addedLineTexts(_ diff: TextDiff?) -> [String] { - diffLines(diff).filter { $0.kind == .added }.map(\.text) -} - -private func removedLineTexts(_ diff: TextDiff?) -> [String] { - diffLines(diff).filter { $0.kind == .removed }.map(\.text) -} - -private func assertBasicWatchSemantics( - workspace: Workspace, - watchedPath: WorkspacePath -) async throws { - let recorder = ChangeEventRecorder() - let stream = await workspace.watchChanges(at: watchedPath, recursive: false) - let task = startRecording(stream, into: recorder) - defer { task.cancel() } - - try await workspace.writeFile(watchedPath, content: "one") - try await workspace.writeFile(watchedPath, content: "two") - try await workspace.removeItem(at: watchedPath) - - let events = try await waitForRecordedEvents(3, recorder: recorder) - #expect( - events == [ - ChangeEvent(kind: .created, path: watchedPath, nodeKind: .file), - ChangeEvent(kind: .modified, path: watchedPath, nodeKind: .file), - ChangeEvent(kind: .deleted, path: watchedPath, nodeKind: .file), - ] - ) -} - -@Suite("Core") -struct CoreTests { - @Test - func `workspace module reexports core filesystem primitives`() async throws { - let workspaceFilesystem: any FileSystem = InMemoryFilesystem() - let mount = MountableFilesystem.Mount(mountPoint: "/memory", filesystem: InMemoryFilesystem()) - let permission = PermissionRequest(operation: .readFile, path: "/memory/note.txt") - let error = WorkspaceError.unsupported("workspace shim check") - - #expect(await workspaceFilesystem.exists(path: "/")) - #expect(mount.mountPoint == "/memory") - #expect(permission.path == "/memory/note.txt") - #expect(error.description.contains("workspace shim check")) - } - - @Test - func `workspace exposes immutable filesystem property`() async throws { - let workspace = Workspace(filesystem: InMemoryFilesystem()) - - try await workspace.writeFile("/note.txt", content: "hello") - - let filesystem: any FileSystem = workspace.filesystem - #expect(try await filesystem.readFile(path: "/note.txt") == Data("hello".utf8)) - - try await filesystem.writeFile(path: "/direct.txt", data: Data("normal".utf8), append: false) - #expect(try await workspace.readFile("/direct.txt") == "normal") - } - - @Test - func `readJSON and writeJSON roundtrip`() async throws { - let fs = InMemoryFilesystem() - let state = Workspace(filesystem: fs) - - try await state.writeJSON(DemoConfig(name: "demo", enabled: true), to: "/config.json") - let loaded: DemoConfig = try await state.readJSON(from: "/config.json") - #expect(loaded == DemoConfig(name: "demo", enabled: true)) - } - - @Test - func `workspace supports binary data appends directory listings and globbing`() async throws { - let state = Workspace(filesystem: InMemoryFilesystem()) - - try await state.createDirectory(at: "/", recursive: false) - try await state.createDirectory(at: "/docs", recursive: false) - try await state.writeData(Data([0xDE, 0xAD, 0xBE, 0xEF]), to: "/docs/blob.bin") - try await state.writeFile("/docs/note.txt", content: "one") - try await state.appendFile("/docs/note.txt", content: " two") - - #expect(try await state.readData(from: "/docs/blob.bin") == Data([0xDE, 0xAD, 0xBE, 0xEF])) - #expect(try await state.readFile("/docs/note.txt") == "one two") - - let info = try await state.fileInfo(at: "/docs/note.txt") - #expect(info.kind == .file) - #expect(info.size == 7) - - let entries = try await state.listDirectory(at: "/docs") - #expect(entries.map(\.name) == ["blob.bin", "note.txt"]) - - let globbed = try await state.glob("/docs/*", currentDirectory: "/") - #expect(globbed == ["/docs/blob.bin", "/docs/note.txt"]) - #expect(await state.exists("/docs/blob.bin")) - - do { - _ = try await state.readFile("/docs/blob.bin") - Issue.record("expected invalid UTF-8 error") - } catch let error as WorkspaceError { - #expect(error.description.contains("not valid UTF-8")) - } - } - - @Test(.tags(.watching)) - func `watchChanges emits create modify and delete for a file`() async throws { - let state = Workspace(filesystem: InMemoryFilesystem()) - let recorder = ChangeEventRecorder() - let stream = await state.watchChanges(at: "/note.txt", recursive: false) - let task = startRecording(stream, into: recorder) - defer { task.cancel() } - - try await state.writeFile("/note.txt", content: "one") - try await state.writeFile("/note.txt", content: "one") - try await state.writeFile("/note.txt", content: "two") - try await state.removeItem(at: "/note.txt") - - let events = try await waitForRecordedEvents(3, recorder: recorder) - #expect( - events == [ - ChangeEvent(kind: .created, path: "/note.txt", nodeKind: .file), - ChangeEvent(kind: .modified, path: "/note.txt", nodeKind: .file), - ChangeEvent(kind: .deleted, path: "/note.txt", nodeKind: .file), - ] - ) - } - - @Test(.tags(.watching)) - func `watchChanges recursively emits directory file and symlink copy events`() async throws { - let fs = InMemoryFilesystem() - try await fs.createDirectory(path: "/docs/archive", recursive: true) - try await fs.writeFile(path: "/docs/archive/file.txt", data: Data("hello".utf8), append: false) - try await fs.createSymlink(path: "/docs/archive/link.txt", target: "file.txt") - let state = Workspace(filesystem: fs) - - let recorder = ChangeEventRecorder() - let stream = await state.watchChanges(at: "/docs") - let task = startRecording(stream, into: recorder) - defer { task.cancel() } - - try await state.copyItem(from: "/docs/archive", to: "/docs/copy") - - let events = try await waitForRecordedEvents(3, recorder: recorder) - #expect( - events == [ - ChangeEvent( - kind: .copied, - path: "/docs/copy", - sourcePath: "/docs/archive", - nodeKind: .directory - ), - ChangeEvent( - kind: .copied, - path: "/docs/copy/file.txt", - sourcePath: "/docs/archive/file.txt", - nodeKind: .file - ), - ChangeEvent( - kind: .copied, - path: "/docs/copy/link.txt", - sourcePath: "/docs/archive/link.txt", - nodeKind: .symlink - ), - ] - ) - } - - @Test(.tags(.watching)) - func `watchChanges emits missing directory creation and symlink deletion events`() async throws { - let fs = InMemoryFilesystem() - try await fs.writeFile(path: "/target.txt", data: Data("hello".utf8), append: false) - try await fs.createSymlink(path: "/link.txt", target: "target.txt") - let state = Workspace(filesystem: fs) - - let directoryRecorder = ChangeEventRecorder() - let symlinkRecorder = ChangeEventRecorder() - let directoryTask = startRecording(await state.watchChanges(at: "/folder", recursive: false), into: directoryRecorder) - let symlinkTask = startRecording(await state.watchChanges(at: "/link.txt", recursive: false), into: symlinkRecorder) - defer { - directoryTask.cancel() - symlinkTask.cancel() - } - - try await state.createDirectory(at: "/folder", recursive: false) - try await state.createDirectory(at: "/folder", recursive: true) - try await state.removeItem(at: "/link.txt", recursive: false) - - let directoryEvents = try await waitForRecordedEvents(1, recorder: directoryRecorder) - let symlinkEvents = try await waitForRecordedEvents(1, recorder: symlinkRecorder) - #expect(directoryEvents == [ChangeEvent(kind: .created, path: "/folder", nodeKind: .directory)]) - #expect(symlinkEvents == [ChangeEvent(kind: .deleted, path: "/link.txt", nodeKind: .symlink)]) - - let settledDirectoryEvents = try await waitForSettledEvents(recorder: directoryRecorder) - #expect(settledDirectoryEvents == directoryEvents) - } - - @Test(.tags(.watching)) - func `watchChanges matches move events by source and destination paths`() async throws { - let fs = InMemoryFilesystem() - try await fs.createDirectory(path: "/docs", recursive: true) - try await fs.createDirectory(path: "/archive", recursive: true) - try await fs.writeFile(path: "/docs/file.txt", data: Data("hello".utf8), append: false) - let state = Workspace(filesystem: fs) - - let docsRecorder = ChangeEventRecorder() - let archiveRecorder = ChangeEventRecorder() - let docsTask = startRecording(await state.watchChanges(at: "/docs"), into: docsRecorder) - let archiveTask = startRecording(await state.watchChanges(at: "/archive"), into: archiveRecorder) - defer { - docsTask.cancel() - archiveTask.cancel() - } - - try await state.moveItem(from: "/docs/file.txt", to: "/archive/file.txt") - - let expected = ChangeEvent( - kind: .moved, - path: "/archive/file.txt", - sourcePath: "/docs/file.txt", - nodeKind: .file - ) - let docsEvents = try await waitForRecordedEvents(1, recorder: docsRecorder) - let archiveEvents = try await waitForRecordedEvents(1, recorder: archiveRecorder) - #expect(docsEvents == [expected]) - #expect(archiveEvents == [expected]) - } - - @Test(.tags(.edits, .watching)) - func `applyEdits rollback emits no watch events`() async throws { - let state = Workspace( - filesystem: FailOnceFilesystem( - base: InMemoryFilesystem(), - failingWritePaths: ["/b.txt"] - ) - ) - let recorder = ChangeEventRecorder() - let stream = await state.watchChanges(at: "/") - let task = startRecording(stream, into: recorder) - defer { task.cancel() } - - let result = try await state.applyEdits( - [ - .writeFile(path: "/a.txt", content: "one"), - .writeFile(path: "/b.txt", content: "two"), - ], - failurePolicy: .rollback - ) - - #expect(result.rolledBack) - let events = try await waitForSettledEvents(recorder: recorder) - #expect(events.isEmpty) - } - - @Test(.tags(.edits, .watching)) - func `applyEdits fail-fast emits only persisted watch events`() async throws { - let state = Workspace( - filesystem: FailOnceFilesystem( - base: InMemoryFilesystem(), - failingWritePaths: ["/b.txt"] - ) - ) - let recorder = ChangeEventRecorder() - let stream = await state.watchChanges(at: "/") - let task = startRecording(stream, into: recorder) - defer { task.cancel() } - - let result = try await state.applyEdits( - [ - .writeFile(path: "/a.txt", content: "one"), - .writeFile(path: "/b.txt", content: "two"), - .writeFile(path: "/c.txt", content: "three"), - ], - failurePolicy: .failFast - ) - - #expect(!result.rolledBack) - let events = try await waitForRecordedEvents(1, recorder: recorder) - #expect(events == [ChangeEvent(kind: .created, path: "/a.txt", nodeKind: .file)]) - let settled = try await waitForSettledEvents(recorder: recorder) - #expect(settled == events) - } - - @Test(.tags(.edits, .watching)) - func `applyEdits best-effort emits only applied watch events`() async throws { - let state = Workspace( - filesystem: FailOnceFilesystem( - base: InMemoryFilesystem(), - failingWritePaths: ["/b.txt"] - ) - ) - let recorder = ChangeEventRecorder() - let stream = await state.watchChanges(at: "/") - let task = startRecording(stream, into: recorder) - defer { task.cancel() } - - let result = try await state.applyEdits( - [ - .writeFile(path: "/a.txt", content: "one"), - .writeFile(path: "/b.txt", content: "two"), - .writeFile(path: "/c.txt", content: "three"), - ], - failurePolicy: .bestEffort - ) - - #expect(!result.rolledBack) - let events = try await waitForRecordedEvents(2, recorder: recorder) - #expect( - events == [ - ChangeEvent(kind: .created, path: "/a.txt", nodeKind: .file), - ChangeEvent(kind: .created, path: "/c.txt", nodeKind: .file), - ] - ) - } - - @Test(.tags(.replacement, .watching)) - func `applyReplacement emits only persisted watch events`() async throws { - let base = InMemoryFilesystem() - try await base.createDirectory(path: "/src", recursive: true) - try await base.writeFile(path: "/src/a.txt", data: Data("foo".utf8), append: false) - try await base.writeFile(path: "/src/b.txt", data: Data("foo".utf8), append: false) - let state = Workspace( - filesystem: FailOnceFilesystem( - base: base, - failingWritePaths: ["/src/b.txt"] - ) - ) - let recorder = ChangeEventRecorder() - let stream = await state.watchChanges(at: "/src") - let task = startRecording(stream, into: recorder) - defer { task.cancel() } - - let result = try await state.applyReplacement( - ReplacementRequest(pattern: "/src/*.txt", search: "foo", replacement: "bar"), - failurePolicy: .bestEffort - ) - - #expect(!result.rolledBack) - let events = try await waitForRecordedEvents(1, recorder: recorder) - #expect(events == [ChangeEvent(kind: .modified, path: "/src/a.txt", nodeKind: .file)]) - let settled = try await waitForSettledEvents(recorder: recorder) - #expect(settled == events) - } - - @Test(.tags(.watching)) - func `watchChanges behaves consistently for in-memory overlay and mounted workspaces`() async throws { - try await assertBasicWatchSemantics( - workspace: Workspace(filesystem: InMemoryFilesystem()), - watchedPath: "/note.txt" - ) - - let overlayRoot = try CoreTestSupport.makeTempDirectory(prefix: "OverlayWatch") - defer { CoreTestSupport.removeDirectory(overlayRoot) } - let overlayWorkspace = Workspace(filesystem: try await OverlayFilesystem(rootDirectory: overlayRoot)) - try await assertBasicWatchSemantics( - workspace: overlayWorkspace, - watchedPath: "/overlay.txt" - ) - - let mountedWorkspace = Workspace( - filesystem: MountableFilesystem( - base: InMemoryFilesystem(), - mounts: [ - .init(mountPoint: "/mounted", filesystem: InMemoryFilesystem()), - ] - ) - ) - try await assertBasicWatchSemantics( - workspace: mountedWorkspace, - watchedPath: "/mounted/note.txt" - ) - } - - @Test(.tags(.watching)) - func `watchChanges unregisters cancelled streams`() async throws { - let state = Workspace(filesystem: InMemoryFilesystem()) - let recorder = ChangeEventRecorder() - - do { - let stream = await state.watchChanges(at: "/note.txt", recursive: false) - let task = startRecording(stream, into: recorder) - - try await state.writeFile("/note.txt", content: "one") - _ = try await waitForRecordedEvents(1, recorder: recorder) - - task.cancel() - } - - try await Task.sleep(for: .milliseconds(50)) - try await state.writeFile("/note.txt", content: "two") - - let settled = try await waitForSettledEvents(recorder: recorder) - #expect(settled == [ChangeEvent(kind: .created, path: "/note.txt", nodeKind: .file)]) - } - - @Test - func `nested Codable mutation metadata roundtrips`() throws { - let original = MutationMode.execution - let data = try JSONEncoder().encode(original) - let decoded = try JSONDecoder().decode(MutationMode.self, from: data) - #expect(decoded == original) - } - - @Test(.tags(.replacement)) - func `replacement request and result roundtrip through Codable`() throws { - let request = ReplacementRequest( - scope: "/Sources", - include: ["**/*.swift"], - exclude: ["**/*Tests.swift"], - search: .literal("workspace", caseSensitive: false), - replacement: "Workspace" - ) - let requestData = try JSONEncoder().encode(request) - let decodedRequest = try JSONDecoder().decode(ReplacementRequest.self, from: requestData) - #expect(decodedRequest == request) - - let diff = TextDiff( - hunks: [ - .init( - oldStartLine: 1, - oldLineCount: 1, - newStartLine: 1, - newLineCount: 1, - lines: [ - .init( - kind: .added, - text: "Workspace", - hasTrailingNewline: true, - oldLineNumber: nil, - newLineNumber: 1 - ) - ] - ) - ] - ) - let result = ReplacementResult( - mode: .preview, - touchedPaths: ["/Sources/Workspace.swift"], - changes: [ - .init( - path: "/Sources/Workspace.swift", - replacements: 1, - status: .planned, - diff: diff - ) - ], - failures: [.init(path: "/Sources/Broken.swift", message: "decode failed")], - rolledBack: false - ) - - let resultData = try JSONEncoder().encode(result) - let decodedResult = try JSONDecoder().decode(ReplacementResult.self, from: resultData) - #expect(decodedResult.mode == result.mode) - #expect(decodedResult.touchedPaths == result.touchedPaths) - #expect(decodedResult.changes.count == 1) - #expect(decodedResult.changes[0].path == result.changes[0].path) - #expect(decodedResult.changes[0].replacements == result.changes[0].replacements) - #expect(decodedResult.changes[0].status == result.changes[0].status) - #expect(decodedResult.changes[0].diff == result.changes[0].diff) - #expect(decodedResult.failures.count == 1) - #expect(decodedResult.failures[0].path == result.failures[0].path) - #expect(decodedResult.failures[0].message == result.failures[0].message) - #expect(decodedResult.rolledBack == result.rolledBack) - } - - @Test - func `default filesystem extensions throw unsupported advanced operations`() async throws { - let filesystem = MinimalFilesystem() - try await filesystem.writeFile(path: "/note.txt", data: Data("hello".utf8), append: false) - - do { - try await filesystem.configure(rootDirectory: URL(fileURLWithPath: "/ignored")) - Issue.record("expected default configure error") - } catch let error as WorkspaceError { - #expect(error.description.contains("not configured")) - } - - do { - try await filesystem.createSymlink(path: "/alias.txt", target: "note.txt") - Issue.record("expected unsupported createSymlink error") - } catch let error as WorkspaceError { - #expect(error.description.contains("symbolic links are not supported")) - } - - do { - try await filesystem.createHardLink(path: "/hard.txt", target: "/note.txt") - Issue.record("expected unsupported createHardLink error") - } catch let error as WorkspaceError { - #expect(error.description.contains("hard links are not supported")) - } - - do { - _ = try await filesystem.readSymlink(path: "/note.txt") - Issue.record("expected unsupported readSymlink error") - } catch let error as WorkspaceError { - #expect(error.description.contains("symbolic links are not supported")) - } - - do { - try await filesystem.setPermissions(path: "/note.txt", permissions: .defaultFile) - Issue.record("expected unsupported setPermissions error") - } catch let error as WorkspaceError { - #expect(error.description.contains("setting permissions is not supported")) - } - - do { - _ = try await filesystem.resolveRealPath(path: "/note.txt") - Issue.record("expected unsupported resolveRealPath error") - } catch let error as WorkspaceError { - #expect(error.description.contains("real path resolution is not supported")) - } - } - - @Test - func `readJSON rejects invalid JSON`() async throws { - let fs = InMemoryFilesystem() - try await fs.writeFile(path: "/broken.json", data: Data("{ nope".utf8), append: false) - let state = Workspace(filesystem: fs) - - do { - _ = try await state.readJSON(DemoConfig.self, from: "/broken.json") - Issue.record("expected invalid JSON error") - } catch let error as WorkspaceError { - #expect(error.description.contains("invalid JSON")) - } - } - - @Test(.tags(.tree)) - func `walkTree and summarizeTree preserve stable ordering`() async throws { - let fs = InMemoryFilesystem() - try await fs.createDirectory(path: "/src", recursive: true) - try await fs.writeFile(path: "/src/b.txt", data: Data("b".utf8), append: false) - try await fs.writeFile(path: "/src/a.txt", data: Data("aa".utf8), append: false) - try await fs.createDirectory(path: "/src/nested", recursive: true) - let state = Workspace(filesystem: fs) - - let tree = try await state.walkTree("/src", maxDepth: 1) - #expect(tree.children?.map(\.path) == ["/src/a.txt", "/src/b.txt", "/src/nested"]) - - let summary = try await state.summarizeTree("/src", maxDepth: 1) - #expect(summary.children.map(\.path) == ["/src/a.txt", "/src/b.txt", "/src/nested"]) - #expect(summary.fileCount == 2) - #expect(summary.directoryCount == 2) - } - - @Test(.tags(.tree)) - func `walkTree and summarizeTree report symlink kinds`() async throws { - let fs = InMemoryFilesystem() - try await fs.writeFile(path: "/note.txt", data: Data("hello".utf8), append: false) - try await fs.createSymlink(path: "/alias.txt", target: "note.txt") - let state = Workspace(filesystem: fs) - - let tree = try await state.walkTree("/") - let alias = try #require(tree.children?.first(where: { $0.path == "/alias.txt" })) - #expect(alias.kind == .symlink) - - let summary = try await state.summarizeTree("/") - let aliasSummary = try #require(summary.children.first(where: { $0.path == "/alias.txt" })) - #expect(aliasSummary.kind == .symlink) - #expect(summary.symlinkCount == 1) - } - - @Test(.tags(.replacement)) - func `previewReplacement previews without mutating files`() async throws { - let fs = InMemoryFilesystem() - try await fs.createDirectory(path: "/src", recursive: true) - try await fs.writeFile(path: "/src/a.txt", data: Data("foo".utf8), append: false) - try await fs.writeFile(path: "/src/b.txt", data: Data("foo bar".utf8), append: false) - let state = Workspace(filesystem: fs) - - let result = try await state.previewReplacement( - ReplacementRequest(pattern: "/src/*.txt", search: "foo", replacement: "baz") - ) - #expect(result.mode == .preview) - #expect(result.touchedPaths == ["/src/a.txt", "/src/b.txt"]) - #expect(result.changes.map(\.status) == [.planned, .planned]) - #expect(result.changes.map(\.replacements) == [1, 1]) - #expect(result.changes.map { addedLineTexts($0.diff) } == [["baz"], ["baz bar"]]) - #expect(try await state.readFile("/src/a.txt") == "foo") - } - - @Test(.tags(.replacement)) - func `applyReplacement rolls back on write failure`() async throws { - let base = InMemoryFilesystem() - try await base.createDirectory(path: "/src", recursive: true) - try await base.writeFile(path: "/src/a.txt", data: Data("foo".utf8), append: false) - try await base.writeFile(path: "/src/b.txt", data: Data("foo".utf8), append: false) - - let state = Workspace( - filesystem: FailOnceFilesystem(base: base, failingWritePaths: ["/src/b.txt"]) - ) - - let result = try await state.applyReplacement( - ReplacementRequest(pattern: "/src/*.txt", search: "foo", replacement: "bar"), - failurePolicy: .rollback - ) - #expect(result.rolledBack) - #expect(result.failures.count == 1) - #expect(result.failures.first?.path == "/src/b.txt") - #expect(result.changes.map(\.status) == [.rolledBack, .failed]) - #expect(try await base.readFile(path: "/src/a.txt") == Data("foo".utf8)) - #expect(try await base.readFile(path: "/src/b.txt") == Data("foo".utf8)) - } - - @Test(.tags(.replacement)) - func `applyReplacement best effort reports failures without rollback`() async throws { - let base = InMemoryFilesystem() - try await base.createDirectory(path: "/src", recursive: true) - try await base.writeFile(path: "/src/a.txt", data: Data("foo".utf8), append: false) - try await base.writeFile(path: "/src/b.txt", data: Data("foo".utf8), append: false) - - let state = Workspace( - filesystem: FailOnceFilesystem(base: base, failingWritePaths: ["/src/b.txt"]) - ) - - let result = try await state.applyReplacement( - ReplacementRequest(pattern: "/src/*.txt", search: .regularExpression("f.o"), replacement: "bar"), - failurePolicy: .bestEffort - ) - - #expect(!result.rolledBack) - #expect(result.failures.count == 1) - #expect(result.failures.first?.path == "/src/b.txt") - #expect(result.changes.map(\.status) == [.applied, .failed]) - #expect(try await base.readFile(path: "/src/a.txt") == Data("bar".utf8)) - #expect(try await base.readFile(path: "/src/b.txt") == Data("foo".utf8)) - } - - @Test(.tags(.replacement)) - func `applyReplacement fail-fast stops after the first failure`() async throws { - let base = InMemoryFilesystem() - try await base.createDirectory(path: "/src", recursive: true) - try await base.writeFile(path: "/src/a.txt", data: Data("foo".utf8), append: false) - try await base.writeFile(path: "/src/b.txt", data: Data("foo".utf8), append: false) - try await base.writeFile(path: "/src/c.txt", data: Data("foo".utf8), append: false) - - let state = Workspace( - filesystem: NSErrorFailOnceFilesystem(base: base, failingWritePaths: ["/src/b.txt"]) - ) - - let result = try await state.applyReplacement( - ReplacementRequest(pattern: "/src/*.txt", search: "foo", replacement: "bar"), - failurePolicy: .failFast - ) - - #expect(!result.rolledBack) - #expect(result.failures.count == 1) - #expect(result.failures.first?.path == "/src/b.txt") - #expect(result.failures.first?.message.contains("CoreTests") == true) - #expect(result.changes.map(\.status) == [.applied, .failed, .skipped]) - #expect(try await base.readFile(path: "/src/a.txt") == Data("bar".utf8)) - #expect(try await base.readFile(path: "/src/b.txt") == Data("foo".utf8)) - #expect(try await base.readFile(path: "/src/c.txt") == Data("foo".utf8)) - } - - @Test(.tags(.replacement)) - func `applyReplacement returns an empty execution result when nothing matches`() async throws { - let state = Workspace(filesystem: InMemoryFilesystem()) - - let result = try await state.applyReplacement( - ReplacementRequest(pattern: "/missing/*.txt", search: "foo", replacement: "bar") - ) - - #expect(result.mode == .execution) - #expect(result.touchedPaths.isEmpty) - #expect(result.changes.isEmpty) - #expect(result.failures.isEmpty) - #expect(!result.rolledBack) - } - - @Test(.tags(.replacement)) - func `previewReplacement respects scope excludes and case-insensitive matching`() async throws { - let fs = InMemoryFilesystem() - try await fs.createDirectory(path: "/src/dir", recursive: true) - try await fs.writeFile(path: "/src/keep.txt", data: Data("FoO".utf8), append: false) - try await fs.writeFile(path: "/src/skip.txt", data: Data("foo".utf8), append: false) - let state = Workspace(filesystem: fs) - - let result = try await state.previewReplacement( - ReplacementRequest( - scope: "/src", - include: ["*.txt", "dir"], - exclude: ["skip.txt"], - search: .literal("foo", caseSensitive: false), - replacement: "bar" - ) - ) - - #expect(result.touchedPaths == ["/src/keep.txt"]) - #expect(result.changes.count == 1) - #expect(result.changes.first?.status == .planned) - #expect(removedLineTexts(result.changes.first?.diff) == ["FoO"]) - #expect(addedLineTexts(result.changes.first?.diff) == ["bar"]) - } - - @Test(.tags(.replacement)) - func `previewReplacement rejects invalid UTF-8 and empty search patterns`() async throws { - let fs = InMemoryFilesystem() - try await fs.writeFile(path: "/binary.bin", data: Data([0xFF]), append: false) - try await fs.writeFile(path: "/note.txt", data: Data("hello".utf8), append: false) - let state = Workspace(filesystem: fs) - - do { - _ = try await state.previewReplacement( - ReplacementRequest( - include: ["/binary.bin"], - search: .literal("x"), - replacement: "y" - ) - ) - Issue.record("expected invalid UTF-8 error") - } catch let error as WorkspaceError { - #expect(error.description.contains("not valid UTF-8")) - } - - do { - _ = try await state.previewReplacement( - ReplacementRequest( - include: ["/note.txt"], - search: .literal("", caseSensitive: false), - replacement: "y" - ) - ) - Issue.record("expected empty literal search rejection") - } catch let error as WorkspaceError { - #expect(error.description.contains("must not be empty")) - } - - do { - _ = try await state.previewReplacement( - ReplacementRequest( - include: ["/note.txt"], - search: .regularExpression(""), - replacement: "y" - ) - ) - Issue.record("expected empty regex search rejection") - } catch let error as WorkspaceError { - #expect(error.description.contains("must not be empty")) - } - } - - @Test(.tags(.edits)) - func `applyEdits succeeds across multiple files`() async throws { - let fs = InMemoryFilesystem() - let state = Workspace(filesystem: fs) - - let result = try await state.applyEdits([ - .createDirectory(path: "/src"), - .writeFile(path: "/src/a.txt", content: "one"), - .appendFile(path: "/src/a.txt", content: " two"), - .copy(from: "/src/a.txt", to: "/src/b.txt"), - .move(from: "/src/b.txt", to: "/src/c.txt"), - ]) - - #expect(!result.rolledBack) - #expect(result.mode == .execution) - #expect(result.touchedPaths == ["/src"]) - #expect(result.edits.map(\.status) == [.applied, .applied, .applied, .applied, .applied]) - #expect(addedLineTexts(result.edits[1].fileChanges.first?.diff) == ["one"]) - #expect(addedLineTexts(result.edits[2].fileChanges.first?.diff) == ["one two"]) - #expect(result.edits[3].fileChanges.first?.sourcePath == "/src/a.txt") - #expect(result.edits[4].fileChanges.first?.sourcePath == "/src/b.txt") - #expect(try await state.readFile("/src/a.txt") == "one two") - #expect(try await state.readFile("/src/c.txt") == "one two") - } - - @Test(.tags(.edits)) - func `applyEdits rolls back on failure`() async throws { - let base = InMemoryFilesystem() - try await base.writeFile(path: "/a.txt", data: Data("old".utf8), append: false) - let state = Workspace( - filesystem: FailOnceFilesystem(base: base, failingWritePaths: ["/b.txt"]) - ) - - let result = try await state.applyEdits([ - .writeFile(path: "/a.txt", content: "new"), - .writeFile(path: "/b.txt", content: "blocked"), - ]) - - #expect(result.rolledBack) - #expect(result.failures.count == 1) - #expect(result.failures.first?.index == 1) - #expect(result.edits.map(\.status) == [.rolledBack, .failed]) - #expect(result.edits.first?.fileChanges.first?.status == .rolledBack) - #expect(try await base.readFile(path: "/a.txt") == Data("old".utf8)) - let bExists = await base.exists(path: "/b.txt") - #expect(!bExists) - } - - @Test(.tags(.edits)) - func `previewEdits reports change states and delete diffs`() async throws { - let fs = InMemoryFilesystem() - try await fs.createDirectory(path: "/dir", recursive: true) - try await fs.writeFile(path: "/same.txt", data: Data("same".utf8), append: false) - try await fs.writeFile(path: "/delete.txt", data: Data("bye".utf8), append: false) - let state = Workspace(filesystem: fs) - - let result = try await state.previewEdits([ - .writeFile(path: "/same.txt", content: "same"), - .delete(path: "/delete.txt"), - .delete(path: "/missing.txt"), - .createDirectory(path: "/dir"), - .move(from: "/same.txt", to: "/same.txt"), - ]) - - #expect(result.mode == .preview) - #expect(result.edits.map(\.status) == [.planned, .planned, .planned, .planned, .planned]) - #expect(result.edits.map(\.changeState) == [.unchanged, .changed, .unchanged, .unchanged, .unchanged]) - #expect(result.edits[0].fileChanges.count == 1) - #expect(result.edits[0].fileChanges[0].effect == .unchanged) - #expect(result.edits[1].fileChanges[0].effect == .deleted) - #expect(result.edits[0].fileChanges[0].diff?.hunks.isEmpty == true) - #expect(removedLineTexts(result.edits[1].fileChanges.first?.diff) == ["bye"]) - #expect(result.edits[2].fileChanges.isEmpty) - } - - @Test(.tags(.edits)) - func `applyEdits fail-fast keeps prior changes and reports non-workspace errors`() async throws { - let base = InMemoryFilesystem() - try await base.writeFile(path: "/old.txt", data: Data("gone".utf8), append: false) - let state = Workspace( - filesystem: NSErrorFailOnceFilesystem(base: base, failingWritePaths: ["/blocked.txt"]) - ) - - let result = try await state.applyEdits([ - .delete(path: "/old.txt"), - .writeFile(path: "/blocked.txt", content: "blocked"), - .writeFile(path: "/after.txt", content: "after"), - ], failurePolicy: .failFast) - - #expect(!result.rolledBack) - #expect(result.failures.count == 1) - #expect(result.failures.first?.index == 1) - #expect(result.failures.first?.message.contains("CoreTests") == true) - #expect(result.edits.map(\.status) == [.applied, .failed, .skipped]) - #expect(!(await base.exists(path: "/old.txt"))) - #expect(!(await base.exists(path: "/blocked.txt"))) - #expect(!(await base.exists(path: "/after.txt"))) - } - - @Test(.tags(.edits)) - func `applyEdits returns an empty execution result when given no edits`() async throws { - let state = Workspace(filesystem: InMemoryFilesystem()) - let result = try await state.applyEdits([]) - - #expect(result.mode == .execution) - #expect(result.touchedPaths.isEmpty) - #expect(result.edits.isEmpty) - #expect(result.failures.isEmpty) - #expect(!result.rolledBack) - } - - @Test(.tags(.edits)) - func `previewEdits plans sequential text diffs across earlier edits`() async throws { - let state = Workspace(filesystem: InMemoryFilesystem()) - - let result = try await state.previewEdits([ - .writeFile(path: "/note.txt", content: "one"), - .appendFile(path: "/note.txt", content: " two"), - ]) - - #expect(result.edits.map(\.status) == [.planned, .planned]) - #expect(addedLineTexts(result.edits[0].fileChanges.first?.diff) == ["one"]) - #expect(removedLineTexts(result.edits[1].fileChanges.first?.diff) == ["one"]) - #expect(addedLineTexts(result.edits[1].fileChanges.first?.diff) == ["one two"]) - } - - @Test(.tags(.edits)) - func `previewEdits expands recursive file changes and omits binary diffs`() async throws { - let fs = InMemoryFilesystem() - try await fs.createDirectory(path: "/src/nested", recursive: true) - try await fs.writeFile(path: "/src/a.txt", data: Data("alpha".utf8), append: false) - try await fs.writeFile(path: "/src/nested/b.bin", data: Data([0xFF]), append: false) - let state = Workspace(filesystem: fs) - - let copyPreview = try await state.previewEdits([ - .copy(from: "/src", to: "/dest") - ]) - let deletePreview = try await state.previewEdits([ - .delete(path: "/src") - ]) - - #expect(copyPreview.edits[0].fileChanges.map(\.path) == ["/dest/a.txt", "/dest/nested/b.bin"]) - #expect(copyPreview.edits[0].fileChanges.map(\.sourcePath) == ["/src/a.txt", "/src/nested/b.bin"]) - #expect(copyPreview.edits[0].fileChanges.allSatisfy { $0.diff == nil }) - #expect(deletePreview.edits[0].fileChanges.map(\.path) == ["/src/a.txt", "/src/nested/b.bin"]) - #expect(deletePreview.edits[0].fileChanges.first?.diff != nil) - #expect(deletePreview.edits[0].fileChanges.last?.diff == nil) - } - - @Test(.tags(.edits)) - func `previewEdits preserves trailing newline metadata in diffs`() async throws { - let state = Workspace(filesystem: InMemoryFilesystem()) - - let result = try await state.previewEdits([ - .writeFile(path: "/multi.txt", content: "a\nb\n"), - ]) - - let lines = diffLines(result.edits[0].fileChanges.first?.diff) - #expect(lines.map(\.text) == ["a", "b"]) - #expect(lines.allSatisfy { $0.hasTrailingNewline }) - } - - @Test - func `workspace path and type helpers cover normalization and coding`() throws { - #expect(WorkspacePath(normalizing: "", relativeTo: "/base") == "/base") - #expect(WorkspacePath(normalizing: "./child", relativeTo: "/base") == "/base/child") - #expect(WorkspacePath.basename("/") == "/") - #expect(WorkspacePath.dirname("/") == .root) - #expect(WorkspacePath.join("/base", "/override") == "/override") - #expect(WorkspacePath.globToRegex("file?.[ch]") == "^file[^/]\\.[ch]$") - #expect(WorkspacePath.globToRegex("file[") == "^file\\[$") - #expect(WorkspacePath.globToRegex("*.txt") == "^[^/]*\\.txt$") - #expect(WorkspacePath.globToRegex("**/*.txt") == "^.*\\/[^/]*\\.txt$") - #expect(WorkspacePath.globToRegex("[!ab]") == "^[^ab]$") - - let encoded = try JSONEncoder().encode(WorkspacePath(normalizing: "/tmp/../file.txt")) - #expect(try JSONDecoder().decode(WorkspacePath.self, from: encoded) == "/file.txt") - - let invalidPathError = WorkspaceError.invalidPath("/bad\u{0}path") - #expect(invalidPathError.description == "path contains null byte") - - let fileInfo = FileInfo( - path: "/tmp/../file.txt", - kind: .file, - size: 7, - permissions: POSIXPermissions(0o644), - modificationDate: nil - ) - #expect(fileInfo.path == "/file.txt") - } - - @Test(.tags(.edits)) - func `applyEdits works with overlay and mountable filesystems`() async throws { - let workspaceRoot = try CoreTestSupport.makeTempDirectory(prefix: "MountRoot") - defer { CoreTestSupport.removeDirectory(workspaceRoot) } - - let docsRoot = try CoreTestSupport.makeTempDirectory(prefix: "DocsRoot") - defer { CoreTestSupport.removeDirectory(docsRoot) } - try Data("guide".utf8).write(to: docsRoot.appendingPathComponent("guide.txt")) - - let mountable = MountableFilesystem( - base: InMemoryFilesystem(), - mounts: [ - .init(mountPoint: "/workspace", filesystem: try await OverlayFilesystem(rootDirectory: workspaceRoot)), - .init(mountPoint: "/docs", filesystem: try await OverlayFilesystem(rootDirectory: docsRoot)), - ] - ) - - let state = Workspace(filesystem: mountable) - let result = try await state.applyEdits([ - .copy(from: "/docs/guide.txt", to: "/workspace/guide.txt"), - .writeFile(path: "/workspace/note.txt", content: "hello"), - ]) - - #expect(!result.rolledBack) - #expect(try await state.readFile("/workspace/guide.txt") == "guide") - #expect(try await state.readFile("/workspace/note.txt") == "hello") - #expect(!FileManager.default.fileExists(atPath: workspaceRoot.appendingPathComponent("guide.txt").path)) - } -} diff --git a/Tests/WorkspaceTests/FileSystemAPITests.swift b/Tests/WorkspaceTests/FileSystemAPITests.swift new file mode 100644 index 0000000..c89c55c --- /dev/null +++ b/Tests/WorkspaceTests/FileSystemAPITests.swift @@ -0,0 +1,135 @@ +import Foundation +import Testing +@testable import Workspace + +@Suite("File systems") +struct FileSystemAPITests { + @Test + func `root mounts route descendants while nested mounts win`() async throws { + let root = InMemoryFileSystem() + let nested = InMemoryFileSystem() + let mounted = MountedFileSystem( + base: InMemoryFileSystem(), + mounts: [ + .init(mountPoint: "/", fileSystem: root), + .init(mountPoint: "/nested", fileSystem: nested), + ] + ) + try await mounted.writeFile(path: "/root.txt", data: Data("root".utf8), append: false) + try await mounted.writeFile(path: "/nested/value.txt", data: Data("nested".utf8), append: false) + #expect(try await root.readFile(path: "/root.txt") == Data("root".utf8)) + #expect(try await nested.readFile(path: "/value.txt") == Data("nested".utf8)) + } + + @Test + func `generic overlay keeps mutations above its base`() async throws { + let base = InMemoryFileSystem() + try await base.writeFile(path: "/base.txt", data: Data("base".utf8), append: false) + let overlay = OverlayFileSystem(over: base) + try await overlay.writeFile(path: "/base.txt", data: Data("overlay".utf8), append: false) + try await overlay.writeFile(path: "/new.txt", data: Data("new".utf8), append: false) + #expect(try await overlay.readFile(path: "/base.txt") == Data("overlay".utf8)) + #expect(try await base.readFile(path: "/base.txt") == Data("base".utf8)) + #expect(!(await base.exists(path: "/new.txt"))) + try await overlay.discardChanges() + #expect(try await overlay.readFile(path: "/base.txt") == Data("base".utf8)) + } + + @Test + func `limits reject projected writes copies and entry growth`() async throws { + let base = InMemoryFileSystem() + let limited = LimitedFileSystem( + base: base, + limits: FileSystemLimits(maxTotalBytes: 5, maxEntryCount: 2, maxWriteBytes: 4) + ) + try await limited.writeFile(path: "/a", data: Data("1234".utf8), append: false) + do { + try await limited.writeFile(path: "/a", data: Data("12345".utf8), append: false) + Issue.record("expected per-write limit") + } catch let error as FileSystemLimitError { + #expect(error == .writeBytes(attempted: 5, limit: 4)) + } + do { + try await limited.copy(from: "/a", to: "/b", recursive: false) + Issue.record("expected total limit") + } catch let error as FileSystemLimitError { + #expect(error == .totalBytes(attempted: 8, limit: 5)) + } + #expect(!(await base.exists(path: "/b"))) + } + + @Test + func `authorization supports temporary approvals rules and bounded audit`() async throws { + let authorizer = PermissionAuthorizer(auditCapacity: 2) { _ in .allowFor(.milliseconds(2)) } + let request = PermissionRequest(operation: .readFile, path: "/a") + _ = await authorizer.authorize(request) + _ = await authorizer.authorize(request) + #expect(await authorizer.auditLog().map(\.source) == [.handler, .temporaryCache]) + try await Task.sleep(for: .milliseconds(5)) + _ = await authorizer.authorize(request) + #expect(await authorizer.auditLog().count == 2) + + let rules = RuleBasedPermissionAuthorizer( + rules: [.init(operations: [.readFile], pathPrefix: "/src", effect: .allow)] + ) + #expect((await rules.authorize(.init(operation: .readFile, path: "/src/a"))).isAllowed) + #expect(!(await rules.authorize(.init(operation: .readFile, path: "/src2/a"))).isAllowed) + } + + @Test + func `hard-link append shares content while replacement changes one path entry`() async throws { + let memory = InMemoryFileSystem() + try await assertHardLinkReplacement(on: memory) + + let root = try TestSupport.temporaryDirectory("HardLinks") + defer { TestSupport.remove(root) } + try await assertHardLinkReplacement(on: LocalFileSystem(root: root)) + } + + @Test + func `local filesystem blocks symlink escapes`() async throws { + let root = try TestSupport.temporaryDirectory("Root") + let outside = try TestSupport.temporaryDirectory("Outside") + defer { TestSupport.remove(root); TestSupport.remove(outside) } + let secret = outside.appendingPathComponent("secret") + try Data("secret".utf8).write(to: secret) + let local = try LocalFileSystem(root: root) + try await local.createSymlink(path: "/escape", target: secret.path) + do { + _ = try await local.readFile(path: "/escape") + Issue.record("expected jail rejection") + } catch let error as WorkspaceError { + #expect(error.description.contains("invalid path")) + } + #expect(try await local.readSymlink(path: "/escape") == secret.path) + } + + @Test + func `workspace paths have one dynamic initializer and clear components`() throws { + let path = try WorkspacePath("../src/./main.swift", relativeTo: "/project/tests") + #expect(path == "/project/src/main.swift") + #expect(path.name == "main.swift") + #expect(path.parent == "/project/src") + let invalid = "bad\u{0}path" + #expect(throws: WorkspaceError.self) { try WorkspacePath(invalid) } + } + + private func assertHardLinkReplacement(on fileSystem: any FileSystem) async throws { + try await fileSystem.writeFile(path: "/target", data: Data("a".utf8), append: false) + try await fileSystem.createHardLink(path: "/alias", target: "/target") + try await fileSystem.writeFile(path: "/target", data: Data("b".utf8), append: true) + #expect(try await fileSystem.readFile(path: "/alias") == Data("ab".utf8)) + try await fileSystem.writeFile(path: "/target", data: Data("new".utf8), append: false) + #expect(try await fileSystem.readFile(path: "/target") == Data("new".utf8)) + #expect(try await fileSystem.readFile(path: "/alias") == Data("ab".utf8)) + } +} + +private extension PermissionDecision { + var isAllowed: Bool { + switch self { + case .allow, .allowFor, .allowForSession: true + case .deny: false + } + } +} diff --git a/Tests/WorkspaceTests/FilesystemTests.swift b/Tests/WorkspaceTests/FilesystemTests.swift index 5d4e63b..6b850a1 100644 --- a/Tests/WorkspaceTests/FilesystemTests.swift +++ b/Tests/WorkspaceTests/FilesystemTests.swift @@ -61,7 +61,7 @@ extension Tag { struct FilesystemTests { @Test(.tags(.permissions)) func `permissioned filesystem normalizes paths and blocks denied writes`() async throws { - let base = InMemoryFilesystem() + let base = InMemoryFileSystem() let recorder = PermissionRecorder() let authorizer = PermissionAuthorizer { request in @@ -71,7 +71,7 @@ struct FilesystemTests { } return .allow } - let filesystem = PermissionedFileSystem(base: base, authorizer: authorizer) + let filesystem = AuthorizedFileSystem(base: base, authorizer: authorizer) do { try await filesystem.writeFile(path: "/tmp/../note.txt", data: Data("hello".utf8), append: false) @@ -89,7 +89,7 @@ struct FilesystemTests { @Test(.tags(.permissions)) func `permissioned filesystem caches allow-for-session decisions`() async throws { - let base = InMemoryFilesystem() + let base = InMemoryFileSystem() try await base.writeFile(path: "/doc.txt", data: Data("hello".utf8), append: false) let recorder = PermissionRecorder() @@ -97,7 +97,7 @@ struct FilesystemTests { await recorder.record(request) return .allowForSession } - let filesystem = PermissionedFileSystem(base: base, authorizer: authorizer) + let filesystem = AuthorizedFileSystem(base: base, authorizer: authorizer) _ = try await filesystem.readFile(path: "/doc.txt") _ = try await filesystem.readFile(path: "/doc.txt") @@ -109,13 +109,13 @@ struct FilesystemTests { @Test(.tags(.permissions)) func `permissioned mountable filesystem sees mounted virtual paths`() async throws { - let docs = InMemoryFilesystem() + let docs = InMemoryFileSystem() try await docs.writeFile(path: "/guide.txt", data: Data("guide".utf8), append: false) - let mountable = MountableFilesystem( - base: InMemoryFilesystem(), + let mountable = MountedFileSystem( + base: InMemoryFileSystem(), mounts: [ - MountableFilesystem.Mount(mountPoint: "/docs", filesystem: docs), + MountedFileSystem.Mount(mountPoint: "/docs", fileSystem: docs), ] ) @@ -124,7 +124,7 @@ struct FilesystemTests { await recorder.record(request) return .allow } - let filesystem = PermissionedFileSystem(base: mountable, authorizer: authorizer) + let filesystem = AuthorizedFileSystem(base: mountable, authorizer: authorizer) let data = try await filesystem.readFile(path: "/docs/guide.txt") #expect(String(decoding: data, as: UTF8.self) == "guide") @@ -145,7 +145,7 @@ struct FilesystemTests { let outsideFile = outside.appendingPathComponent("outside.txt") try Data("secret".utf8).write(to: outsideFile) - let filesystem = try ReadWriteFilesystem(rootDirectory: root) + let filesystem = try LocalFileSystem(rootDirectory: root) try await filesystem.createSymlink(path: "/leak", target: outsideFile.path) do { @@ -167,7 +167,7 @@ struct FilesystemTests { let outsideFile = outside.appendingPathComponent("target.txt") try Data("original".utf8).write(to: outsideFile) - let filesystem = try ReadWriteFilesystem(rootDirectory: root) + let filesystem = try LocalFileSystem(rootDirectory: root) try await filesystem.createSymlink(path: "/leak", target: outsideFile.path) do { @@ -198,7 +198,7 @@ struct FilesystemTests { let outsideFile = outside.appendingPathComponent("target.txt") try Data("secret".utf8).write(to: outsideFile) - let filesystem = try ReadWriteFilesystem(rootDirectory: root) + let filesystem = try LocalFileSystem(rootDirectory: root) try await filesystem.createSymlink(path: "/leak", target: outsideFile.path) // lstat semantics: the link itself is visible and manageable... @@ -230,7 +230,7 @@ struct FilesystemTests { let outside = try FilesystemTestSupport.makeTempDirectory(prefix: "FilesystemOutside") defer { FilesystemTestSupport.removeDirectory(outside) } - let filesystem = try ReadWriteFilesystem(rootDirectory: root) + let filesystem = try LocalFileSystem(rootDirectory: root) let missingTarget = outside.appendingPathComponent("missing.txt").path try await filesystem.createSymlink(path: "/dangling", target: missingTarget) @@ -248,7 +248,7 @@ struct FilesystemTests { let root = try FilesystemTestSupport.makeTempDirectory(prefix: "FilesystemRoot") defer { FilesystemTestSupport.removeDirectory(root) } - let filesystem = try ReadWriteFilesystem(rootDirectory: root) + let filesystem = try LocalFileSystem(rootDirectory: root) try await filesystem.createDirectory(path: "/real", recursive: true) try await filesystem.writeFile(path: "/real/keep.txt", data: Data("keep".utf8), append: false) try await filesystem.createSymlink(path: "/alias", target: "real") @@ -261,7 +261,7 @@ struct FilesystemTests { @Test(.tags(.inMemory)) func `glob wildcards stay within a single path component`() async throws { - let filesystem = InMemoryFilesystem() + let filesystem = InMemoryFileSystem() try await filesystem.createDirectory(path: "/docs/sub", recursive: true) try await filesystem.writeFile(path: "/docs/top.txt", data: FilesystemTestSupport.data("a"), append: false) try await filesystem.writeFile(path: "/docs/sub/deep.txt", data: FilesystemTestSupport.data("b"), append: false) @@ -278,7 +278,7 @@ struct FilesystemTests { let root = try FilesystemTestSupport.makeTempDirectory(prefix: "FilesystemRanged") defer { FilesystemTestSupport.removeDirectory(root) } - let filesystem = try ReadWriteFilesystem(rootDirectory: root) + let filesystem = try LocalFileSystem(rootDirectory: root) let payload = Data("0123456789".utf8) try await filesystem.writeFile(path: "/data.bin", data: payload, append: false) @@ -295,7 +295,7 @@ struct FilesystemTests { #expect(chunks.reduce(Data(), +) == payload) // The protocol's default implementation used by the in-memory backend agrees. - let memory = InMemoryFilesystem() + let memory = InMemoryFileSystem() try await memory.writeFile(path: "/data.bin", data: payload, append: false) #expect(try await memory.readFile(path: "/data.bin", offset: 3, length: 4) == Data("3456".utf8)) #expect(try await memory.readFile(path: "/data.bin", offset: 42, length: 1) == Data()) @@ -312,7 +312,7 @@ struct FilesystemTests { let root = try FilesystemTestSupport.makeTempDirectory(prefix: "FilesystemCreate") defer { FilesystemTestSupport.removeDirectory(root) } - let filesystem = try ReadWriteFilesystem(rootDirectory: root) + let filesystem = try LocalFileSystem(rootDirectory: root) try await filesystem.createFile(path: "/fresh/new.txt", data: Data("one".utf8)) #expect(try await filesystem.readFile(path: "/fresh/new.txt") == Data("one".utf8)) @@ -325,7 +325,7 @@ struct FilesystemTests { } #expect(try await filesystem.readFile(path: "/fresh/new.txt") == Data("one".utf8)) - let memory = InMemoryFilesystem() + let memory = InMemoryFileSystem() try await memory.createFile(path: "/new.txt", data: Data("one".utf8)) do { try await memory.createFile(path: "/new.txt", data: Data("two".utf8)) @@ -338,18 +338,18 @@ struct FilesystemTests { @Test(.tags(.permissions)) func `capabilities are advertised and forwarded through wrappers`() async throws { - let memory = InMemoryFilesystem() + let memory = InMemoryFileSystem() let memoryCapabilities = await memory.capabilities() #expect(memoryCapabilities.contains(.symlinks)) #expect(memoryCapabilities.contains(.permissions)) - let permissioned = PermissionedFileSystem( + let permissioned = AuthorizedFileSystem( base: memory, authorizer: PermissionAuthorizer { _ in .allow } ) #expect(await permissioned.capabilities() == memoryCapabilities) - let denied = PermissionedFileSystem( + let denied = AuthorizedFileSystem( base: memory, authorizer: PermissionAuthorizer { request in request.operation == .writeFile ? .deny(message: "no writes") : .allow @@ -365,7 +365,7 @@ struct FilesystemTests { @Test(.tags(.inMemory)) func `glob character classes support shell negation`() async throws { - let filesystem = InMemoryFilesystem() + let filesystem = InMemoryFileSystem() try await filesystem.writeFile(path: "/a.txt", data: FilesystemTestSupport.data("a"), append: false) try await filesystem.writeFile(path: "/b.txt", data: FilesystemTestSupport.data("b"), append: false) @@ -378,7 +378,7 @@ struct FilesystemTests { let root = try FilesystemTestSupport.makeTempDirectory(prefix: "FilesystemRoot") defer { FilesystemTestSupport.removeDirectory(root) } - let filesystem = try ReadWriteFilesystem(rootDirectory: root) + let filesystem = try LocalFileSystem(rootDirectory: root) try await filesystem.writeFile(path: "/real.txt", data: Data("one".utf8), append: false) try await filesystem.createSymlink(path: "/alias", target: "real.txt") @@ -390,7 +390,7 @@ struct FilesystemTests { @Test(.tags(.inMemory)) func `in-memory filesystem reset clears prior contents`() async throws { - let filesystem = InMemoryFilesystem() + let filesystem = InMemoryFileSystem() try await filesystem.writeFile(path: "/note.txt", data: Data("hello".utf8), append: false) #expect(await filesystem.exists(path: "/note.txt")) @@ -409,7 +409,7 @@ struct FilesystemTests { let fileURL = root.appendingPathComponent("note.txt") try Data("disk".utf8).write(to: fileURL) - let filesystem = try await OverlayFilesystem(rootDirectory: root) + let filesystem = try await OverlayFileSystem(rootDirectory: root) try await filesystem.writeFile(path: "/note.txt", data: Data("overlay".utf8), append: false) #expect(try await filesystem.readFile(path: "/note.txt") == Data("overlay".utf8)) @@ -421,7 +421,7 @@ struct FilesystemTests { @Test(.tags(.inMemory)) func `in-memory filesystem handles symlink writes copies moves and configure reset`() async throws { - let filesystem = InMemoryFilesystem() + let filesystem = InMemoryFileSystem() try await filesystem.writeFile(path: "/target.txt", data: Data("one".utf8), append: false) try await filesystem.createSymlink(path: "/link.txt", target: "target.txt") @@ -448,7 +448,7 @@ struct FilesystemTests { @Test(.tags(.inMemory)) func `in-memory filesystem reports POSIX errors for invalid operations`() async throws { - let filesystem = InMemoryFilesystem() + let filesystem = InMemoryFileSystem() try await filesystem.writeFile(path: "/file.txt", data: Data("data".utf8), append: false) try await filesystem.createDirectory(path: "/dir", recursive: true) try await filesystem.writeFile(path: "/dir/child.txt", data: Data("child".utf8), append: false) @@ -553,7 +553,7 @@ struct FilesystemTests { @Test(.tags(.inMemory)) func `in-memory filesystem detects symlink cycles when resolving paths`() async throws { - let filesystem = InMemoryFilesystem() + let filesystem = InMemoryFileSystem() try await filesystem.createSymlink(path: "/loop", target: "/loop") do { @@ -567,7 +567,7 @@ struct FilesystemTests { @Test(.tags(.inMemory)) func `in-memory filesystem move no-op when source and destination are identical`() async throws { - let filesystem = InMemoryFilesystem() + let filesystem = InMemoryFileSystem() try await filesystem.writeFile(path: "/a.txt", data: Data("x".utf8), append: false) try await filesystem.move(from: "/a.txt", to: "/a.txt") #expect(try await filesystem.readFile(path: "/a.txt") == Data("x".utf8)) @@ -575,14 +575,14 @@ struct FilesystemTests { @Test(.tags(.inMemory)) func `in-memory filesystem remove is silent when the path is missing`() async throws { - let filesystem = InMemoryFilesystem() + let filesystem = InMemoryFileSystem() try await filesystem.remove(path: "/missing.txt", recursive: false) #expect(!(await filesystem.exists(path: "/missing.txt"))) } @Test(.tags(.inMemory)) func `in-memory filesystem glob returns matching paths sorted`() async throws { - let filesystem = InMemoryFilesystem() + let filesystem = InMemoryFileSystem() try await filesystem.createDirectory(path: "/docs", recursive: true) try await filesystem.writeFile(path: "/docs/a.txt", data: Data(), append: false) try await filesystem.writeFile(path: "/docs/b.txt", data: Data(), append: false) @@ -594,7 +594,7 @@ struct FilesystemTests { @Test(.tags(.inMemory)) func `in-memory filesystem setPermissions updates stat results`() async throws { - let filesystem = InMemoryFilesystem() + let filesystem = InMemoryFileSystem() try await filesystem.writeFile(path: "/x.txt", data: Data("y".utf8), append: false) try await filesystem.setPermissions(path: "/x.txt", permissions: POSIXPermissions(0o600)) @@ -604,7 +604,7 @@ struct FilesystemTests { @Test(.tags(.inMemory)) func `in-memory filesystem stat reports symlink kind for the link path`() async throws { - let filesystem = InMemoryFilesystem() + let filesystem = InMemoryFileSystem() try await filesystem.writeFile(path: "/target.txt", data: Data("z".utf8), append: false) try await filesystem.createSymlink(path: "/link.txt", target: "target.txt") @@ -617,7 +617,7 @@ struct FilesystemTests { let root = try FilesystemTestSupport.makeTempDirectory(prefix: "ReadWriteRoot") defer { FilesystemTestSupport.removeDirectory(root) } - let filesystem = try ReadWriteFilesystem(rootDirectory: root) + let filesystem = try LocalFileSystem(rootDirectory: root) try await filesystem.createDirectory(path: "/docs", recursive: false) try await filesystem.writeFile(path: "/docs/note.txt", data: FilesystemTestSupport.data("hello"), append: false) @@ -676,21 +676,19 @@ struct FilesystemTests { @Test(.tags(.readWrite)) func `read-write filesystem reports configuration and directory operation errors`() async throws { - let unconfigured = ReadWriteFilesystem() + let unconfigured = LocalFileSystem() do { _ = try await unconfigured.stat(path: "/") Issue.record("expected unconfigured filesystem error") } catch let error as WorkspaceError { - #expect(error.description.contains("filesystem is not configured")) + #expect(error.description.contains("local filesystem requires a root")) } - #expect(!(await unconfigured.exists(path: "/\u{0}"))) - let root = try FilesystemTestSupport.makeTempDirectory(prefix: "ReadWriteErrors") defer { FilesystemTestSupport.removeDirectory(root) } - let filesystem = try ReadWriteFilesystem(rootDirectory: root) + let filesystem = try LocalFileSystem(rootDirectory: root) try await filesystem.createDirectory(path: "/dir", recursive: false) try await filesystem.writeFile(path: "/dir/file.txt", data: FilesystemTestSupport.data("x"), append: false) @@ -737,7 +735,7 @@ struct FilesystemTests { let symlinkURL = root.appendingPathComponent("alias.txt") try FileManager.default.createSymbolicLink(atPath: symlinkURL.path, withDestinationPath: "dir/file.txt") - let filesystem = try await OverlayFilesystem(rootDirectory: root) + let filesystem = try await OverlayFileSystem(rootDirectory: root) #expect((try await filesystem.listDirectory(path: "/")).map(\.name) == ["alias.txt", "dir"]) #expect((try await filesystem.listDirectory(path: "/dir")).map(\.name) == ["file.txt"]) @@ -770,7 +768,7 @@ struct FilesystemTests { let noteURL = root.appendingPathComponent("note.txt") try Data("disk".utf8).write(to: noteURL) - let filesystem = try await OverlayFilesystem(rootDirectory: root) + let filesystem = try await OverlayFileSystem(rootDirectory: root) #expect(try await filesystem.readFile(path: "/note.txt") == Data("disk".utf8)) // A file added on disk after configuration is visible without reload: reads are lazy. @@ -793,7 +791,7 @@ struct FilesystemTests { try Data("a".utf8).write(to: dirURL.appendingPathComponent("a.txt")) try Data("b".utf8).write(to: dirURL.appendingPathComponent("b.txt")) - let filesystem = try await OverlayFilesystem(rootDirectory: root) + let filesystem = try await OverlayFileSystem(rootDirectory: root) try await filesystem.remove(path: "/dir/a.txt", recursive: false) #expect(!(await filesystem.exists(path: "/dir/a.txt"))) @@ -821,7 +819,7 @@ struct FilesystemTests { try FileManager.default.createDirectory(at: subURL, withIntermediateDirectories: true) try Data("k".utf8).write(to: subURL.appendingPathComponent("keep.txt")) - let filesystem = try await OverlayFilesystem(rootDirectory: root) + let filesystem = try await OverlayFileSystem(rootDirectory: root) try await filesystem.writeFile(path: "/log.txt", data: Data(" two".utf8), append: true) #expect(try await filesystem.readFile(path: "/log.txt") == Data("one two".utf8)) @@ -849,7 +847,7 @@ struct FilesystemTests { ofItemAtPath: fileURL.path ) - let filesystem = try await OverlayFilesystem(rootDirectory: root) + let filesystem = try await OverlayFileSystem(rootDirectory: root) let info = try await filesystem.stat(path: "/old.txt") #expect(info.permissions == POSIXPermissions(0o640)) @@ -859,7 +857,7 @@ struct FilesystemTests { @Test(.tags(.overlay)) func `overlay filesystem reload requires a configured root and treats missing roots as empty`() async throws { - let unconfigured = OverlayFilesystem() + let unconfigured = OverlayFileSystem() do { try await unconfigured.reload() @@ -871,7 +869,7 @@ struct FilesystemTests { let root = try FilesystemTestSupport.makeTempDirectory(prefix: "OverlayMissingRoot") try FileManager.default.removeItem(at: root) - let filesystem = OverlayFilesystem() + let filesystem = OverlayFileSystem() try await filesystem.configure(rootDirectory: root) #expect(await filesystem.exists(path: "/")) @@ -880,13 +878,13 @@ struct FilesystemTests { @Test(.tags(.permissions)) func `permissioned filesystem forwards filesystem operations and normalized paths`() async throws { - let base = InMemoryFilesystem() + let base = InMemoryFileSystem() let recorder = PermissionRecorder() let authorizer = PermissionAuthorizer { request in await recorder.record(request) return .allow } - let filesystem = PermissionedFileSystem(base: base, authorizer: authorizer) + let filesystem = AuthorizedFileSystem(base: base, authorizer: authorizer) try await filesystem.writeFile(path: "/dir/../note.txt", data: FilesystemTestSupport.data("hello"), append: false) try await filesystem.createDirectory(path: "/links", recursive: true) @@ -934,12 +932,12 @@ struct FilesystemTests { } @Test(.tags(.permissions)) - func `permissioned filesystem forwards configuration and denied remove operations`() async throws { + func `authorized filesystem forwards operations and denies remove`() async throws { let root = try FilesystemTestSupport.makeTempDirectory(prefix: "PermissionedConfig") defer { FilesystemTestSupport.removeDirectory(root) } - let base = ReadWriteFilesystem() - let filesystem = PermissionedFileSystem( + let base = try LocalFileSystem(root: root) + let filesystem = AuthorizedFileSystem( base: base, authorizer: PermissionAuthorizer { request in if request.operation == .remove { @@ -949,7 +947,6 @@ struct FilesystemTests { } ) - try await filesystem.configure(rootDirectory: root) try await filesystem.writeFile(path: "/note.txt", data: FilesystemTestSupport.data("hello"), append: false) do { @@ -959,13 +956,12 @@ struct FilesystemTests { #expect(error.description.contains("workspace access denied: remove")) } - let deniedExists = await PermissionedFileSystem( + let deniedExists = await AuthorizedFileSystem( base: base, authorizer: PermissionAuthorizer { _ in .deny(message: "blocked") } ).exists(path: "/note.txt") #expect(!deniedExists) - #expect(!(await filesystem.exists(path: "/\u{0}"))) } @Test(.tags(.sandbox)) @@ -1040,10 +1036,10 @@ struct FilesystemTests { defer { FilesystemTestSupport.removeDirectory(secondRoot) } let filesystem = try SandboxFilesystem(root: .url(firstRoot)) - try await filesystem.configure(rootDirectory: secondRoot) try await filesystem.writeFile(path: "/configured.txt", data: FilesystemTestSupport.data("configured"), append: false) - #expect(FileManager.default.fileExists(atPath: secondRoot.appendingPathComponent("configured.txt").path)) + #expect(FileManager.default.fileExists(atPath: firstRoot.appendingPathComponent("configured.txt").path)) + #expect(!FileManager.default.fileExists(atPath: secondRoot.appendingPathComponent("configured.txt").path)) } @Test(.tags(.bookmarks)) diff --git a/Tests/WorkspaceTests/MountingTests.swift b/Tests/WorkspaceTests/MountingTests.swift index f6edf33..922d392 100644 --- a/Tests/WorkspaceTests/MountingTests.swift +++ b/Tests/WorkspaceTests/MountingTests.swift @@ -23,70 +23,67 @@ private enum MountingTestSupport { struct MountingTests { @Test func `multiple isolated mounts can share a memory workspace`() async throws { - let workspaceA = InMemoryFilesystem() + let workspaceA = InMemoryFileSystem() - let workspaceB = InMemoryFilesystem() + let workspaceB = InMemoryFileSystem() - let memory = InMemoryFilesystem() + let memory = InMemoryFileSystem() - let mountable = MountableFilesystem( - base: InMemoryFilesystem(), + let mountable = MountedFileSystem( + base: InMemoryFileSystem(), mounts: [ - .init(mountPoint: "/workspace-a", filesystem: workspaceA), - .init(mountPoint: "/workspace-b", filesystem: workspaceB), - .init(mountPoint: "/memory", filesystem: memory), + .init(mountPoint: "/workspace-a", fileSystem: workspaceA), + .init(mountPoint: "/workspace-b", fileSystem: workspaceB), + .init(mountPoint: "/memory", fileSystem: memory), ] ) - let workspace = Workspace(filesystem: mountable) - try await workspace.writeFile("/memory/shared.txt", content: "memo") - try await workspace.copyItem(from: "/memory/shared.txt", to: "/workspace-a/note.txt") + let workspace = Workspace(fileSystem: mountable) + try await workspace.writeText("/memory/shared.txt", "memo") + try await workspace.copy(from: "/memory/shared.txt", to: "/workspace-a/note.txt") - #expect(try await workspace.readFile("/memory/shared.txt") == "memo") - #expect(try await workspace.readFile("/workspace-a/note.txt") == "memo") + #expect(try await workspace.readText("/memory/shared.txt") == "memo") + #expect(try await workspace.readText("/workspace-a/note.txt") == "memo") #expect(!(await workspace.exists("/workspace-b/note.txt"))) } @Test - func `previewEdits previews mounted changes without mutating`() async throws { - let memory = InMemoryFilesystem() + func `preview shows mounted changes without mutating`() async throws { + let memory = InMemoryFileSystem() - let workspaceRoot = InMemoryFilesystem() + let workspaceRoot = InMemoryFileSystem() - let mountable = MountableFilesystem( - base: InMemoryFilesystem(), + let mountable = MountedFileSystem( + base: InMemoryFileSystem(), mounts: [ - .init(mountPoint: "/workspace", filesystem: workspaceRoot), - .init(mountPoint: "/memory", filesystem: memory), + .init(mountPoint: "/workspace", fileSystem: workspaceRoot), + .init(mountPoint: "/memory", fileSystem: memory), ] ) try await memory.writeFile(path: "/shared.txt", data: Data("memo".utf8), append: false) - let workspace = Workspace(filesystem: mountable) - let result = try await workspace.previewEdits( + let workspace = Workspace(fileSystem: mountable) + let result = try await workspace.preview( [ .copy(from: "/memory/shared.txt", to: "/workspace/shared.txt"), - .appendFile(path: "/memory/shared.txt", content: "!"), + .appendText("/memory/shared.txt", "!"), ] ) - #expect(result.mode == .preview) - #expect(result.edits.map(\.edit) == [ - .copy(from: "/memory/shared.txt", to: "/workspace/shared.txt"), - .appendFile(path: "/memory/shared.txt", content: "!") - ]) + #expect(result.touchedPaths.contains("/workspace/shared.txt")) + #expect(result.touchedPaths.contains("/memory/shared.txt")) #expect(!(await workspace.exists("/workspace/shared.txt"))) - #expect(try await workspace.readFile("/memory/shared.txt") == "memo") + #expect(try await workspace.readText("/memory/shared.txt") == "memo") } @Test - func `walkTree maxDepth stops recursion at nested directories`() async throws { - let filesystem = InMemoryFilesystem() + func `tree maxDepth stops recursion at nested directories`() async throws { + let filesystem = InMemoryFileSystem() try await filesystem.createDirectory(path: "/workspace/src/nested", recursive: true) try await filesystem.writeFile(path: "/workspace/src/nested/deep.txt", data: Data("deep".utf8), append: false) - let workspace = Workspace(filesystem: filesystem) - let tree = try await workspace.walkTree("/workspace", maxDepth: 1) + let workspace = Workspace(fileSystem: filesystem) + let tree = try await workspace.tree(at: "/workspace", maxDepth: 1) #expect(tree.children?.count == 1) let src = try #require(tree.children?.first) @@ -96,41 +93,41 @@ struct MountingTests { @Test func `copy and move operations work across mounted roots`() async throws { - let docs = InMemoryFilesystem() + let docs = InMemoryFileSystem() - let workspaceFiles = InMemoryFilesystem() + let workspaceFiles = InMemoryFileSystem() - let mountable = MountableFilesystem( - base: InMemoryFilesystem(), + let mountable = MountedFileSystem( + base: InMemoryFileSystem(), mounts: [ - .init(mountPoint: "/docs", filesystem: docs), - .init(mountPoint: "/workspace", filesystem: workspaceFiles), + .init(mountPoint: "/docs", fileSystem: docs), + .init(mountPoint: "/workspace", fileSystem: workspaceFiles), ] ) try await docs.writeFile(path: "/guide.txt", data: Data("guide".utf8), append: false) - let workspace = Workspace(filesystem: mountable) - try await workspace.copyItem(from: "/docs/guide.txt", to: "/workspace/guide.txt") - try await workspace.moveItem(from: "/workspace/guide.txt", to: "/workspace/guide-copy.txt") + let workspace = Workspace(fileSystem: mountable) + try await workspace.copy(from: "/docs/guide.txt", to: "/workspace/guide.txt") + try await workspace.move(from: "/workspace/guide.txt", to: "/workspace/guide-copy.txt") #expect(!(await workspace.exists("/workspace/guide.txt"))) - #expect(try await workspace.readFile("/workspace/guide-copy.txt") == "guide") - #expect(try await workspace.readFile("/docs/guide.txt") == "guide") + #expect(try await workspace.readText("/workspace/guide-copy.txt") == "guide") + #expect(try await workspace.readText("/docs/guide.txt") == "guide") } @Test func `mountable filesystem merges base entries with mounted directories`() async throws { - let base = InMemoryFilesystem() + let base = InMemoryFileSystem() try await base.createDirectory(path: "/docs", recursive: true) try await base.writeFile(path: "/docs/local.txt", data: MountingTestSupport.data("local"), append: false) try await base.writeFile(path: "/base.txt", data: MountingTestSupport.data("base"), append: false) - let mountedDocs = InMemoryFilesystem() + let mountedDocs = InMemoryFileSystem() try await mountedDocs.writeFile(path: "/guide.txt", data: MountingTestSupport.data("guide"), append: false) - let filesystem = MountableFilesystem( + let filesystem = MountedFileSystem( base: base, - mounts: [.init(mountPoint: "/docs/external", filesystem: mountedDocs)] + mounts: [.init(mountPoint: "/docs/external", fileSystem: mountedDocs)] ) let docsInfo = try await filesystem.stat(path: "/docs") @@ -177,17 +174,17 @@ struct MountingTests { @Test func `mountable filesystem supports directory copy and move across mounts`() async throws { - let source = InMemoryFilesystem() + let source = InMemoryFileSystem() try await source.createDirectory(path: "/tree/sub", recursive: true) try await source.writeFile(path: "/tree/sub/file.txt", data: MountingTestSupport.data("nested"), append: false) try await source.createSymlink(path: "/tree/link.txt", target: "sub/file.txt") - let destination = InMemoryFilesystem() - let filesystem = MountableFilesystem( - base: InMemoryFilesystem(), + let destination = InMemoryFileSystem() + let filesystem = MountedFileSystem( + base: InMemoryFileSystem(), mounts: [ - .init(mountPoint: "/src", filesystem: source), - .init(mountPoint: "/dst", filesystem: destination), + .init(mountPoint: "/src", fileSystem: source), + .init(mountPoint: "/dst", fileSystem: destination), ] ) @@ -216,16 +213,15 @@ struct MountingTests { } @Test - func `mountable filesystem supports dynamic mounts and configurable base storage`() async throws { + func `mounted filesystem supports dynamic mounts and constructor configured base storage`() async throws { let baseRoot = try MountingTestSupport.makeTempDirectory(prefix: "MountableBase") defer { MountingTestSupport.removeDirectory(baseRoot) } - let base = ReadWriteFilesystem() - let filesystem = MountableFilesystem(base: base) - try await filesystem.configure(rootDirectory: baseRoot) + let base = try LocalFileSystem(root: baseRoot) + let filesystem = MountedFileSystem(base: base) - let memory = InMemoryFilesystem() - filesystem.mount("/memory", filesystem: memory) + let memory = InMemoryFileSystem() + filesystem.mount("/memory", fileSystem: memory) try await filesystem.writeFile(path: "/root.txt", data: MountingTestSupport.data("root"), append: false) try await filesystem.writeFile(path: "/memory/note.txt", data: MountingTestSupport.data("memo"), append: false) @@ -236,16 +232,16 @@ struct MountingTests { @Test func `mountable filesystem prefers the longest matching mount prefix`() async throws { - let outer = InMemoryFilesystem() - let inner = InMemoryFilesystem() + let outer = InMemoryFileSystem() + let inner = InMemoryFileSystem() try await outer.writeFile(path: "/outer.txt", data: MountingTestSupport.data("outer"), append: false) try await inner.writeFile(path: "/inner.txt", data: MountingTestSupport.data("inner"), append: false) - let filesystem = MountableFilesystem( - base: InMemoryFilesystem(), + let filesystem = MountedFileSystem( + base: InMemoryFileSystem(), mounts: [ - .init(mountPoint: "/mnt", filesystem: outer), - .init(mountPoint: "/mnt/deep", filesystem: inner), + .init(mountPoint: "/mnt", fileSystem: outer), + .init(mountPoint: "/mnt/deep", fileSystem: inner), ] ) @@ -255,12 +251,12 @@ struct MountingTests { @Test func `mountable filesystem exposes synthetic parents for nested mount points`() async throws { - let nested = InMemoryFilesystem() + let nested = InMemoryFileSystem() try await nested.writeFile(path: "/leaf.txt", data: MountingTestSupport.data("leaf"), append: false) - let filesystem = MountableFilesystem( - base: InMemoryFilesystem(), - mounts: [.init(mountPoint: "/data/repo", filesystem: nested)] + let filesystem = MountedFileSystem( + base: InMemoryFileSystem(), + mounts: [.init(mountPoint: "/data/repo", fileSystem: nested)] ) #expect(await filesystem.exists(path: "/data")) @@ -281,15 +277,15 @@ struct MountingTests { @Test func `mountable filesystem glob aggregates paths from base and mounts`() async throws { - let mounted = InMemoryFilesystem() + let mounted = InMemoryFileSystem() try await mounted.writeFile(path: "/b.txt", data: MountingTestSupport.data("mb"), append: false) - let base = InMemoryFilesystem() + let base = InMemoryFileSystem() try await base.writeFile(path: "/a.txt", data: MountingTestSupport.data("ba"), append: false) - let filesystem = MountableFilesystem( + let filesystem = MountedFileSystem( base: base, - mounts: [.init(mountPoint: "/m", filesystem: mounted)] + mounts: [.init(mountPoint: "/m", fileSystem: mounted)] ) let matches = try await filesystem.glob(pattern: "/*.txt", currentDirectory: "/") diff --git a/Tests/WorkspaceTests/PersistenceTests.swift b/Tests/WorkspaceTests/PersistenceTests.swift new file mode 100644 index 0000000..94884bf --- /dev/null +++ b/Tests/WorkspaceTests/PersistenceTests.swift @@ -0,0 +1,90 @@ +import Foundation +import Testing +@testable import Workspace + +@Suite("Persistence") +struct PersistenceTests { + @Test + func `directory persistence reloads revisions and new mutation schema`() async throws { + let root = try TestSupport.temporaryDirectory("Persistence") + defer { TestSupport.remove(root) } + let id = UUID() + let first = Workspace(workspaceID: id, persistence: .directory(root)) + try await first.writeText("/note", "one") + let checkpoint = try await first.createCheckpoint(label: "saved") + + let second = Workspace(workspaceID: id, persistence: .directory(root)) + #expect(try await second.checkpoint(id: checkpoint.id) == checkpoint) + #expect(try await second.readText("/note", at: .checkpoint(checkpoint.id)) == "one") + #expect(try await second.history().count == 1) + #expect(FileManager.default.fileExists(atPath: root.appendingPathComponent(id.uuidString).appendingPathComponent("format.json").path)) + } + + @Test + func `revision diff does not load an unchanged blob`() async throws { + let root = try TestSupport.temporaryDirectory("LazyDiff") + defer { TestSupport.remove(root) } + let id = UUID() + let workspace = Workspace(workspaceID: id, persistence: .directory(root)) + _ = try await workspace.apply([ + .writeText("/stable", "stable"), + .writeText("/changed", "before"), + ]) + let before = try await workspace.createCheckpoint() + try await workspace.writeText("/changed", "after") + let after = try await workspace.createCheckpoint() + + let stableHash = SHA256.hexDigest(of: Data("stable".utf8)) + let stableBlob = root.appendingPathComponent(id.uuidString) + .appendingPathComponent("blobs") + .appendingPathComponent(stableHash) + try FileManager.default.removeItem(at: stableBlob) + + let diff = try await workspace.diff(from: .checkpoint(before.id), to: .checkpoint(after.id)) + #expect(diff.changes.map(\.path) == ["/changed"]) + await #expect(throws: WorkspaceError.self) { + _ = try await workspace.readData(from: "/stable", at: .checkpoint(before.id)) + } + } + + @Test + func `torn final JSONL record is ignored and repaired by the next append`() async throws { + let root = try TestSupport.temporaryDirectory("TornLog") + defer { TestSupport.remove(root) } + let id = UUID() + var workspace = Workspace(workspaceID: id, persistence: .directory(root)) + try await workspace.writeText("/a", "a") + let log = root.appendingPathComponent(id.uuidString).appendingPathComponent("mutations.jsonl") + let handle = try FileHandle(forWritingTo: log) + try handle.seekToEnd() + try handle.write(contentsOf: Data("{\"sequence\":".utf8)) + try handle.close() + + workspace = Workspace(workspaceID: id, persistence: .directory(root)) + #expect(try await workspace.history().count == 1) + try await workspace.writeText("/b", "b") + #expect(try await workspace.history().map(\.sequence) == [1, 2]) + } + + @Test + func `concurrent checkpoint forks retain both tips and select a deterministic parent`() async throws { + let root = try TestSupport.temporaryDirectory("Forks") + defer { TestSupport.remove(root) } + let id = UUID() + let a = Workspace(workspaceID: id, persistence: .directory(root)) + let b = Workspace(workspaceID: id, persistence: .directory(root)) + async let first = a.createCheckpoint(label: "a") + async let second = b.createCheckpoint(label: "b") + let forks = try await [first, second] + #expect(forks.count == 2) + + let reloaded = Workspace(workspaceID: id, persistence: .directory(root)) + let listed = try await reloaded.checkpoints() + #expect(listed.count == 2) + let expectedHead = listed.max { + $0.createdAt == $1.createdAt ? $0.id.uuidString < $1.id.uuidString : $0.createdAt < $1.createdAt + } + let next = try await reloaded.createCheckpoint(label: "next") + #expect(next.parentID == expectedHead?.id) + } +} diff --git a/Tests/WorkspaceTests/SnapshotTests.swift b/Tests/WorkspaceTests/SnapshotTests.swift index 8331c64..6cc1d57 100644 --- a/Tests/WorkspaceTests/SnapshotTests.swift +++ b/Tests/WorkspaceTests/SnapshotTests.swift @@ -2,62 +2,46 @@ import Foundation import Testing @testable import Workspace -@Suite("Snapshot") +@Suite("Snapshot engine") struct SnapshotTests { @Test func `capture and restore roundtrips tree contents symlinks and permissions`() async throws { - let source = InMemoryFilesystem() + let source = InMemoryFileSystem() try await source.createDirectory(path: "/docs/nested", recursive: true) try await source.writeFile(path: "/docs/nested/note.txt", data: Data("hello".utf8), append: false) try await source.createSymlink(path: "/docs/link.txt", target: "/docs/nested/note.txt") try await source.setPermissions(path: "/docs", permissions: POSIXPermissions(0o750)) - try await source.setPermissions(path: "/docs/nested", permissions: POSIXPermissions(0o700)) try await source.setPermissions(path: "/docs/nested/note.txt", permissions: POSIXPermissions(0o600)) - try await source.setPermissions(path: "/docs/link.txt", permissions: POSIXPermissions(0o777)) - - let snapshotId = UUID() - let snapshot = try await Snapshot.capture(from: source, snapshotId: snapshotId) - - let target = InMemoryFilesystem() - try await target.createDirectory(path: "/docs", recursive: true) - try await target.writeFile(path: "/docs/stale.txt", data: Data("stale".utf8), append: false) - try await target.writeFile(path: "/stale-root.txt", data: Data("stale".utf8), append: false) + let snapshotID = UUID() + let snapshot = try await Snapshot.capture(from: source, snapshotId: snapshotID) + let target = InMemoryFileSystem() + try await target.writeFile(path: "/stale.txt", data: Data("stale".utf8), append: false) try await Snapshot.restore(snapshot, to: target) - #expect(snapshot.id == snapshotId) - #expect(snapshot.rootPath == .root) + #expect(snapshot.id == snapshotID) + #expect(snapshot.rootPath == WorkspacePath.root) #expect(try await target.readFile(path: "/docs/nested/note.txt") == Data("hello".utf8)) #expect(try await target.readSymlink(path: "/docs/link.txt") == "/docs/nested/note.txt") #expect(try await target.stat(path: "/docs").permissions == POSIXPermissions(0o750)) - #expect(try await target.stat(path: "/docs/nested").permissions == POSIXPermissions(0o700)) #expect(try await target.stat(path: "/docs/nested/note.txt").permissions == POSIXPermissions(0o600)) - #expect(try await target.stat(path: "/docs/link.txt").permissions == POSIXPermissions(0o777)) - #expect(!(await target.exists(path: "/docs/stale.txt"))) - #expect(!(await target.exists(path: "/stale-root.txt"))) + #expect(!(await target.exists(path: "/stale.txt"))) } @Test - func `empty root and missing subtree snapshots restore expected absence`() async throws { - let source = InMemoryFilesystem() + func `empty root and missing subtree restore exact absence`() async throws { + let source = InMemoryFileSystem() let emptyRoot = try await Snapshot.capture(from: source) - let emptySummary = emptyRoot.summary(comparedTo: nil) - - let target = InMemoryFilesystem() + let target = InMemoryFileSystem() try await target.writeFile(path: "/stale.txt", data: Data("stale".utf8), append: false) try await Snapshot.restore(emptyRoot, to: target) - - #expect(emptySummary.changeCount == 0) - #expect(emptySummary.touchedPaths.isEmpty) - #expect(emptySummary.hasTextDiffs == false) #expect(!(await target.exists(path: "/stale.txt"))) let missing = try await Snapshot.capture(from: source, at: "/missing") - let missingTarget = InMemoryFilesystem() + let missingTarget = InMemoryFileSystem() try await missingTarget.createDirectory(path: "/missing", recursive: true) - try await missingTarget.writeFile(path: "/missing/file.txt", data: Data("remove me".utf8), append: false) - try await missingTarget.writeFile(path: "/kept.txt", data: Data("kept".utf8), append: false) - + try await missingTarget.writeFile(path: "/missing/file", data: Data("remove".utf8), append: false) + try await missingTarget.writeFile(path: "/kept", data: Data("kept".utf8), append: false) try await Snapshot.restore(missing, to: missingTarget) guard case let .missing(entry) = missing.entry else { @@ -66,111 +50,42 @@ struct SnapshotTests { } #expect(entry.path == "/missing") #expect(!(await missingTarget.exists(path: "/missing"))) - #expect(try await missingTarget.readFile(path: "/kept.txt") == Data("kept".utf8)) + #expect(try await missingTarget.readFile(path: "/kept") == Data("kept".utf8)) } @Test - func `summary reports stable changed paths and text diff availability`() async throws { - let base = try await summaryFilesystem(text: "old", binary: Data([0xFF, 0xFE])) - let baseSnapshot = try await Snapshot.capture(from: base) - - let unchanged = try await Snapshot.capture(from: try await summaryFilesystem(text: "old", binary: Data([0xFF, 0xFE]))) - let unchangedSummary = unchanged.summary(comparedTo: baseSnapshot) - - let textChanged = try await Snapshot.capture(from: try await summaryFilesystem(text: "new", binary: Data([0xFF, 0xFE]))) - let textSummary = textChanged.summary(comparedTo: baseSnapshot) - - let binaryChanged = try await Snapshot.capture(from: try await summaryFilesystem(text: "old", binary: Data([0x00, 0xFF]))) - let binarySummary = binaryChanged.summary(comparedTo: baseSnapshot) - - #expect(unchangedSummary.changeCount == 0) - #expect(unchangedSummary.touchedPaths.isEmpty) - #expect(unchangedSummary.hasTextDiffs == false) - - #expect(textSummary.changeCount == 1) - #expect(textSummary.touchedPaths == [WorkspacePath("/text.txt")]) - #expect(textSummary.hasTextDiffs == true) - - #expect(binarySummary.changeCount == 1) - #expect(binarySummary.touchedPaths == [WorkspacePath("/binary.dat")]) - #expect(binarySummary.hasTextDiffs == false) - } - - @Test - func `entry path accessor reflects each captured node kind`() async throws { - let filesystem = InMemoryFilesystem() - try await filesystem.createDirectory(path: "/dir", recursive: true) - try await filesystem.writeFile(path: "/dir/note.txt", data: Data("hi".utf8), append: false) - try await filesystem.createSymlink(path: "/dir/link", target: "/dir/note.txt") - - let snapshot = try await Snapshot.capture(from: filesystem) - guard case let .directory(rootDir) = snapshot.entry else { - Issue.record("expected directory root entry") + func `entry paths reflect every captured node kind`() async throws { + let fileSystem = InMemoryFileSystem() + try await fileSystem.createDirectory(path: "/dir", recursive: true) + try await fileSystem.writeFile(path: "/dir/note", data: Data("hi".utf8), append: false) + try await fileSystem.createSymlink(path: "/dir/link", target: "/dir/note") + + let snapshot = try await Snapshot.capture(from: fileSystem) + guard case let .directory(root) = snapshot.entry, + let child = root.children.first(where: { $0.path == "/dir" }), + case let .directory(directory) = child + else { + Issue.record("expected captured directory") return } - guard case let .directory(dir) = rootDir.children.first(where: { $0.path == "/dir" }) ?? .missing(.init(path: .root)) else { - Issue.record("expected /dir directory entry") - return - } - - #expect(snapshot.entry.path == .root) - #expect(rootDir.path == .root) - #expect(dir.children.map(\.path).sorted() == ["/dir/link", "/dir/note.txt"]) - - let missing = try await Snapshot.capture(from: filesystem, at: "/missing") - #expect(missing.entry.path == "/missing") - } - - @Test - func `Workspace capture and restore round trips through the public API`() async throws { - let workspace = Workspace(filesystem: InMemoryFilesystem()) - try await workspace.createDirectory(at: "/notes") - try await workspace.writeFile("/notes/a.txt", content: "alpha") - try await workspace.writeFile("/notes/b.txt", content: "beta") - try await workspace.createDirectory(at: "/empty") - - let snapshot = try await workspace.captureSnapshot() - - try await workspace.writeFile("/notes/a.txt", content: "alpha-updated") - try await workspace.removeItem(at: "/notes/b.txt") - try await workspace.writeFile("/notes/c.txt", content: "gamma") - - try await workspace.restoreSnapshot(snapshot) - - #expect(try await workspace.readFile("/notes/a.txt") == "alpha") - #expect(try await workspace.readFile("/notes/b.txt") == "beta") - #expect(!(await workspace.exists("/notes/c.txt"))) - #expect(await workspace.exists("/empty")) + #expect(snapshot.entry.path == WorkspacePath.root) + #expect(directory.children.map(\.path).sorted() == ["/dir/link", "/dir/note"]) } @Test - func `subtree snapshot summary excludes the captured root and Codable round-trips`() async throws { - let filesystem = InMemoryFilesystem() - try await filesystem.createDirectory(path: "/proj/src", recursive: true) - try await filesystem.writeFile(path: "/proj/src/main.swift", data: Data("print(\"hi\")".utf8), append: false) - try await filesystem.writeFile(path: "/proj/README.md", data: Data("docs".utf8), append: false) - - let baseSubtree = try await Snapshot.capture(from: filesystem, at: "/proj") - try await filesystem.writeFile(path: "/proj/src/main.swift", data: Data("print(\"updated\")".utf8), append: false) - let updatedSubtree = try await Snapshot.capture(from: filesystem, at: "/proj") - let summary = updatedSubtree.summary(comparedTo: baseSubtree) - - let encoded = try JSONEncoder().encode(updatedSubtree) - let decoded = try JSONDecoder().decode(Snapshot.self, from: encoded) - - #expect(summary.changeCount == 1) - #expect(summary.touchedPaths == [WorkspacePath("/proj/src/main.swift")]) - #expect(summary.hasTextDiffs == true) - #expect(decoded == updatedSubtree) - #expect(decoded.rootPath == "/proj") - } - - private func summaryFilesystem(text: String, binary: Data) async throws -> InMemoryFilesystem { - let filesystem = InMemoryFilesystem() - try await filesystem.writeFile(path: "/text.txt", data: Data(text.utf8), append: false) - try await filesystem.writeFile(path: "/binary.dat", data: binary, append: false) - try await filesystem.createDirectory(path: "/docs", recursive: true) - try await filesystem.createSymlink(path: "/link.txt", target: "/text.txt") - return filesystem + func `subtree snapshots produce canonical changes and Codable round trips`() async throws { + let fileSystem = InMemoryFileSystem() + try await fileSystem.createDirectory(path: "/project/src", recursive: true) + try await fileSystem.writeFile(path: "/project/src/main.swift", data: Data("old\n".utf8), append: false) + let before = try await Snapshot.capture(from: fileSystem, at: "/project") + + try await fileSystem.writeFile(path: "/project/src/main.swift", data: Data("new\n".utf8), append: false) + let after = try await Snapshot.capture(from: fileSystem, at: "/project") + let changes = ChangeSet.compare(before: before, after: after, maxTextBytes: 1_000_000) + let decoded = try JSONDecoder().decode(Snapshot.self, from: JSONEncoder().encode(after)) + + #expect(changes.touchedPaths == ["/project/src/main.swift"]) + #expect(changes.summary.hasTextChanges) + #expect(decoded == after) } } diff --git a/Tests/WorkspaceTests/TestSupport.swift b/Tests/WorkspaceTests/TestSupport.swift new file mode 100644 index 0000000..d13af3d --- /dev/null +++ b/Tests/WorkspaceTests/TestSupport.swift @@ -0,0 +1,14 @@ +import Foundation + +enum TestSupport { + static func temporaryDirectory(_ prefix: String = "WorkspaceTests") throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("\(prefix)-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + static func remove(_ url: URL) { + try? FileManager.default.removeItem(at: url) + } +} diff --git a/Tests/WorkspaceTests/TextDiffTests.swift b/Tests/WorkspaceTests/TextDiffTests.swift index 0e30fcb..0a67bd5 100644 --- a/Tests/WorkspaceTests/TextDiffTests.swift +++ b/Tests/WorkspaceTests/TextDiffTests.swift @@ -84,7 +84,7 @@ struct TextDiffTests { } @Test - func `lineBased matches Workspace previewReplacement diff for the same edit`() async throws { + func `lineBased matches Workspace replacement preview for the same edit`() async throws { let original = """ red green @@ -96,12 +96,16 @@ struct TextDiffTests { blue """ - let workspace = Workspace(filesystem: InMemoryFilesystem()) - try await workspace.writeFile("/colors.txt", content: original) + let workspace = Workspace(fileSystem: InMemoryFileSystem()) + try await workspace.writeText("/colors.txt", original) - let preview = try await workspace.previewReplacement( - ReplacementRequest(pattern: "/colors.txt", search: "green", replacement: "teal") - ) + let preview = try await workspace.preview([ + .replace( + files: FileSelection(include: ["/colors.txt"]), + pattern: .literal("green"), + with: "teal" + ) + ]) let workspaceDiff = try #require(preview.changes.first?.diff) let utilityDiff = TextDiff.lineBased(from: original, to: updated) diff --git a/Tests/WorkspaceTests/ThreeWayMergeTests.swift b/Tests/WorkspaceTests/ThreeWayMergeTests.swift deleted file mode 100644 index a0cb476..0000000 --- a/Tests/WorkspaceTests/ThreeWayMergeTests.swift +++ /dev/null @@ -1,144 +0,0 @@ -import Foundation -import Testing -@testable import Workspace - -@Suite("Three-way merge") -struct ThreeWayMergeTests { - @Test - func `non-overlapping changes on both sides merge cleanly`() async throws { - let workspace = Workspace(filesystem: InMemoryFilesystem()) - try await workspace.writeFile("/a.txt", content: "base a\n") - try await workspace.writeFile("/b.txt", content: "base b\n") - _ = try await workspace.createCheckpoint(label: "base") - - let branch = try await workspace.branch() - try await branch.writeFile("/b.txt", content: "branch b\n") - try await branch.writeFile("/new-branch.txt", content: "from branch\n") - - // Parent edits after branching, without a checkpoint — merge(_:) would refuse this. - try await workspace.writeFile("/a.txt", content: "parent a\n") - - let result = try await workspace.mergeThreeWay(branch, label: "merge branch") - - #expect(result.applied) - #expect(result.conflicts.isEmpty) - #expect(try await workspace.readFile("/a.txt") == "parent a\n") - #expect(try await workspace.readFile("/b.txt") == "branch b\n") - #expect(try await workspace.readFile("/new-branch.txt") == "from branch\n") - - let checkpoint = try #require(result.checkpoint) - #expect(checkpoint.mergedFromWorkspaceId == branch.workspaceId) - #expect((try await workspace.mutationRecords()).last?.kind == .mergeWorkspace) - } - - @Test - func `identical changes on both sides do not conflict`() async throws { - let workspace = Workspace(filesystem: InMemoryFilesystem()) - try await workspace.writeFile("/shared.txt", content: "base\n") - _ = try await workspace.createCheckpoint(label: "base") - - let branch = try await workspace.branch() - try await branch.writeFile("/shared.txt", content: "same change\n") - try await branch.writeFile("/dup.txt", content: "identical\n") - try await workspace.writeFile("/shared.txt", content: "same change\n") - try await workspace.writeFile("/dup.txt", content: "identical\n") - - let result = try await workspace.mergeThreeWay(branch) - - #expect(result.applied) - #expect(result.conflicts.isEmpty) - #expect(try await workspace.readFile("/shared.txt") == "same change\n") - } - - @Test - func `deletions on the branch propagate when ours is unchanged`() async throws { - let workspace = Workspace(filesystem: InMemoryFilesystem()) - try await workspace.createDirectory(at: "/doomed") - try await workspace.writeFile("/doomed/file.txt", content: "bye\n") - try await workspace.writeFile("/keep.txt", content: "keep\n") - _ = try await workspace.createCheckpoint(label: "base") - - let branch = try await workspace.branch() - try await branch.removeItem(at: "/doomed", recursive: true) - - let result = try await workspace.mergeThreeWay(branch) - - #expect(result.applied) - #expect(!(await workspace.exists("/doomed"))) - #expect(try await workspace.readFile("/keep.txt") == "keep\n") - } - - @Test - func `conflicting edits report bothModified with diffs and apply nothing`() async throws { - let workspace = Workspace(filesystem: InMemoryFilesystem()) - try await workspace.writeFile("/contested.txt", content: "base\n") - _ = try await workspace.createCheckpoint(label: "base") - let checkpointsBefore = try await workspace.listCheckpoints().count - - let branch = try await workspace.branch() - try await branch.writeFile("/contested.txt", content: "theirs\n") - try await workspace.writeFile("/contested.txt", content: "ours\n") - - let result = try await workspace.mergeThreeWay(branch) - - #expect(!result.applied) - #expect(result.checkpoint == nil) - #expect(result.conflicts.map(\.path) == ["/contested.txt"]) - #expect(result.conflicts.first?.kind == .bothModified) - #expect(result.conflicts.first?.oursDiff?.hunks.isEmpty == false) - #expect(result.conflicts.first?.theirsDiff?.hunks.isEmpty == false) - - // Nothing changed: ours content intact, no merge checkpoint or mutation appended. - #expect(try await workspace.readFile("/contested.txt") == "ours\n") - #expect(try await workspace.listCheckpoints().count == checkpointsBefore) - #expect((try await workspace.mutationRecords()).last?.kind != .mergeWorkspace) - } - - @Test - func `modify versus delete is reported as a conflict`() async throws { - let workspace = Workspace(filesystem: InMemoryFilesystem()) - try await workspace.writeFile("/contested.txt", content: "base\n") - _ = try await workspace.createCheckpoint(label: "base") - - let branch = try await workspace.branch() - try await branch.writeFile("/contested.txt", content: "modified\n") - try await workspace.removeItem(at: "/contested.txt") - - let result = try await workspace.mergeThreeWay(branch) - - #expect(!result.applied) - #expect(result.conflicts.map(\.kind) == [.modifiedAndDeleted]) - #expect(!(await workspace.exists("/contested.txt"))) - } - - @Test - func `divergent creations at the same path are reported as bothCreated`() async throws { - let workspace = Workspace(filesystem: InMemoryFilesystem()) - try await workspace.writeFile("/base.txt", content: "base\n") - _ = try await workspace.createCheckpoint(label: "base") - - let branch = try await workspace.branch() - try await branch.writeFile("/fresh.txt", content: "theirs\n") - try await workspace.writeFile("/fresh.txt", content: "ours\n") - - let result = try await workspace.mergeThreeWay(branch) - - #expect(!result.applied) - #expect(result.conflicts.map(\.kind) == [.bothCreated]) - #expect(try await workspace.readFile("/fresh.txt") == "ours\n") - } - - @Test - func `three-way merge requires a branch base checkpoint`() async throws { - let workspace = Workspace(filesystem: InMemoryFilesystem()) - try await workspace.writeFile("/a.txt", content: "no checkpoints yet") - let branch = try await workspace.branch() - - do { - _ = try await workspace.mergeThreeWay(branch) - Issue.record("expected unsupported error for a base-less branch") - } catch let error as WorkspaceError { - #expect(error.description.contains("three-way merge requires")) - } - } -} diff --git a/Tests/WorkspaceTests/TrackingPolicyTests.swift b/Tests/WorkspaceTests/TrackingPolicyTests.swift deleted file mode 100644 index da2b179..0000000 --- a/Tests/WorkspaceTests/TrackingPolicyTests.swift +++ /dev/null @@ -1,104 +0,0 @@ -import Foundation -import Testing -@testable import Workspace - -@Suite("Tracking policy") -struct TrackingPolicyTests { - @Test - func `full tracking exposes public mutation history with diffs`() async throws { - let workspace = Workspace(filesystem: InMemoryFilesystem()) - try await workspace.writeFile("/note.txt", content: "one\n") - try await workspace.writeFile("/note.txt", content: "two\n") - - let mutations = try await workspace.mutationRecords() - #expect(mutations.map(\.kind) == [.writeFile, .writeFile]) - #expect(mutations.map(\.sequence) == [1, 2]) - #expect(mutations[1].diff?.hunks.isEmpty == false) - #expect(mutations[1].touchedPaths == ["/note.txt"]) - } - - @Test - func `pathsOnly tracking records effects without diffs`() async throws { - let workspace = Workspace(filesystem: InMemoryFilesystem(), tracking: .pathsOnly) - try await workspace.writeFile("/note.txt", content: "one") - try await workspace.writeFile("/note.txt", content: "two") - try await workspace.removeItem(at: "/note.txt") - - let mutations = try await workspace.mutationRecords() - #expect(mutations.map(\.kind) == [.writeFile, .writeFile, .removeItem]) - #expect(mutations.allSatisfy { $0.diff == nil }) - #expect(mutations.allSatisfy { record in record.fileChanges.allSatisfy { $0.diff == nil } }) - #expect(mutations[1].fileChanges.first?.effect == .modified) - #expect(mutations[1].touchedPaths == ["/note.txt"]) - #expect(mutations[2].fileChanges.first?.effect == .deleted) - } - - @Test - func `pathsOnly tracking keeps replacement counts and change events`() async throws { - let workspace = Workspace(filesystem: InMemoryFilesystem(), tracking: .pathsOnly) - try await workspace.writeFile("/a.txt", content: "old old") - - let stream = await workspace.watchChanges(at: "/a.txt", recursive: false) - var iterator = stream.makeAsyncIterator() - - let result = try await workspace.applyReplacement( - ReplacementRequest(pattern: "/a.txt", search: "old", replacement: "new") - ) - - #expect(result.changes.first?.replacements == 2) - #expect(result.changes.first?.diff.hunks.isEmpty == true) - #expect(try await workspace.readFile("/a.txt") == "new new") - - let event = await iterator.next() - #expect(event?.kind == .modified) - #expect(event?.path == "/a.txt") - } - - @Test - func `disabled tracking records nothing but still emits change events`() async throws { - let workspace = Workspace(filesystem: InMemoryFilesystem(), tracking: .disabled) - - let stream = await workspace.watchChanges(at: "/", recursive: true) - var iterator = stream.makeAsyncIterator() - - try await workspace.writeFile("/note.txt", content: "one") - try await workspace.appendFile("/note.txt", content: " two") - try await workspace.writeData(Data([0xFF]), to: "/bin.dat") - - #expect(try await workspace.readFile("/note.txt") == "one two") - #expect(try await workspace.mutationRecords().isEmpty) - - let event = await iterator.next() - #expect(event?.kind == .created) - #expect(event?.path == "/note.txt") - } - - @Test - func `diff size cap suppresses diffs for large contents only`() async throws { - let workspace = Workspace( - filesystem: InMemoryFilesystem(), - tracking: .fullDiffs(maxDiffBytes: 16) - ) - try await workspace.writeFile("/small.txt", content: "tiny\n") - try await workspace.writeFile("/big.txt", content: String(repeating: "x", count: 64)) - - let mutations = try await workspace.mutationRecords() - #expect(mutations[0].fileChanges.first?.diff != nil) - #expect(mutations[1].fileChanges.first?.diff == nil) - #expect(mutations[1].fileChanges.first?.effect == .created) - } - - @Test - func `branches inherit the parent tracking policy`() async throws { - let workspace = Workspace(filesystem: InMemoryFilesystem(), tracking: .pathsOnly) - try await workspace.writeFile("/base.txt", content: "base") - _ = try await workspace.createCheckpoint(label: "base") - - let branch = try await workspace.branch() - #expect(branch.tracking == .pathsOnly) - - try await branch.writeFile("/base.txt", content: "branch") - let mutations = try await branch.mutationRecords() - #expect(mutations.allSatisfy { record in record.fileChanges.allSatisfy { $0.diff == nil } }) - } -} diff --git a/Tests/WorkspaceTests/TransactionTests.swift b/Tests/WorkspaceTests/TransactionTests.swift new file mode 100644 index 0000000..0bf8234 --- /dev/null +++ b/Tests/WorkspaceTests/TransactionTests.swift @@ -0,0 +1,94 @@ +import Foundation +import Testing +@testable import Workspace + +@Suite("Transactions") +struct TransactionTests { + @Test + func `transaction previews and commits as one parent mutation`() async throws { + let workspace = Workspace() + try await workspace.writeText("/a.txt", "base\n") + let transaction = try await workspace.beginTransaction(label: "draft") + try await transaction.writeText("/a.txt", "draft\n") + try await transaction.writeText("/b.txt", "new\n") + + let preview = try await transaction.preview() + #expect(preview.statistics.changedFileCount == 2) + #expect(try await workspace.readText("/a.txt") == "base\n") + + let commit = try await transaction.commit() + #expect(commit.applied) + #expect(commit.preview == preview) + #expect(commit.checkpoint?.origin == .transaction(base: nil)) + #expect(try await workspace.readText("/a.txt") == "draft\n") + #expect(try await workspace.history().last?.operation == .transaction) + } + + @Test + func `discard and no-op commit create no parent artifacts`() async throws { + let workspace = Workspace() + let discarded = try await workspace.beginTransaction() + try await discarded.writeText("/x", "x") + try await discarded.discard() + #expect(!(await workspace.exists("/x"))) + + let noOp = try await workspace.beginTransaction() + let commit = try await noOp.commit() + #expect(commit.applied) + #expect(commit.checkpoint == nil) + #expect(try await workspace.checkpoints().isEmpty) + } + + @Test + func `three-way commit merges non-overlapping parent changes`() async throws { + let workspace = Workspace() + _ = try await workspace.apply([.writeText("/a", "a0"), .writeText("/b", "b0")]) + let transaction = try await workspace.beginTransaction() + try await transaction.writeText("/a", "a1") + try await workspace.writeText("/b", "b1") + + let commit = try await transaction.commit(strategy: .threeWay) + #expect(commit.applied) + #expect(try await workspace.readText("/a") == "a1") + #expect(try await workspace.readText("/b") == "b1") + } + + @Test + func `conflicted transaction remains active for preview and discard`() async throws { + let workspace = Workspace() + try await workspace.writeText("/a", "base") + let transaction = try await workspace.beginTransaction() + try await transaction.writeText("/a", "draft") + try await workspace.writeText("/a", "parent") + + let commit = try await transaction.commit() + #expect(!commit.applied) + #expect(commit.conflicts.map(\.path) == ["/a"]) + #expect(try await transaction.preview().changes.first?.path == "/a") + try await transaction.discard() + #expect(try await workspace.readText("/a") == "parent") + } + + @Test + func `strict commit rejects any parent change`() async throws { + let workspace = Workspace() + _ = try await workspace.apply([.writeText("/a", "a"), .writeText("/b", "b")]) + let transaction = try await workspace.beginTransaction() + try await transaction.writeText("/a", "draft") + try await workspace.writeText("/b", "parent") + let commit = try await transaction.commit(strategy: .strict) + #expect(commit.conflicts.contains { $0.kind == .parentChanged && $0.path == "/b" }) + } + + @Test + func `scoped transaction returns value and commit`() async throws { + let workspace = Workspace() + let result = try await workspace.transaction { transaction in + try await transaction.writeText("/value", "42") + return 42 + } + #expect(result.value == 42) + #expect(result.commit.applied) + #expect(try await workspace.readText("/value") == "42") + } +} diff --git a/Tests/WorkspaceTests/WorkspaceAPITests.swift b/Tests/WorkspaceTests/WorkspaceAPITests.swift new file mode 100644 index 0000000..595a471 --- /dev/null +++ b/Tests/WorkspaceTests/WorkspaceAPITests.swift @@ -0,0 +1,136 @@ +import Foundation +import Testing +@testable import Workspace + +@Suite("Workspace API") +struct WorkspaceAPITests { + @Test + func `one changeset flows through preview apply events and history`() async throws { + let workspace = Workspace() + let preview = try await workspace.preview([ + .createDirectory("/docs"), + .writeText("/docs/note.txt", "hello\n"), + ]) + #expect(preview.touchedPaths.contains("/docs/note.txt")) + #expect(!(await workspace.exists("/docs/note.txt"))) + + let stream = await workspace.events(.init(includeCheckpoints: false)) + let nextEvent = Task { + for await event in stream { return event } + return nil + } + let result = try await workspace.apply([ + .createDirectory("/docs"), + .writeText("/docs/note.txt", "hello\n"), + ]) + #expect(result.failures.isEmpty) + #expect(try await workspace.readText("/docs/note.txt") == "hello\n") + #expect(await nextEvent.value == .changes(result.changes)) + + let history = try await workspace.history() + #expect(history.count == 1) + #expect(history[0].operation == .edit) + #expect(history[0].changes == result.changes) + } + + @Test + func `atomic failure leaves files events and history unchanged`() async throws { + let workspace = Workspace() + let stream = await workspace.events(.init(includeCheckpoints: false)) + do { + _ = try await workspace.apply([ + .writeText("/kept.txt", "temporary"), + .remove("/missing/child", recursive: false), + ]) + Issue.record("expected edit failure") + } catch {} + #expect(!(await workspace.exists("/kept.txt"))) + #expect(try await workspace.history().isEmpty) + + let recorder = Task { + var iterator = stream.makeAsyncIterator() + return await iterator.next() + } + recorder.cancel() + #expect(await recorder.value == nil) + } + + @Test + func `checkpoint revisions support reads trees globs and structured diffs`() async throws { + let workspace = Workspace() + try await workspace.writeText("/note.txt", "hello world\n") + let before = try await workspace.createCheckpoint(label: "before") + + try await workspace.writeText("/note.txt", "hello swift\nsecond\n") + try await workspace.writeData(Data([0, 1, 2]), to: "/binary.dat") + let after = try await workspace.createCheckpoint(label: "after") + + #expect(try await workspace.readText("/note.txt", at: .checkpoint(before.id)) == "hello world\n") + #expect(try await workspace.glob("/**/*.txt", at: .checkpoint(after.id)) == ["/note.txt"]) + #expect(try await workspace.tree(revision: .checkpoint(after.id)).summary.fileCount == 2) + + let diff = try await workspace.diff(from: .checkpoint(before.id), to: .checkpoint(after.id)) + #expect(diff.statistics.changedFileCount == 2) + #expect(diff.statistics.additions == 2) + #expect(diff.statistics.deletions == 1) + #expect(diff.textDiffOmissions == [.init(path: "/binary.dat", reason: .binary)]) + let textDiff = try #require(diff.changes.first { $0.path == "/note.txt" }?.diff) + #expect(textDiff.originalLineCount == 1) + #expect(textDiff.updatedLineCount == 2) + #expect(textDiff.hunks.flatMap(\.lines).contains { !$0.intralineRanges.isEmpty }) + } + + @Test + func `search and replacement share file selection and pattern behavior`() async throws { + let workspace = Workspace() + _ = try await workspace.apply([ + .writeText("/root.swift", "let OldName = 1\n"), + .createDirectory("/Sources/Nested"), + .writeText("/Sources/Nested/a.swift", "// OldName\nlet value = OldName\n"), + .writeText("/Sources/Nested/ignored.txt", "OldName\n"), + ]) + let selection = FileSelection(root: "/", include: ["**/*.swift"], exclude: ["**/Generated/**"]) + let search = try await workspace.search( + SearchRequest(pattern: .literal("oldname", caseSensitive: false), files: selection, contextLines: 1) + ) + #expect(search.matches.count == 3) + #expect(search.matches.map(\.path).contains("/root.swift")) + #expect(search.matches.contains { $0.before.count == 1 }) + + let result = try await workspace.apply([ + .replace(files: selection, pattern: .literal("OldName"), with: "NewName") + ]) + #expect(result.changes.statistics.changedFileCount == 2) + #expect(try await workspace.readText("/root.swift") == "let NewName = 1\n") + #expect(try await workspace.readText("/Sources/Nested/ignored.txt") == "OldName\n") + } + + @Test + func `archives are portable materialized subtrees`() async throws { + let source = Workspace() + _ = try await source.apply([ + .createDirectory("/docs/sub"), + .writeText("/docs/sub/note.txt", "portable"), + .createSymbolicLink("/docs/link", target: "sub/note.txt"), + ]) + let archive = try await source.archive(at: "/docs") + + let destination = Workspace() + let changes = try await destination.restore(archive, at: "/imported") + #expect(!changes.isEmpty) + #expect(try await destination.readText("/imported/sub/note.txt") == "portable") + #expect(try await destination.readText("/imported/link") == "portable") + } + + @Test + func `JSON helpers and revision restore use the simplified surface`() async throws { + struct Value: Codable, Equatable, Sendable { var name: String } + let workspace = Workspace() + try await workspace.writeJSON(Value(name: "one"), to: "/value.json") + let checkpoint = try await workspace.createCheckpoint() + try await workspace.writeJSON(Value(name: "two"), to: "/value.json") + _ = try await workspace.restore(to: checkpoint) + #expect(try await workspace.readJSON(Value.self, from: "/value.json") == Value(name: "one")) + #expect(try await workspace.history().last?.operation == .rollback) + } +} diff --git a/Tests/WorkspaceTests/WorkspaceCheckpointTests.swift b/Tests/WorkspaceTests/WorkspaceCheckpointTests.swift deleted file mode 100644 index c9efb3d..0000000 --- a/Tests/WorkspaceTests/WorkspaceCheckpointTests.swift +++ /dev/null @@ -1,456 +0,0 @@ -import Foundation -import Testing -@testable import Workspace - -private actor CheckpointEventRecorder { - private var events: [CheckpointEvent] = [] - - func append(_ event: CheckpointEvent) { - events.append(event) - } - - func snapshot() -> [CheckpointEvent] { - events - } -} - -private func startCheckpointRecording( - _ stream: AsyncStream, - into recorder: CheckpointEventRecorder -) -> Task { - Task { - for await event in stream { - await recorder.append(event) - } - } -} - -private func waitForCheckpointEvents( - _ expectedCount: Int, - recorder: CheckpointEventRecorder, - timeout: Duration = .seconds(1) -) async throws -> [CheckpointEvent] { - let clock = ContinuousClock() - let deadline = clock.now.advanced(by: timeout) - - while clock.now < deadline { - let snapshot = await recorder.snapshot() - if snapshot.count >= expectedCount { - return Array(snapshot.prefix(expectedCount)) - } - try await Task.sleep(for: .milliseconds(10)) - } - - return await recorder.snapshot() -} - -private actor FlakySnapshotCheckpointStore: CheckpointStore { - private let base = InMemoryCheckpointStore() - private var snapshotIdsReturningNil: Set = [] - - func breakLoadingSnapshot(id: UUID) { - snapshotIdsReturningNil.insert(id) - } - - func saveCheckpoint(_ checkpoint: Checkpoint) async throws { - try await base.saveCheckpoint(checkpoint) - } - - func loadCheckpoint(id: UUID, workspaceId: UUID) async throws -> Checkpoint? { - try await base.loadCheckpoint(id: id, workspaceId: workspaceId) - } - - func listCheckpoints(workspaceId: UUID) async throws -> [Checkpoint] { - try await base.listCheckpoints(workspaceId: workspaceId) - } - - func saveSnapshot(_ snapshot: Snapshot, workspaceId: UUID) async throws { - try await base.saveSnapshot(snapshot, workspaceId: workspaceId) - } - - func loadSnapshot(id: UUID, workspaceId: UUID) async throws -> Snapshot? { - if snapshotIdsReturningNil.contains(id) { - return nil - } - return try await base.loadSnapshot(id: id, workspaceId: workspaceId) - } - - func appendMutation(_ mutation: MutationRecord) async throws -> MutationRecord { - try await base.appendMutation(mutation) - } - - func listMutationRecords(workspaceId: UUID) async throws -> [MutationRecord] { - try await base.listMutationRecords(workspaceId: workspaceId) - } - - func pruneMutationRecords(workspaceId: UUID, throughSequence: Int) async throws { - try await base.pruneMutationRecords(workspaceId: workspaceId, throughSequence: throughSequence) - } -} - -@Suite("Workspace checkpoints") -struct WorkspaceCheckpointTests { - @Test - func `tracked writes persist mutation ranges and checkpoint summaries`() async throws { - let workspace = Workspace(filesystem: InMemoryFilesystem()) - - try await workspace.writeFile("/note.txt", content: "one") - try await workspace.appendFile("/note.txt", content: " two") - try await workspace.createDirectory(at: "/docs") - - let checkpoint = try await workspace.createCheckpoint(label: "draft") - let mutations = try await workspace.mutationRecords() - - #expect(checkpoint.firstMutationSequence == 1) - #expect(checkpoint.lastMutationSequence == 3) - #expect(checkpoint.mutationCursor == 3) - #expect(checkpoint.label == "draft") - #expect(checkpoint.summary.changeCount == 2) - #expect(checkpoint.summary.touchedPaths.contains("/note.txt")) - #expect(checkpoint.summary.touchedPaths.contains("/docs")) - #expect(checkpoint.summary.hasTextDiffs == true) - #expect(mutations.map(\.kind) == [.writeFile, .appendFile, .createDirectory]) - #expect(mutations[0].diff?.hunks.isEmpty == false) - #expect(mutations[1].diff?.hunks.isEmpty == false) - #expect(mutations[2].diff == nil) - #expect(try await workspace.readFile("/note.txt") == "one two") - } - - @Test - func `rollback restores the tree and emits a rollback event`() async throws { - let workspace = Workspace(filesystem: InMemoryFilesystem()) - - try await workspace.writeFile("/shared.txt", content: "one") - let checkpoint = try await workspace.createCheckpoint(label: "v1") - try await workspace.writeFile("/shared.txt", content: "two") - - let recorder = CheckpointEventRecorder() - let stream = try await workspace.watchCheckpointEvents() - let task = startCheckpointRecording(stream, into: recorder) - defer { task.cancel() } - - let rollbackCheckpoint = try await workspace.rollback(to: checkpoint.id, label: "revert") - let events = try await waitForCheckpointEvents(1, recorder: recorder) - - #expect(try await workspace.readFile("/shared.txt") == "one") - #expect(rollbackCheckpoint.rollbackSourceCheckpointId == checkpoint.id) - #expect(events.count == 1) - #expect(events[0].kind == .rolledBack) - #expect(events[0].checkpoint == rollbackCheckpoint) - } - - @Test - func `rollback appends a rollback mutation record`() async throws { - let workspace = Workspace(filesystem: InMemoryFilesystem()) - try await workspace.writeFile("/file.txt", content: "one") - let checkpoint = try await workspace.createCheckpoint(label: "v1") - try await workspace.writeFile("/file.txt", content: "two") - - _ = try await workspace.rollback(to: checkpoint.id) - - let mutations = try await workspace.mutationRecords() - #expect(mutations.map(\.kind) == [.writeFile, .writeFile, .rollback]) - #expect(mutations.last?.touchedPaths == ["/file.txt"]) - #expect(mutations.last?.fileChanges.first?.diff?.hunks.isEmpty == false) - #expect(try await workspace.readFile("/file.txt") == "one") - } - - @Test - func `pruneMutationHistory bounds the log while keeping sequences monotonic`() async throws { - let root = try makeTempDirectory() - defer { removeTempDirectory(root) } - - let workspace = Workspace(filesystem: InMemoryFilesystem(), storage: .directory(at: root)) - for index in 0..<5 { - try await workspace.writeFile("/file.txt", content: "revision \(index)") - } - - try await workspace.pruneMutationHistory(throughSequence: 3) - #expect(try await workspace.mutationRecords().map(\.sequence) == [4, 5]) - - try await workspace.pruneMutationHistory(throughSequence: 100) - #expect(try await workspace.mutationRecords().map(\.sequence) == [5]) - - try await workspace.writeFile("/file.txt", content: "after prune") - #expect(try await workspace.mutationRecords().map(\.sequence) == [5, 6]) - } - - @Test - func `branch writes are isolated and merge creates one merge checkpoint`() async throws { - let workspace = Workspace(filesystem: InMemoryFilesystem()) - try await workspace.writeFile("/note.txt", content: "base") - let base = try await workspace.createCheckpoint(label: "base") - - let branch = try await workspace.branch(label: "branch start") - try await branch.writeFile("/note.txt", content: "branch") - try await branch.writeFile("/new.txt", content: "new") - let branchHead = try await branch.createCheckpoint(label: "branch head") - - #expect(try await workspace.readFile("/note.txt") == "base") - #expect(!(await workspace.exists("/new.txt"))) - - let branchCheckpoints = try await branch.listCheckpoints() - #expect(branchCheckpoints.count == 2) - #expect(branchCheckpoints[0].baseCheckpointId == base.id) - #expect(branchHead.parentCheckpointId == branchCheckpoints[0].id) - #expect((try await workspace.listCheckpoints()).map(\.id) == [base.id]) - - let merged = try await workspace.merge(branch, label: "merge branch") - let mutations = try await workspace.mutationRecords() - - #expect(try await workspace.readFile("/note.txt") == "branch") - #expect(try await workspace.readFile("/new.txt") == "new") - #expect(merged.parentCheckpointId == base.id) - #expect(merged.mergedFromWorkspaceId == branch.workspaceId) - #expect(merged.mergedFromCheckpointId == branchHead.id) - #expect(merged.summary.touchedPaths.contains("/note.txt")) - #expect(merged.summary.touchedPaths.contains("/new.txt")) - #expect(Array(mutations.map(\.kind).suffix(1)) == [.mergeWorkspace]) - } - - @Test - func `merge conflicts when parent head advanced after branch`() async throws { - let workspace = Workspace(filesystem: InMemoryFilesystem()) - try await workspace.writeFile("/note.txt", content: "base") - let base = try await workspace.createCheckpoint(label: "base") - - let branch = try await workspace.branch() - try await branch.writeFile("/note.txt", content: "branch") - - try await workspace.writeFile("/note.txt", content: "main") - let advanced = try await workspace.createCheckpoint(label: "main") - - do { - _ = try await workspace.merge(branch) - Issue.record("expected merge conflict") - } catch let error as WorkspaceError { - guard case let .mergeConflict(parentWorkspaceId, expectedBase, actualHead) = error else { - Issue.record("unexpected workspace error: \(error)") - return - } - #expect(parentWorkspaceId == workspace.workspaceId) - #expect(expectedBase == base.id) - #expect(actualHead == advanced.id) - } - } - - @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() - defer { removeTempDirectory(root) } - - let workspaceId = UUID() - let workspace = Workspace( - workspaceId: workspaceId, - filesystem: InMemoryFilesystem(), - storage: .directory(at: root) - ) - - try await workspace.writeFile("/note.txt", content: "checkpoint") - let checkpoint = try await workspace.createCheckpoint(label: "saved") - try await workspace.writeFile("/note.txt", content: "current") - - let reloaded = Workspace( - workspaceId: workspaceId, - filesystem: InMemoryFilesystem(), - storage: .directory(at: root) - ) - let persistedCheckpoint = try #require(try await reloaded.checkpoint(id: checkpoint.id)) - let snapshot = try await reloaded.snapshot(for: persistedCheckpoint) - let restored = InMemoryFilesystem() - - try await Snapshot.restore(snapshot, to: restored) - - #expect(try await workspace.readFile("/note.txt") == "current") - #expect(try await restored.readFile(path: "/note.txt") == Data("checkpoint".utf8)) - #expect(snapshot.summary(comparedTo: nil) == checkpoint.summary) - } - - @Test - func `all public write surfaces append mutation records`() async throws { - let workspace = Workspace(filesystem: InMemoryFilesystem()) - - try await workspace.writeFile("/base.txt", content: "x") - try await workspace.appendFile("/base.txt", content: "y") - try await workspace.writeData(Data([0, 1, 2]), to: "/bin.dat") - try await workspace.writeJSON(["a": 1], to: "/config.json", prettyPrinted: false) - try await workspace.createDirectory(at: "/nested/deep", recursive: true) - try await workspace.writeFile("/nested/deep/a.txt", content: "a") - try await workspace.copyItem(from: "/nested/deep/a.txt", to: "/nested/deep/b.txt") - try await workspace.moveItem(from: "/nested/deep/b.txt", to: "/moved.txt") - try await workspace.removeItem(at: "/nested", recursive: true) - - _ = try await workspace.applyEdits([ - .writeFile(path: "/batch.txt", content: "line\n"), - ]) - try await workspace.writeFile("/replace.txt", content: "hello old world") - _ = try await workspace.applyReplacement( - ReplacementRequest(pattern: "*.txt", search: "old", replacement: "new") - ) - - let beforeSnapshot = try await workspace.captureSnapshot() - try await workspace.writeFile("/temporary.txt", content: "gone") - try await workspace.restoreSnapshot(beforeSnapshot) - - let mutations = try await workspace.mutationRecords() - let kinds = mutations.map(\.kind) - #expect( - kinds == [ - .writeFile, - .appendFile, - .writeData, - .writeJSON, - .createDirectory, - .writeFile, - .copyItem, - .moveItem, - .removeItem, - .applyEdits, - .writeFile, - .applyReplacement, - .writeFile, - .restoreSnapshot, - ] - ) - #expect(try await workspace.readFile("/base.txt") == "xy") - #expect(try await workspace.readFile("/moved.txt") == "a") - #expect(try await workspace.readFile("/replace.txt") == "hello new world") - #expect(!(await workspace.exists("/temporary.txt"))) - } - - @Test - func `snapshot and rollback errors use WorkspaceError`() async throws { - let store = FlakySnapshotCheckpointStore() - let workspace = Workspace(filesystem: InMemoryFilesystem(), store: store) - - try await workspace.writeFile("/note.txt", content: "v1") - let checkpoint = try await workspace.createCheckpoint(label: "has-snapshot") - - await store.breakLoadingSnapshot(id: checkpoint.snapshotId) - - do { - _ = try await workspace.snapshot(for: checkpoint) - Issue.record("expected snapshotNotFound") - } catch let error as WorkspaceError { - guard case let .snapshotNotFound(id) = error else { - Issue.record("unexpected error: \(error)") - return - } - #expect(id == checkpoint.snapshotId) - } - - do { - _ = try await workspace.rollback(to: UUID()) - Issue.record("expected checkpointNotFound") - } catch let error as WorkspaceError { - guard case .checkpointNotFound = error else { - Issue.record("unexpected error: \(error)") - return - } - } - } - - @Test - func `watchCheckpointEvents receives checkpoints from another workspace sharing the store`() async throws { - let store = InMemoryCheckpointStore() - let workspaceId = UUID() - - let observer = Workspace(workspaceId: workspaceId, filesystem: InMemoryFilesystem(), store: store) - let producer = Workspace(workspaceId: workspaceId, filesystem: InMemoryFilesystem(), store: store) - - let recorder = CheckpointEventRecorder() - let stream = try await observer.watchCheckpointEvents() - let task = startCheckpointRecording(stream, into: recorder) - defer { task.cancel() } - - try await producer.writeFile("/remote.txt", content: "remote") - let created = try await producer.createCheckpoint(label: "other-instance") - - let events = try await waitForCheckpointEvents(1, recorder: recorder, timeout: .seconds(2)) - #expect(events.count == 1) - #expect(events[0].kind == .created) - #expect(events[0].checkpoint.id == created.id) - - try await observer.writeFile("/local.txt", content: "local") - let observerCheckpoint = try await observer.createCheckpoint(label: "from-observer") - #expect(observerCheckpoint.parentCheckpointId == created.id) - } - - @Test - func `merge mutation includes per-file text diffs`() async throws { - let workspace = Workspace() - try await workspace.writeFile("/a.txt", content: "a1\n") - try await workspace.writeFile("/b.txt", content: "b1\n") - _ = try await workspace.createCheckpoint(label: "base") - - let branch = try await workspace.branch() - try await branch.writeFile("/a.txt", content: "a2\n") - try await branch.writeFile("/b.txt", content: "b2\n") - - _ = try await workspace.merge(branch, label: "merge") - let mutations = try await workspace.mutationRecords() - let mergeMutation = try #require(mutations.last) - - #expect(mergeMutation.kind == .mergeWorkspace) - #expect(mergeMutation.fileChanges.map(\.path) == ["/a.txt", "/b.txt"]) - #expect(mergeMutation.fileChanges.allSatisfy { $0.diff?.hunks.isEmpty == false }) - #expect(mergeMutation.diff == nil) - } - - private func makeTempDirectory() throws -> URL { - let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) - let url = base.appendingPathComponent("WorkspaceCheckpointTests-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) - return url - } - - private func removeTempDirectory(_ url: URL) { - try? FileManager.default.removeItem(at: url) - } -} diff --git a/Tests/WorkspaceTests/WorkspaceInternalsTests.swift b/Tests/WorkspaceTests/WorkspaceInternalsTests.swift deleted file mode 100644 index 78f39b7..0000000 --- a/Tests/WorkspaceTests/WorkspaceInternalsTests.swift +++ /dev/null @@ -1,286 +0,0 @@ -import Foundation -import Testing -@testable import Workspace - -private actor CountingCheckpointStore: CheckpointStore { - private let checkpoints: [Checkpoint] - private let snapshots: [UUID: Snapshot] - private let mutations: [MutationRecord] - private var checkpointListCount = 0 - private var mutationListCount = 0 - - init( - checkpoints: [Checkpoint] = [], - snapshots: [UUID: Snapshot] = [:], - mutations: [MutationRecord] = [] - ) { - self.checkpoints = checkpoints - self.snapshots = snapshots - self.mutations = mutations - } - - func saveCheckpoint(_ checkpoint: Checkpoint) async throws {} - - func loadCheckpoint(id: UUID, workspaceId: UUID) async throws -> Checkpoint? { - checkpoints.first { $0.id == id && $0.workspaceId == workspaceId } - } - - func listCheckpoints(workspaceId: UUID) async throws -> [Checkpoint] { - checkpointListCount += 1 - try await Task.sleep(for: .milliseconds(20)) - return checkpoints.filter { $0.workspaceId == workspaceId } - } - - func saveSnapshot(_ snapshot: Snapshot, workspaceId: UUID) async throws {} - - func loadSnapshot(id: UUID, workspaceId: UUID) async throws -> Snapshot? { - snapshots[id] - } - - func appendMutation(_ mutation: MutationRecord) async throws -> MutationRecord { - mutation - } - - func listMutationRecords(workspaceId: UUID) async throws -> [MutationRecord] { - mutationListCount += 1 - try await Task.sleep(for: .milliseconds(20)) - return mutations.filter { $0.workspaceId == workspaceId } - } - - func pruneMutationRecords(workspaceId: UUID, throughSequence: Int) async throws {} - - func listCounts() -> (checkpoints: Int, mutations: Int) { - (checkpointListCount, mutationListCount) - } -} - -@Suite("Workspace internals") -struct WorkspaceInternalsTests { - @Test - func `ensureLoaded coalesces concurrent store loads`() async throws { - let workspaceId = UUID() - let store = CountingCheckpointStore() - let workspace = Workspace( - workspaceId: workspaceId, - filesystem: InMemoryFilesystem(), - store: store - ) - - try await withThrowingTaskGroup(of: Void.self) { group in - for _ in 0..<8 { - group.addTask { - try await workspace.ensureLoaded() - } - } - try await group.waitForAll() - } - - let counts = await store.listCounts() - #expect(counts.checkpoints == 1) - #expect(counts.mutations == 1) - } - - @Test - func `appendMutation canonicalizes touched paths and file changes`() async throws { - let workspace = Workspace() - try await workspace.ensureLoaded() - - try await workspace.appendMutation( - kind: .applyEdits, - touchedPaths: ["/b.txt", "/a.txt", "/b.txt"], - fileChanges: [ - FileEdit.FileChange(path: "/b.txt", kind: .file, effect: .modified), - FileEdit.FileChange(path: "/a.txt", kind: .file, effect: .created), - FileEdit.FileChange(path: "/a.txt", kind: .file, effect: .deleted), - ], - diff: nil - ) - - let mutation = try #require(try await workspace.mutationRecords().first) - #expect(mutation.sequence == 1) - #expect(mutation.touchedPaths == ["/a.txt", "/b.txt"]) - #expect(mutation.fileChanges.map(\.path) == ["/a.txt", "/a.txt", "/b.txt"]) - #expect(mutation.fileChanges.map(\.effect) == [.created, .deleted, .modified]) - } - - @Test - func `pollCheckpointEvents refreshes mutation cursor before the next local checkpoint`() async throws { - let store = InMemoryCheckpointStore() - let workspaceId = UUID() - let observer = Workspace(workspaceId: workspaceId, filesystem: InMemoryFilesystem(), store: store) - let producer = Workspace(workspaceId: workspaceId, filesystem: InMemoryFilesystem(), store: store) - - let stream = try await observer.watchCheckpointEvents() - let task = Task { - for await _ in stream {} - } - defer { task.cancel() } - - try await producer.writeFile("/remote.txt", content: "remote") - let remote = try await producer.createCheckpoint(label: "remote") - - await observer.pollCheckpointEvents() - try await observer.writeFile("/local.txt", content: "local") - let local = try await observer.createCheckpoint(label: "local") - let localMutation = try #require(try await observer.mutationRecords().last) - - #expect(local.parentCheckpointId == remote.id) - #expect(local.firstMutationSequence == 2) - #expect(local.lastMutationSequence == 2) - #expect(localMutation.sequence == 2) - } - - @Test - func `snapshotDelta captures type changes symlink updates and directory permission changes`() async throws { - let workspace = Workspace() - let original = Snapshot.Entry.directory( - Snapshot.Directory( - path: .root, - permissions: .defaultDirectory, - children: [ - .file(Snapshot.File(path: "/node", data: Data("old\n".utf8), permissions: .defaultFile)), - .symlink(Snapshot.Symlink(path: "/link", target: "/node", permissions: .defaultFile)), - .directory(Snapshot.Directory(path: "/docs", permissions: POSIXPermissions(0o755), children: [])), - ] - ) - ) - let updated = Snapshot.Entry.directory( - Snapshot.Directory( - path: .root, - permissions: .defaultDirectory, - children: [ - .directory( - Snapshot.Directory( - path: "/node", - permissions: .defaultDirectory, - children: [ - .file( - Snapshot.File( - path: "/node/child.txt", - data: Data("new\n".utf8), - permissions: .defaultFile - ) - ), - ] - ) - ), - .symlink(Snapshot.Symlink(path: "/link", target: "/node/child.txt", permissions: .defaultFile)), - .directory(Snapshot.Directory(path: "/docs", permissions: POSIXPermissions(0o700), children: [])), - ] - ) - ) - - let delta = await workspace.snapshotDelta(from: original, to: updated) - let changesByPath = Dictionary(uniqueKeysWithValues: delta.fileChanges.map { ($0.path, $0) }) - - #expect(delta.hasChanges) - #expect(delta.touchedPaths == ["/docs", "/link", "/node", "/node/child.txt"]) - #expect(delta.fileChanges.map(\.path) == ["/link", "/node", "/node/child.txt"]) - #expect(changesByPath["/link"]?.kind == .symlink) - #expect(changesByPath["/link"]?.effect == .modified) - #expect(changesByPath["/link"]?.diff == nil) - #expect(changesByPath["/node"]?.effect == .deleted) - #expect(changesByPath["/node"]?.diff?.hunks.isEmpty == false) - #expect(changesByPath["/node/child.txt"]?.effect == .created) - #expect(changesByPath["/node/child.txt"]?.diff?.hunks.isEmpty == false) - } - - @Test - func `snapshotDelta captures created and deleted symlinks without text diffs`() async throws { - let workspace = Workspace() - let original = Snapshot.Entry.directory( - Snapshot.Directory( - path: .root, - permissions: .defaultDirectory, - children: [ - .file(Snapshot.File(path: "/target.txt", data: Data("target\n".utf8), permissions: .defaultFile)), - .symlink(Snapshot.Symlink(path: "/deleted-link", target: "/target.txt", permissions: .defaultFile)), - ] - ) - ) - let updated = Snapshot.Entry.directory( - Snapshot.Directory( - path: .root, - permissions: .defaultDirectory, - children: [ - .file(Snapshot.File(path: "/target.txt", data: Data("target\n".utf8), permissions: .defaultFile)), - .symlink(Snapshot.Symlink(path: "/created-link", target: "/target.txt", permissions: .defaultFile)), - ] - ) - ) - - let delta = await workspace.snapshotDelta(from: original, to: updated) - let changesByPath = Dictionary(uniqueKeysWithValues: delta.fileChanges.map { ($0.path, $0) }) - - #expect(delta.touchedPaths == ["/created-link", "/deleted-link"]) - #expect(delta.fileChanges.map(\.path) == ["/created-link", "/deleted-link"]) - #expect(changesByPath["/created-link"]?.kind == .symlink) - #expect(changesByPath["/created-link"]?.effect == .created) - #expect(changesByPath["/created-link"]?.diff == nil) - #expect(changesByPath["/deleted-link"]?.kind == .symlink) - #expect(changesByPath["/deleted-link"]?.effect == .deleted) - #expect(changesByPath["/deleted-link"]?.diff == nil) - } - - @Test - func `writeData to a directory path throws with unsupported`() async throws { - let workspace = Workspace() - try await workspace.createDirectory(at: "/out", recursive: true) - do { - try await workspace.writeData(Data([1]), to: "/out") - Issue.record("expected error when writing to directory") - } catch let error as WorkspaceError { - guard case .unsupported = error else { - Issue.record("unexpected workspace error: \(error)") - return - } - } - } - - @Test - func `performBinaryWrite through an existing symlink records symlink metadata and updates target`() async throws { - let filesystem = InMemoryFilesystem() - try await filesystem.writeFile(path: "/target.bin", data: Data([1, 2, 3]), append: false) - try await filesystem.createSymlink(path: "/alias.bin", target: "/target.bin") - let workspace = Workspace(filesystem: filesystem) - - try await workspace.writeData(Data([4, 5, 6]), to: "/alias.bin") - try await workspace.writeData(Data([4, 5, 6]), to: "/alias.bin") - - let mutations = try await workspace.mutationRecords() - let first = try #require(mutations.first) - let second = try #require(mutations.last) - - #expect(try await filesystem.readFile(path: "/target.bin") == Data([4, 5, 6])) - #expect(try await filesystem.readSymlink(path: "/alias.bin") == "/target.bin") - #expect(first.kind == .writeData) - #expect(first.touchedPaths == ["/alias.bin"]) - #expect(first.fileChanges.count == 1) - #expect(first.fileChanges[0].path == "/alias.bin") - #expect(first.fileChanges[0].kind == .symlink) - #expect(first.fileChanges[0].effect == .modified) - #expect(first.fileChanges[0].status == .applied) - #expect(first.fileChanges[0].diff == nil) - #expect(second.sequence == 2) - #expect(second.fileChanges[0].kind == .symlink) - #expect(second.fileChanges[0].effect == .unchanged) - } - - @Test - func `untrackedRestore restores root snapshots without appending a mutation`() async throws { - let workspace = Workspace() - - try await workspace.writeFile("/kept.txt", content: "one") - let snapshot = try await workspace.captureSnapshot() - try await workspace.writeFile("/extra.txt", content: "two") - let before = try await workspace.mutationRecords().count - - try await workspace.untrackedRestore(snapshot) - let after = try await workspace.mutationRecords().count - - #expect(before == 2) - #expect(after == before) - #expect(try await workspace.readFile("/kept.txt") == "one") - #expect(!(await workspace.exists("/extra.txt"))) - } -} From 52df894db244816b3a97c66effecbfc6a5cd6e76 Mon Sep 17 00:00:00 2001 From: Zac White Date: Sun, 12 Jul 2026 15:12:28 -0700 Subject: [PATCH 2/8] Add checkpoint storage compaction and retention --- README.md | 30 + Sources/Workspace/Checkpoint.swift | 88 +++ Sources/Workspace/CheckpointStore.swift | 516 +++++++++++++++--- Sources/Workspace/Workspace+Checkpoints.swift | 4 +- Sources/Workspace/Workspace+Internals.swift | 77 +-- Sources/Workspace/WorkspaceCompaction.swift | 215 ++++++++ .../WorkspaceTests/CheckpointStoreTests.swift | 24 +- Tests/WorkspaceTests/CompactionTests.swift | 269 +++++++++ 8 files changed, 1062 insertions(+), 161 deletions(-) create mode 100644 Sources/Workspace/WorkspaceCompaction.swift create mode 100644 Tests/WorkspaceTests/CompactionTests.swift diff --git a/README.md b/README.md index aba05ab..a0d0bd1 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,36 @@ let archive = try await workspace.archive(at: "/Sources") try await anotherWorkspace.restore(archive, at: "/Imported") ``` +## Storage Lifecycle + +Inspect checkpoint-store usage and preview retention before deleting anything: + +```swift +let usage = try await workspace.storageStatistics() + +let preview = try await workspace.compact( + retaining: .latest( + 50, + preservingLabeled: true, + preserving: [releaseCheckpoint.id] + ), + dryRun: true +) + +print(preview.removedCheckpointCount) +print(preview.reclaimedBytes) +``` + +Apply the same policy by omitting `dryRun`: + +```swift +let report = try await workspace.compact(retaining: .latest(50)) +``` + +Compaction rebases retained checkpoints to their nearest retained ancestor and recomputes their summaries. Retained rollback and transaction checkpoints also retain an existing checkpoint they reference as provenance. `.all` keeps every checkpoint while sweeping orphaned manifests and blobs. + +Directory-backed stores serialize checkpoint commits, revision reads, and compaction with a persistent lifecycle lock. Mutation history is independent and is not pruned by checkpoint retention. + ## Transactions Transactions replace separate branch and merge APIs: diff --git a/Sources/Workspace/Checkpoint.swift b/Sources/Workspace/Checkpoint.swift index 74ae80e..35ae7d1 100644 --- a/Sources/Workspace/Checkpoint.swift +++ b/Sources/Workspace/Checkpoint.swift @@ -30,6 +30,14 @@ public struct Checkpoint: Sendable, Codable, Equatable { return nil } + var provenanceCheckpointID: UUID? { + switch origin { + case let .rollback(from): from + case let .transaction(base): base + case .manual, .archiveRestore: nil + } + } + init( id: UUID = UUID(), workspaceId: UUID, @@ -55,4 +63,84 @@ public struct Checkpoint: Sendable, Codable, Equatable { self.snapshotId = snapshotId self.summary = summary } + + static func orderedBefore(_ lhs: Checkpoint, _ rhs: Checkpoint) -> Bool { + if lhs.createdAt == rhs.createdAt { + return lhs.id.uuidString < rhs.id.uuidString + } + return lhs.createdAt < rhs.createdAt + } + + static func lineageHeadID(in checkpoints: [Checkpoint]) -> UUID? { + guard !checkpoints.isEmpty else { return nil } + let referencedParents = Set(checkpoints.compactMap(\.parentID)) + let tips = checkpoints.filter { !referencedParents.contains($0.id) } + return (tips.isEmpty ? checkpoints : tips).max(by: orderedBefore)?.id + } + + static func make( + snapshot: Snapshot, + draft: CheckpointDraft, + parent: Checkpoint?, + parentSnapshot: Snapshot? + ) -> Checkpoint { + let metadata = revisionMetadata( + snapshot: snapshot, + parent: parent, + parentSnapshot: parentSnapshot, + mutationCursor: draft.mutationCursor + ) + return Checkpoint( + workspaceId: draft.workspaceID, + label: draft.label, + parentCheckpointId: parent?.id, + origin: draft.origin, + firstMutationSequence: metadata.firstMutationSequence, + lastMutationSequence: metadata.lastMutationSequence, + mutationCursor: draft.mutationCursor, + snapshotId: snapshot.id, + summary: metadata.summary + ) + } + + func rebased(to parent: Checkpoint?, snapshot: Snapshot, parentSnapshot: Snapshot?) -> Checkpoint { + var copy = self + copy.parentID = parent?.id + let metadata = Self.revisionMetadata( + snapshot: snapshot, + parent: parent, + parentSnapshot: parentSnapshot, + mutationCursor: mutationCursor + ) + copy.firstMutationSequence = metadata.firstMutationSequence + copy.lastMutationSequence = metadata.lastMutationSequence + copy.summary = metadata.summary + return copy + } + + private static func revisionMetadata( + snapshot: Snapshot, + parent: Checkpoint?, + parentSnapshot: Snapshot?, + mutationCursor: Int + ) -> (firstMutationSequence: Int?, lastMutationSequence: Int?, summary: ChangeSet.Summary) { + let baseline = parentSnapshot ?? Snapshot( + rootPath: snapshot.rootPath, + entry: .missing(.init(path: snapshot.rootPath)) + ) + let previousCursor = parent?.mutationCursor ?? 0 + return ( + mutationCursor > previousCursor ? previousCursor + 1 : nil, + mutationCursor > previousCursor ? mutationCursor : nil, + ChangeSet.compare(before: baseline, after: snapshot, maxTextBytes: 1_000_000).summary + ) + } +} + +struct CheckpointDraft: Sendable { + var workspaceID: UUID + var label: String? + var preferredParentID: UUID? + var origin: Checkpoint.Origin + var mutationCursor: Int } diff --git a/Sources/Workspace/CheckpointStore.swift b/Sources/Workspace/CheckpointStore.swift index 35469b5..bcaceb7 100644 --- a/Sources/Workspace/CheckpointStore.swift +++ b/Sources/Workspace/CheckpointStore.swift @@ -12,10 +12,9 @@ import Glibc /// any placeholder sequence; ``appendMutation(_:)`` returns the record with the next persisted /// monotonic number for that workspace, serialized under the mutations lock. protocol CheckpointStore: AnyObject, Sendable { - func saveCheckpoint(_ checkpoint: Checkpoint) async throws + func saveRevision(_ snapshot: Snapshot, draft: CheckpointDraft) async throws -> Checkpoint func loadCheckpoint(id: UUID, workspaceId: UUID) async throws -> Checkpoint? func listCheckpoints(workspaceId: UUID) async throws -> [Checkpoint] - func saveSnapshot(_ snapshot: Snapshot, workspaceId: UUID) async throws func loadSnapshot(id: UUID, workspaceId: UUID) async throws -> Snapshot? func loadRevisionIndex(id: UUID, workspaceId: UUID) async throws -> RevisionIndex? func readSnapshotFile( @@ -31,6 +30,12 @@ protocol CheckpointStore: AnyObject, Sendable { /// Removes mutation records with `sequence <= throughSequence`. The record carrying the /// highest sequence is always retained so later appends stay monotonic. func pruneMutations(workspaceId: UUID, throughSequence: Int) async throws + func storageStatistics(workspaceId: UUID) async throws -> Workspace.StorageStatistics + func compact( + workspaceId: UUID, + retaining retention: Workspace.Retention, + dryRun: Bool + ) async throws -> StoreCompactionResult } /// An in-memory checkpoint store for tests and ephemeral workspaces. @@ -41,14 +46,23 @@ actor InMemoryCheckpointStore: CheckpointStore { init() {} - func saveCheckpoint(_ checkpoint: Checkpoint) async throws { - var list = checkpointsByWorkspace[checkpoint.workspaceId] ?? [] - if let index = list.firstIndex(where: { $0.id == checkpoint.id }) { - list[index] = checkpoint - } else { - list.append(checkpoint) + func saveRevision(_ snapshot: Snapshot, draft: CheckpointDraft) async throws -> Checkpoint { + var list = checkpointsByWorkspace[draft.workspaceID] ?? [] + let parent = resolvedParent(for: draft, checkpoints: list) + let parentSnapshot = parent.flatMap { snapshotsByWorkspace[draft.workspaceID]?[$0.snapshotId] } + if let parent, parentSnapshot == nil { + throw WorkspaceError.storageCorrupted("checkpoint \(parent.id) has no snapshot") } - checkpointsByWorkspace[checkpoint.workspaceId] = list + let checkpoint = Checkpoint.make( + snapshot: snapshot, + draft: draft, + parent: parent, + parentSnapshot: parentSnapshot + ) + snapshotsByWorkspace[draft.workspaceID, default: [:]][snapshot.id] = snapshot + list.append(checkpoint) + checkpointsByWorkspace[draft.workspaceID] = list.sorted(by: Checkpoint.orderedBefore) + return checkpoint } func loadCheckpoint(id: UUID, workspaceId: UUID) async throws -> Checkpoint? { @@ -64,10 +78,6 @@ actor InMemoryCheckpointStore: CheckpointStore { } } - func saveSnapshot(_ snapshot: Snapshot, workspaceId: UUID) async throws { - snapshotsByWorkspace[workspaceId, default: [:]][snapshot.id] = snapshot - } - func loadSnapshot(id: UUID, workspaceId: UUID) async throws -> Snapshot? { snapshotsByWorkspace[workspaceId]?[id] } @@ -120,6 +130,82 @@ actor InMemoryCheckpointStore: CheckpointStore { } mutationsByWorkspace[workspaceId] = kept } + + func storageStatistics(workspaceId: UUID) async throws -> Workspace.StorageStatistics { + statistics( + checkpoints: checkpointsByWorkspace[workspaceId] ?? [], + snapshots: snapshotsByWorkspace[workspaceId] ?? [:] + ) + } + + func compact( + workspaceId: UUID, + retaining retention: Workspace.Retention, + dryRun: Bool + ) async throws -> StoreCompactionResult { + let currentCheckpoints = checkpointsByWorkspace[workspaceId] ?? [] + let currentSnapshots = snapshotsByWorkspace[workspaceId] ?? [:] + let before = statistics(checkpoints: currentCheckpoints, snapshots: currentSnapshots) + let plan = try CheckpointRetentionPlanner.plan(checkpoints: currentCheckpoints, retaining: retention) + let retained = try CheckpointRetentionPlanner.materialize(plan) { snapshotID in + currentSnapshots[snapshotID] + } + + let retainedSnapshotIDs = Set(retained.map(\.snapshotId)) + let retainedSnapshots = currentSnapshots.filter { retainedSnapshotIDs.contains($0.key) } + let after = statistics(checkpoints: retained, snapshots: retainedSnapshots) + let report = Workspace.CompactionReport( + dryRun: dryRun, + before: before, + after: after, + removedCheckpointIDs: plan.removedCheckpointIDs, + rebasedCheckpointIDs: plan.rebasedCheckpointIDs + ) + + if !dryRun { + checkpointsByWorkspace[workspaceId] = retained + snapshotsByWorkspace[workspaceId] = retainedSnapshots + } + return StoreCompactionResult(report: report, checkpoints: retained) + } + + private func resolvedParent(for draft: CheckpointDraft, checkpoints: [Checkpoint]) -> Checkpoint? { + if let preferred = draft.preferredParentID, + let parent = checkpoints.first(where: { $0.id == preferred }) { + return parent + } + guard draft.preferredParentID != nil, + let headID = Checkpoint.lineageHeadID(in: checkpoints) + else { return nil } + return checkpoints.first(where: { $0.id == headID }) + } + + private func statistics( + checkpoints: [Checkpoint], + snapshots: [UUID: Snapshot] + ) -> Workspace.StorageStatistics { + var blobs: [String: UInt64] = [:] + for snapshot in snapshots.values { + collectBlobStatistics(snapshot.entry, into: &blobs) + } + return Workspace.StorageStatistics( + checkpointCount: checkpoints.count, + snapshotCount: snapshots.count, + blobCount: blobs.count, + blobBytes: blobs.values.reduce(0, +) + ) + } + + private func collectBlobStatistics(_ entry: Snapshot.Entry, into blobs: inout [String: UInt64]) { + switch entry { + case let .file(file): + blobs[SHA256.hexDigest(of: file.data)] = UInt64(file.data.count) + case let .directory(directory): + for child in directory.children { collectBlobStatistics(child, into: &blobs) } + case .missing, .symlink: + break + } + } } /// A JSON file-backed checkpoint store. @@ -130,9 +216,11 @@ actor InMemoryCheckpointStore: CheckpointStore { /// crashed append is skipped when reading and truncated before the next 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`` -/// instances in the same process and across cooperating processes do not lose appends. Checkpoint -/// and snapshot writes are per-artifact atomic replaces. Coordinating writers on network -/// filesystems that do not honor `flock` may still require application-level serialization. +/// instances in the same process and across cooperating processes do not lose appends. A separate +/// lifecycle lock serializes checkpoint commits, revision reads, and compaction; rebased metadata +/// is written before unreachable artifacts are removed, keeping interrupted compaction recoverable. +/// Coordinating writers on network filesystems that do not honor `flock` may still require +/// application-level serialization. actor FileCheckpointStore: CheckpointStore { private let rootDirectory: URL private let fileManager: FileManager @@ -153,19 +241,42 @@ actor FileCheckpointStore: CheckpointStore { self.compactEncoder = JSONEncoder() } - func saveCheckpoint(_ checkpoint: Checkpoint) async throws { - listCheckpointsCache[checkpoint.workspaceId] = nil - try ensureWorkspaceDirectories(for: checkpoint.workspaceId) - try write(checkpoint, to: checkpointURL(id: checkpoint.id, workspaceId: checkpoint.workspaceId)) + func saveRevision(_ snapshot: Snapshot, draft: CheckpointDraft) async throws -> Checkpoint { + try ensureWorkspaceDirectories(for: draft.workspaceID) + return try Self.withExclusiveLock(at: lifecycleLockURL(workspaceId: draft.workspaceID)) { + let checkpoints = try loadAllCheckpointsUnlocked(workspaceId: draft.workspaceID) + let parent = resolvedParent(for: draft, checkpoints: checkpoints) + let parentSnapshot = try parent.map { + guard let snapshot = try loadSnapshotUnlocked(id: $0.snapshotId, workspaceId: draft.workspaceID) else { + throw WorkspaceError.storageCorrupted("checkpoint \($0.id) has no snapshot") + } + return snapshot + } + let checkpoint = Checkpoint.make( + snapshot: snapshot, + draft: draft, + parent: parent, + parentSnapshot: parentSnapshot + ) + try writeSnapshotUnlocked(snapshot, workspaceId: draft.workspaceID) + try write(checkpoint, to: checkpointURL(id: checkpoint.id, workspaceId: draft.workspaceID)) + listCheckpointsCache[draft.workspaceID] = nil + return checkpoint + } } func loadCheckpoint(id: UUID, workspaceId: UUID) async throws -> Checkpoint? { try validateWorkspaceFormatIfPresent(workspaceId) - let url = checkpointURL(id: id, workspaceId: workspaceId) - guard fileManager.fileExists(atPath: url.path) else { - return nil + guard fileManager.fileExists(atPath: workspaceDirectoryURL(workspaceId: workspaceId).path) else { return nil } + return try Self.withSharedLock(at: lifecycleLockURL(workspaceId: workspaceId)) { + let url = checkpointURL(id: id, workspaceId: workspaceId) + guard fileManager.fileExists(atPath: url.path) else { return nil } + let checkpoint = try read(Checkpoint.self, from: url) + guard checkpoint.id == id, checkpoint.workspaceID == workspaceId else { + throw WorkspaceError.storageCorrupted("checkpoint metadata does not match its storage path") + } + return checkpoint } - return try read(Checkpoint.self, from: url) } func listCheckpoints(workspaceId: UUID) async throws -> [Checkpoint] { @@ -175,29 +286,18 @@ actor FileCheckpointStore: CheckpointStore { listCheckpointsCache[workspaceId] = nil return [] } - if let key = try checkpointsDirectoryCacheKey(at: directoryURL), - let entry = listCheckpointsCache[workspaceId], entry.cacheKey == key { - return entry.checkpoints - } + return try Self.withSharedLock(at: lifecycleLockURL(workspaceId: workspaceId)) { + if let key = try checkpointsDirectoryCacheKey(at: directoryURL), + let entry = listCheckpointsCache[workspaceId], entry.cacheKey == key { + return entry.checkpoints + } - let result = try fileManager - .contentsOfDirectory( - at: directoryURL, - includingPropertiesForKeys: nil, - options: [.skipsHiddenFiles] - ) - .filter { $0.pathExtension == "json" } - .map { try read(Checkpoint.self, from: $0) } - .sorted { - if $0.createdAt == $1.createdAt { - return $0.id.uuidString < $1.id.uuidString - } - return $0.createdAt < $1.createdAt + let result = try loadAllCheckpointsUnlocked(workspaceId: workspaceId) + if let key = try checkpointsDirectoryCacheKey(at: directoryURL) { + listCheckpointsCache[workspaceId] = (key, result) } - if let key = try checkpointsDirectoryCacheKey(at: directoryURL) { - listCheckpointsCache[workspaceId] = (key, result) + return result } - return result } /// A persisted snapshot: the tree structure with file contents replaced by content hashes. @@ -224,8 +324,7 @@ actor FileCheckpointStore: CheckpointStore { var root: Node } - func saveSnapshot(_ snapshot: Snapshot, workspaceId: UUID) async throws { - try ensureWorkspaceDirectories(for: workspaceId) + private func writeSnapshotUnlocked(_ snapshot: Snapshot, workspaceId: UUID) throws { let root = try writeManifestNode(snapshot.entry, workspaceId: workspaceId) let manifest = SnapshotManifest( version: 2, @@ -238,28 +337,28 @@ actor FileCheckpointStore: CheckpointStore { func loadSnapshot(id: UUID, workspaceId: UUID) async throws -> Snapshot? { try validateWorkspaceFormatIfPresent(workspaceId) - let url = snapshotURL(id: id, workspaceId: workspaceId) - guard fileManager.fileExists(atPath: url.path) else { - return nil + guard fileManager.fileExists(atPath: workspaceDirectoryURL(workspaceId: workspaceId).path) else { return nil } + return try Self.withSharedLock(at: lifecycleLockURL(workspaceId: workspaceId)) { + try loadSnapshotUnlocked(id: id, workspaceId: workspaceId) } - let manifest = try decoder.decode(SnapshotManifest.self, from: Data(contentsOf: url)) - return Snapshot( - id: manifest.id, - rootPath: manifest.rootPath, - entry: try snapshotEntry(from: manifest.root, workspaceId: workspaceId) - ) } func loadRevisionIndex(id: UUID, workspaceId: UUID) async throws -> RevisionIndex? { try validateWorkspaceFormatIfPresent(workspaceId) - let url = snapshotURL(id: id, workspaceId: workspaceId) - guard fileManager.fileExists(atPath: url.path) else { return nil } - let manifest = try decoder.decode(SnapshotManifest.self, from: Data(contentsOf: url)) - return RevisionIndex( - id: manifest.id, - root: manifest.rootPath, - entry: try revisionEntry(from: manifest.root, workspaceId: workspaceId) - ) + guard fileManager.fileExists(atPath: workspaceDirectoryURL(workspaceId: workspaceId).path) else { return nil } + return try Self.withSharedLock(at: lifecycleLockURL(workspaceId: workspaceId)) { + let url = snapshotURL(id: id, workspaceId: workspaceId) + guard fileManager.fileExists(atPath: url.path) else { return nil } + let manifest = try decoder.decode(SnapshotManifest.self, from: Data(contentsOf: url)) + guard manifest.id == id else { + throw WorkspaceError.storageCorrupted("snapshot manifest id does not match \(id)") + } + return RevisionIndex( + id: manifest.id, + root: manifest.rootPath, + entry: try revisionEntry(from: manifest.root, workspaceId: workspaceId) + ) + } } func readSnapshotFile( @@ -273,22 +372,29 @@ actor FileCheckpointStore: CheckpointStore { if let length, length < 0 { throw WorkspaceError.unsupported("read length must not be negative") } - let url = snapshotURL(id: id, workspaceId: workspaceId) - guard fileManager.fileExists(atPath: url.path) else { return nil } - let manifest = try decoder.decode(SnapshotManifest.self, from: Data(contentsOf: url)) - guard let node = manifestNode(at: path, in: manifest.root), node.kind == .file, - let hash = node.contentHash - else { return nil } - let blob = blobURL(hash: hash, workspaceId: workspaceId) - guard fileManager.fileExists(atPath: blob.path) else { - throw WorkspaceError.storageCorrupted("missing content blob \(hash) for \(path)") + guard fileManager.fileExists(atPath: workspaceDirectoryURL(workspaceId: workspaceId).path) else { return nil } + return try Self.withSharedLock(at: lifecycleLockURL(workspaceId: workspaceId)) { + let url = snapshotURL(id: id, workspaceId: workspaceId) + guard fileManager.fileExists(atPath: url.path) else { return nil } + let manifest = try decoder.decode(SnapshotManifest.self, from: Data(contentsOf: url)) + guard manifest.id == id else { + throw WorkspaceError.storageCorrupted("snapshot manifest id does not match \(id)") + } + guard let node = manifestNode(at: path, in: manifest.root), node.kind == .file else { return nil } + guard let hash = node.contentHash, Self.isValidContentHash(hash) else { + throw WorkspaceError.storageCorrupted("snapshot file node \(node.path) has an invalid content hash") + } + let blob = blobURL(hash: hash, workspaceId: workspaceId) + guard fileManager.fileExists(atPath: blob.path) else { + throw WorkspaceError.storageCorrupted("missing content blob \(hash) for \(path)") + } + let handle = try FileHandle(forReadingFrom: blob) + defer { try? handle.close() } + let size = try handle.seekToEnd() + guard offset < size else { return Data() } + try handle.seek(toOffset: offset) + return try handle.read(upToCount: length ?? Int(size - offset)) ?? Data() } - let handle = try FileHandle(forReadingFrom: blob) - defer { try? handle.close() } - let size = try handle.seekToEnd() - guard offset < size else { return Data() } - try handle.seek(toOffset: offset) - return try handle.read(upToCount: length ?? Int(size - offset)) ?? Data() } private func revisionEntry( @@ -299,8 +405,8 @@ actor FileCheckpointStore: CheckpointStore { case .missing: return .missing(path: node.path) case .file: - guard let hash = node.contentHash else { - throw WorkspaceError.storageCorrupted("snapshot file node \(node.path) has no content hash") + guard let hash = node.contentHash, Self.isValidContentHash(hash) else { + throw WorkspaceError.storageCorrupted("snapshot file node \(node.path) has an invalid content hash") } return .file( path: node.path, @@ -378,8 +484,8 @@ actor FileCheckpointStore: CheckpointStore { case .missing: return .missing(Snapshot.Missing(path: node.path)) case .file: - guard let hash = node.contentHash else { - throw WorkspaceError.storageCorrupted("snapshot file node \(node.path) has no content hash") + guard let hash = node.contentHash, Self.isValidContentHash(hash) else { + throw WorkspaceError.storageCorrupted("snapshot file node \(node.path) has an invalid content hash") } let url = blobURL(hash: hash, workspaceId: workspaceId) guard fileManager.fileExists(atPath: url.path) else { @@ -475,6 +581,233 @@ actor FileCheckpointStore: CheckpointStore { } } + func storageStatistics(workspaceId: UUID) async throws -> Workspace.StorageStatistics { + try validateWorkspaceFormatIfPresent(workspaceId) + guard fileManager.fileExists(atPath: workspaceDirectoryURL(workspaceId: workspaceId).path) else { + return .empty + } + return try Self.withSharedLock(at: lifecycleLockURL(workspaceId: workspaceId)) { + try physicalStatisticsUnlocked(workspaceId: workspaceId) + } + } + + func compact( + workspaceId: UUID, + retaining retention: Workspace.Retention, + dryRun: Bool + ) async throws -> StoreCompactionResult { + try validateWorkspaceFormatIfPresent(workspaceId) + guard fileManager.fileExists(atPath: workspaceDirectoryURL(workspaceId: workspaceId).path) else { + let plan = try CheckpointRetentionPlanner.plan(checkpoints: [], retaining: retention) + return StoreCompactionResult( + report: Workspace.CompactionReport( + dryRun: dryRun, + before: .empty, + after: .empty, + removedCheckpointIDs: plan.removedCheckpointIDs, + rebasedCheckpointIDs: plan.rebasedCheckpointIDs + ), + checkpoints: [] + ) + } + return try Self.withExclusiveLock(at: lifecycleLockURL(workspaceId: workspaceId)) { + try compactUnlocked(workspaceId: workspaceId, retaining: retention, dryRun: dryRun) + } + } + + private func compactUnlocked( + workspaceId: UUID, + retaining retention: Workspace.Retention, + dryRun: Bool + ) throws -> StoreCompactionResult { + let current = try loadAllCheckpointsUnlocked(workspaceId: workspaceId) + let before = try physicalStatisticsUnlocked(workspaceId: workspaceId) + let plan = try CheckpointRetentionPlanner.plan(checkpoints: current, retaining: retention) + let rebasedIDs = Set(plan.rebasedCheckpointIDs) + let retained = try CheckpointRetentionPlanner.materialize(plan) { snapshotID in + try loadSnapshotUnlocked(id: snapshotID, workspaceId: workspaceId) + } + + let retainedSnapshotIDs = Set(retained.map(\.snapshotId)) + var retainedBlobHashes: Set = [] + for checkpoint in retained { + let manifestURL = snapshotURL(id: checkpoint.snapshotId, workspaceId: workspaceId) + guard fileManager.fileExists(atPath: manifestURL.path) else { + throw WorkspaceError.storageCorrupted("checkpoint \(checkpoint.id) has no snapshot") + } + let manifest = try decoder.decode(SnapshotManifest.self, from: Data(contentsOf: manifestURL)) + guard manifest.id == checkpoint.snapshotId else { + throw WorkspaceError.storageCorrupted("checkpoint \(checkpoint.id) references a mismatched snapshot") + } + try collectContentHashes(from: manifest.root, into: &retainedBlobHashes) + } + + var retainedBlobBytes: UInt64 = 0 + for hash in retainedBlobHashes { + let url = blobURL(hash: hash, workspaceId: workspaceId) + guard fileManager.fileExists(atPath: url.path) else { + throw WorkspaceError.storageCorrupted("missing content blob \(hash)") + } + retainedBlobBytes += try fileSize(at: url) + } + let after = Workspace.StorageStatistics( + checkpointCount: retained.count, + snapshotCount: retainedSnapshotIDs.count, + blobCount: retainedBlobHashes.count, + blobBytes: retainedBlobBytes + ) + let report = Workspace.CompactionReport( + dryRun: dryRun, + before: before, + after: after, + removedCheckpointIDs: plan.removedCheckpointIDs, + rebasedCheckpointIDs: plan.rebasedCheckpointIDs + ) + + guard !dryRun else { return StoreCompactionResult(report: report, checkpoints: retained) } + + for checkpoint in retained where rebasedIDs.contains(checkpoint.id) { + try write(checkpoint, to: checkpointURL(id: checkpoint.id, workspaceId: workspaceId)) + } + for id in plan.removedCheckpointIDs { + try removeIfPresent(checkpointURL(id: id, workspaceId: workspaceId)) + } + for url in try artifactURLs(in: snapshotsDirectoryURL(workspaceId: workspaceId), pathExtension: "json") { + guard let id = UUID(uuidString: url.deletingPathExtension().lastPathComponent), + retainedSnapshotIDs.contains(id) + else { + try fileManager.removeItem(at: url) + continue + } + } + for url in try blobURLs(workspaceId: workspaceId) + where !retainedBlobHashes.contains(url.lastPathComponent) { + try fileManager.removeItem(at: url) + } + listCheckpointsCache[workspaceId] = nil + return StoreCompactionResult(report: report, checkpoints: retained) + } + + private func loadAllCheckpointsUnlocked(workspaceId: UUID) throws -> [Checkpoint] { + let directory = checkpointsDirectoryURL(workspaceId: workspaceId) + guard fileManager.fileExists(atPath: directory.path) else { return [] } + return try artifactURLs(in: directory, pathExtension: "json").map { url in + let checkpoint = try read(Checkpoint.self, from: url) + guard UUID(uuidString: url.deletingPathExtension().lastPathComponent) == checkpoint.id, + checkpoint.workspaceID == workspaceId + else { + throw WorkspaceError.storageCorrupted("checkpoint metadata does not match its storage path") + } + return checkpoint + }.sorted(by: Checkpoint.orderedBefore) + } + + private func loadSnapshotUnlocked(id: UUID, workspaceId: UUID) throws -> Snapshot? { + let url = snapshotURL(id: id, workspaceId: workspaceId) + guard fileManager.fileExists(atPath: url.path) else { return nil } + let manifest = try decoder.decode(SnapshotManifest.self, from: Data(contentsOf: url)) + guard manifest.id == id else { + throw WorkspaceError.storageCorrupted("snapshot manifest id does not match \(id)") + } + return Snapshot( + id: manifest.id, + rootPath: manifest.rootPath, + entry: try snapshotEntry(from: manifest.root, workspaceId: workspaceId) + ) + } + + private func resolvedParent(for draft: CheckpointDraft, checkpoints: [Checkpoint]) -> Checkpoint? { + if let preferred = draft.preferredParentID, + let parent = checkpoints.first(where: { $0.id == preferred }) { + return parent + } + guard draft.preferredParentID != nil, + let headID = Checkpoint.lineageHeadID(in: checkpoints) + else { return nil } + return checkpoints.first(where: { $0.id == headID }) + } + + private func physicalStatisticsUnlocked(workspaceId: UUID) throws -> Workspace.StorageStatistics { + let checkpoints = try artifactURLs( + in: checkpointsDirectoryURL(workspaceId: workspaceId), + pathExtension: "json" + ) + let snapshots = try artifactURLs( + in: snapshotsDirectoryURL(workspaceId: workspaceId), + pathExtension: "json" + ) + let blobs = try blobURLs(workspaceId: workspaceId) + var blobBytes: UInt64 = 0 + for blob in blobs { blobBytes += try fileSize(at: blob) } + return Workspace.StorageStatistics( + checkpointCount: checkpoints.count, + snapshotCount: snapshots.count, + blobCount: blobs.count, + blobBytes: blobBytes + ) + } + + private func artifactURLs(in directory: URL, pathExtension: String) throws -> [URL] { + guard fileManager.fileExists(atPath: directory.path) else { return [] } + return try fileManager.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ).filter { $0.pathExtension == pathExtension }.map { url in + let type = try fileManager.attributesOfItem(atPath: url.path)[.type] as? FileAttributeType + guard type == .typeRegular else { + throw WorkspaceError.storageCorrupted("unexpected artifact: \(url.lastPathComponent)") + } + return url + }.sorted { $0.lastPathComponent < $1.lastPathComponent } + } + + private func blobURLs(workspaceId: UUID) throws -> [URL] { + let directory = blobsDirectoryURL(workspaceId: workspaceId) + guard fileManager.fileExists(atPath: directory.path) else { return [] } + return try fileManager.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ).map { url in + let type = try fileManager.attributesOfItem(atPath: url.path)[.type] as? FileAttributeType + guard type == .typeRegular else { + throw WorkspaceError.storageCorrupted("unexpected entry in blob store: \(url.lastPathComponent)") + } + return url + }.sorted { $0.lastPathComponent < $1.lastPathComponent } + } + + private func fileSize(at url: URL) throws -> UInt64 { + let value = try fileManager.attributesOfItem(atPath: url.path)[.size] as? NSNumber + return value?.uint64Value ?? 0 + } + + private func collectContentHashes(from node: SnapshotManifest.Node, into hashes: inout Set) throws { + switch node.kind { + case .file: + guard let hash = node.contentHash, Self.isValidContentHash(hash) else { + throw WorkspaceError.storageCorrupted("snapshot file node \(node.path) has an invalid content hash") + } + hashes.insert(hash) + case .directory: + for child in node.children ?? [] { try collectContentHashes(from: child, into: &hashes) } + case .missing, .symlink: + break + } + } + + private static func isValidContentHash(_ hash: String) -> Bool { + hash.utf8.count == 64 && hash.utf8.allSatisfy { byte in + (UInt8(ascii: "0")...UInt8(ascii: "9")).contains(byte) + || (UInt8(ascii: "a")...UInt8(ascii: "f")).contains(byte) + } + } + + private func removeIfPresent(_ url: URL) throws { + if fileManager.fileExists(atPath: url.path) { try fileManager.removeItem(at: url) } + } + private func loadMutationsFromJSONL(at url: URL) throws -> [Mutation] { let data = try Data(contentsOf: url) if data.isEmpty { return [] } @@ -688,6 +1021,11 @@ actor FileCheckpointStore: CheckpointStore { workspaceDirectoryURL(workspaceId: workspaceId).appendingPathComponent("mutations.lock", isDirectory: false) } + /// Serializes checkpoint, manifest, and blob lifecycle operations across store instances. + private func lifecycleLockURL(workspaceId: UUID) -> URL { + workspaceDirectoryURL(workspaceId: workspaceId).appendingPathComponent("lifecycle.lock", isDirectory: false) + } + private func formatLockURL(workspaceId: UUID) -> URL { rootDirectory.appendingPathComponent(".\(workspaceId.uuidString).format.lock", isDirectory: false) } @@ -711,13 +1049,21 @@ actor FileCheckpointStore: CheckpointStore { #if canImport(Darwin) || canImport(Glibc) private static func withExclusiveLock(at url: URL, _ body: () throws -> R) throws -> R { + try withLock(at: url, operation: LOCK_EX, body) + } + + private static func withSharedLock(at url: URL, _ body: () throws -> R) throws -> R { + try withLock(at: url, operation: LOCK_SH, body) + } + + private static func withLock(at url: URL, operation: Int32, _ body: () throws -> R) throws -> R { let path = url.path let fd = open(path, O_RDWR | O_CREAT, 0o644) guard fd >= 0 else { throw LockFileError(message: "could not open lock file at \(path)") } defer { close(fd) } - while flock(fd, LOCK_EX) != 0 { + while flock(fd, operation) != 0 { if errno != EINTR { throw LockFileError(message: "could not acquire exclusive lock at \(path)") } @@ -729,5 +1075,9 @@ actor FileCheckpointStore: CheckpointStore { private static func withExclusiveLock(at url: URL, _ body: () throws -> R) throws -> R { try body() } + + private static func withSharedLock(at url: URL, _ body: () throws -> R) throws -> R { + try body() + } #endif } diff --git a/Sources/Workspace/Workspace+Checkpoints.swift b/Sources/Workspace/Workspace+Checkpoints.swift index 2cd9ab1..e6a8f2b 100644 --- a/Sources/Workspace/Workspace+Checkpoints.swift +++ b/Sources/Workspace/Workspace+Checkpoints.swift @@ -46,13 +46,13 @@ extension Workspace { /// Returns one checkpoint owned by this workspace when present. public func checkpoint(id: UUID) async throws -> Checkpoint? { - try await ensureLoaded() + try await reconcileCheckpointsWithStore() return checkpoints.first(where: { $0.id == id }) } /// Lists this workspace's checkpoints in stable creation order. public func checkpoints() async throws -> [Checkpoint] { - try await ensureLoaded() + try await reconcileCheckpointsWithStore() return checkpoints } diff --git a/Sources/Workspace/Workspace+Internals.swift b/Sources/Workspace/Workspace+Internals.swift index 02438b7..4bfba1f 100644 --- a/Sources/Workspace/Workspace+Internals.swift +++ b/Sources/Workspace/Workspace+Internals.swift @@ -22,28 +22,14 @@ extension Workspace { func loadStoreState() async throws { checkpoints = try await store.listCheckpoints(workspaceId: workspaceId) mutations = try await store.listMutations(workspaceId: workspaceId) - headCheckpointId = Self.lineageHeadId(in: checkpoints) + headCheckpointId = Checkpoint.lineageHeadID(in: checkpoints) didLoadStoreState = true } func reconcileCheckpointsWithStore() async throws { try await ensureLoaded() checkpoints = try await store.listCheckpoints(workspaceId: workspaceId).sorted(by: checkpointSort) - headCheckpointId = Self.lineageHeadId(in: checkpoints) - } - - static func lineageHeadId(in checkpoints: [Checkpoint]) -> UUID? { - guard !checkpoints.isEmpty else { return nil } - let referencedParents = Set(checkpoints.compactMap(\.parentID)) - let tips = checkpoints.filter { !referencedParents.contains($0.id) } - return (tips.isEmpty ? checkpoints : tips).max(by: orderCheckpoints)?.id - } - - static func orderCheckpoints(_ lhs: Checkpoint, _ rhs: Checkpoint) -> Bool { - if lhs.createdAt == rhs.createdAt { - return lhs.id.uuidString < rhs.id.uuidString - } - return lhs.createdAt < rhs.createdAt + headCheckpointId = Checkpoint.lineageHeadID(in: checkpoints) } func checkpointOrThrow(id: UUID) throws -> Checkpoint { @@ -65,48 +51,22 @@ extension Workspace { snapshot: Snapshot, label: String?, parentCheckpointId: UUID?, - origin: Checkpoint.Origin, - comparisonSnapshot: Snapshot? = nil + origin: Checkpoint.Origin ) async throws -> Checkpoint { - try await store.saveSnapshot(snapshot, workspaceId: workspaceId) - - let parent = parentCheckpointId.flatMap { id in checkpoints.first(where: { $0.id == id }) } - let previous: Snapshot? - if let comparisonSnapshot { - previous = comparisonSnapshot - } else if let parent { - previous = try? await store.loadSnapshot(id: parent.snapshotId, workspaceId: workspaceId) - } else { - previous = nil - } - let baseline = previous ?? Snapshot( - rootPath: snapshot.rootPath, - entry: .missing(.init(path: snapshot.rootPath)) - ) - let summary = ChangeSet.compare( - before: baseline, - after: snapshot, - maxTextBytes: 1_000_000 - ).summary - - let previousCursor = parent?.mutationCursor ?? 0 - let currentCursor = latestMutationSequence() - let checkpoint = Checkpoint( - workspaceId: workspaceId, - label: label, - parentCheckpointId: parentCheckpointId, - origin: origin, - firstMutationSequence: currentCursor > previousCursor ? previousCursor + 1 : nil, - lastMutationSequence: currentCursor > previousCursor ? currentCursor : nil, - mutationCursor: currentCursor, - snapshotId: snapshot.id, - summary: summary + let checkpoint = try await store.saveRevision( + snapshot, + draft: CheckpointDraft( + workspaceID: workspaceId, + label: label, + preferredParentID: parentCheckpointId, + origin: origin, + mutationCursor: latestMutationSequence() + ) ) - - try await store.saveCheckpoint(checkpoint) + checkpoints.removeAll { $0.id == checkpoint.id } checkpoints.append(checkpoint) checkpoints.sort(by: checkpointSort) - headCheckpointId = checkpoint.id + headCheckpointId = Checkpoint.lineageHeadID(in: checkpoints) emitWorkspaceEvent(.checkpoint(checkpoint)) return checkpoint } @@ -154,11 +114,8 @@ extension Workspace { let newCheckpoints = stored.filter { candidate in !checkpoints.contains(where: { $0.id == candidate.id }) } - guard !newCheckpoints.isEmpty else { return } - - checkpoints.append(contentsOf: newCheckpoints) - checkpoints.sort(by: checkpointSort) - headCheckpointId = Self.lineageHeadId(in: checkpoints) + checkpoints = stored.sorted(by: checkpointSort) + headCheckpointId = Checkpoint.lineageHeadID(in: checkpoints) if let refreshed = try? await store.listMutations(workspaceId: workspaceId) { mutations = refreshed.sorted { $0.sequence < $1.sequence } } @@ -168,6 +125,6 @@ extension Workspace { } func checkpointSort(lhs: Checkpoint, rhs: Checkpoint) -> Bool { - Self.orderCheckpoints(lhs, rhs) + Checkpoint.orderedBefore(lhs, rhs) } } diff --git a/Sources/Workspace/WorkspaceCompaction.swift b/Sources/Workspace/WorkspaceCompaction.swift new file mode 100644 index 0000000..81e1367 --- /dev/null +++ b/Sources/Workspace/WorkspaceCompaction.swift @@ -0,0 +1,215 @@ +import Foundation + +extension Workspace { + /// Which checkpoints remain after storage compaction. + public enum Retention: Sendable, Codable, Equatable { + /// Keep every checkpoint while still sweeping orphaned snapshots and blobs. + case all + /// Keep the newest `count` checkpoints plus optionally labeled and explicitly selected checkpoints. + case latest( + Int, + preservingLabeled: Bool = true, + preserving: Set = [] + ) + } + + /// Physical checkpoint-store usage. Blob bytes exclude manifests and checkpoint metadata. + public struct StorageStatistics: Sendable, Codable, Equatable { + public var checkpointCount: Int + public var snapshotCount: Int + public var blobCount: Int + public var blobBytes: UInt64 + + public init(checkpointCount: Int, snapshotCount: Int, blobCount: Int, blobBytes: UInt64) { + self.checkpointCount = checkpointCount + self.snapshotCount = snapshotCount + self.blobCount = blobCount + self.blobBytes = blobBytes + } + + static let empty = StorageStatistics(checkpointCount: 0, snapshotCount: 0, blobCount: 0, blobBytes: 0) + } + + /// The applied or projected result of compacting checkpoint storage. + public struct CompactionReport: Sendable, Codable, Equatable { + public var dryRun: Bool + public var before: StorageStatistics + public var after: StorageStatistics + public var removedCheckpointIDs: [UUID] + public var rebasedCheckpointIDs: [UUID] + + public var removedCheckpointCount: Int { removedCheckpointIDs.count } + public var removedSnapshotCount: Int { max(0, before.snapshotCount - after.snapshotCount) } + public var removedBlobCount: Int { max(0, before.blobCount - after.blobCount) } + public var reclaimedBytes: UInt64 { before.blobBytes >= after.blobBytes ? before.blobBytes - after.blobBytes : 0 } + + public init( + dryRun: Bool, + before: StorageStatistics, + after: StorageStatistics, + removedCheckpointIDs: [UUID], + rebasedCheckpointIDs: [UUID] + ) { + self.dryRun = dryRun + self.before = before + self.after = after + self.removedCheckpointIDs = removedCheckpointIDs + self.rebasedCheckpointIDs = rebasedCheckpointIDs + } + } + + /// Returns current physical checkpoint-store usage. + public func storageStatistics() async throws -> StorageStatistics { + try await store.storageStatistics(workspaceId: workspaceId) + } + + /// Prunes checkpoints, rebases retained lineage, and sweeps unreachable snapshots and blobs. + /// + /// A dry run performs the same validation and returns projected statistics without changing storage. + public func compact(retaining retention: Retention, dryRun: Bool = false) async throws -> CompactionReport { + try await ensureLoaded() + let result = try await store.compact(workspaceId: workspaceId, retaining: retention, dryRun: dryRun) + if !dryRun { + checkpoints = result.checkpoints + headCheckpointId = Checkpoint.lineageHeadID(in: checkpoints) + } + return result.report + } +} + +struct StoreCompactionResult: Sendable { + var report: Workspace.CompactionReport + var checkpoints: [Checkpoint] +} + +struct CheckpointRetentionPlan: Sendable { + var checkpoints: [Checkpoint] + var removedCheckpointIDs: [UUID] + var rebasedCheckpointIDs: [UUID] +} + +enum CheckpointRetentionPlanner { + static func plan( + checkpoints: [Checkpoint], + retaining retention: Workspace.Retention + ) throws -> CheckpointRetentionPlan { + let ordered = checkpoints.sorted(by: Checkpoint.orderedBefore) + var byID: [UUID: Checkpoint] = [:] + for checkpoint in ordered { + guard byID.updateValue(checkpoint, forKey: checkpoint.id) == nil else { + throw WorkspaceError.storageCorrupted("duplicate checkpoint id \(checkpoint.id)") + } + } + try validateAcyclicLineage(checkpoints: ordered, checkpointsByID: byID) + var retainedIDs: Set + + switch retention { + case .all: + retainedIDs = Set(byID.keys) + case let .latest(count, preservingLabeled, explicitlyPreserved): + guard count > 0 else { + throw WorkspaceError.unsupported("checkpoint retention count must be positive") + } + for id in explicitlyPreserved.sorted(by: { $0.uuidString < $1.uuidString }) where byID[id] == nil { + throw WorkspaceError.checkpointNotFound(id) + } + retainedIDs = Set(ordered.suffix(count).map(\.id)) + retainedIDs.formUnion(explicitlyPreserved) + if preservingLabeled { + retainedIDs.formUnion(ordered.lazy.filter { $0.label != nil }.map(\.id)) + } + } + + // A retained rollback or transaction keeps its explicitly referenced provenance checkpoint. + var changed = true + while changed { + changed = false + for id in Array(retainedIDs) { + guard let checkpoint = byID[id], let provenanceID = checkpoint.provenanceCheckpointID, + byID[provenanceID] != nil + else { continue } + if retainedIDs.insert(provenanceID).inserted { changed = true } + } + } + + var retained: [Checkpoint] = [] + var rebasedIDs: [UUID] = [] + for checkpoint in ordered where retainedIDs.contains(checkpoint.id) { + var copy = checkpoint + let parentID = nearestRetainedParent( + of: checkpoint, + retainedIDs: retainedIDs, + checkpointsByID: byID + ) + if parentID != checkpoint.parentID { + copy.parentID = parentID + rebasedIDs.append(copy.id) + } + retained.append(copy) + } + + return CheckpointRetentionPlan( + checkpoints: retained, + removedCheckpointIDs: ordered.filter { !retainedIDs.contains($0.id) }.map(\.id), + rebasedCheckpointIDs: rebasedIDs + ) + } + + static func materialize( + _ plan: CheckpointRetentionPlan, + loadSnapshot: (UUID) throws -> Snapshot? + ) throws -> [Checkpoint] { + let rebasedIDs = Set(plan.rebasedCheckpointIDs) + var retained = plan.checkpoints + var retainedByID = Dictionary(uniqueKeysWithValues: retained.map { ($0.id, $0) }) + + for index in retained.indices { + let checkpoint = retained[index] + guard let snapshot = try loadSnapshot(checkpoint.snapshotId) else { + throw WorkspaceError.storageCorrupted("checkpoint \(checkpoint.id) has no snapshot") + } + guard rebasedIDs.contains(checkpoint.id) else { continue } + let parent = checkpoint.parentID.flatMap { retainedByID[$0] } + let parentSnapshot = try parent.map { + guard let snapshot = try loadSnapshot($0.snapshotId) else { + throw WorkspaceError.storageCorrupted("checkpoint \($0.id) has no snapshot") + } + return snapshot + } + let rebased = checkpoint.rebased(to: parent, snapshot: snapshot, parentSnapshot: parentSnapshot) + retained[index] = rebased + retainedByID[rebased.id] = rebased + } + return retained + } + + private static func nearestRetainedParent( + of checkpoint: Checkpoint, + retainedIDs: Set, + checkpointsByID: [UUID: Checkpoint] + ) -> UUID? { + var candidate = checkpoint.parentID + var visited: Set = [] + while let id = candidate, visited.insert(id).inserted { + if retainedIDs.contains(id) { return id } + candidate = checkpointsByID[id]?.parentID + } + return nil + } + + private static func validateAcyclicLineage( + checkpoints: [Checkpoint], + checkpointsByID: [UUID: Checkpoint] + ) throws { + for checkpoint in checkpoints { + var candidate: UUID? = checkpoint.id + var visited: Set = [] + while let id = candidate, let current = checkpointsByID[id] { + guard visited.insert(id).inserted else { + throw WorkspaceError.storageCorrupted("checkpoint lineage contains a cycle at \(id)") + } + candidate = current.parentID + } + } + } +} diff --git a/Tests/WorkspaceTests/CheckpointStoreTests.swift b/Tests/WorkspaceTests/CheckpointStoreTests.swift index 2a93ec9..56ed289 100644 --- a/Tests/WorkspaceTests/CheckpointStoreTests.swift +++ b/Tests/WorkspaceTests/CheckpointStoreTests.swift @@ -10,10 +10,7 @@ struct CheckpointStoreTests { let firstID = UUID() let secondID = UUID() let snapshot = Self.sampleSnapshot() - let checkpoint = Self.sampleCheckpoint(workspaceID: firstID, snapshotID: snapshot.id) - - try await store.saveSnapshot(snapshot, workspaceId: firstID) - try await store.saveCheckpoint(checkpoint) + let checkpoint = try await store.saveRevision(snapshot, draft: Self.sampleDraft(workspaceID: firstID)) let first = try await store.appendMutation(Self.sampleMutation(workspaceID: firstID, path: "/a")) let second = try await store.appendMutation(Self.sampleMutation(workspaceID: firstID, path: "/b")) @@ -45,10 +42,8 @@ struct CheckpointStoreTests { ) ) ) - let checkpoint = Self.sampleCheckpoint(workspaceID: workspaceID, snapshotID: snapshot.id) let writer = FileCheckpointStore(rootDirectory: root) - try await writer.saveSnapshot(snapshot, workspaceId: workspaceID) - try await writer.saveCheckpoint(checkpoint) + let checkpoint = try await writer.saveRevision(snapshot, draft: Self.sampleDraft(workspaceID: workspaceID)) let reader = FileCheckpointStore(rootDirectory: root) #expect(try await reader.loadSnapshot(id: snapshot.id, workspaceId: workspaceID) == snapshot) @@ -139,16 +134,13 @@ struct CheckpointStoreTests { ) } - private static func sampleCheckpoint(workspaceID: UUID, snapshotID: UUID) -> Checkpoint { - Checkpoint( - workspaceId: workspaceID, + private static func sampleDraft(workspaceID: UUID) -> CheckpointDraft { + CheckpointDraft( + workspaceID: workspaceID, label: "test", - parentCheckpointId: nil, - firstMutationSequence: nil, - lastMutationSequence: nil, - mutationCursor: 0, - snapshotId: snapshotID, - summary: ChangeSet().summary + preferredParentID: nil, + origin: .manual, + mutationCursor: 0 ) } diff --git a/Tests/WorkspaceTests/CompactionTests.swift b/Tests/WorkspaceTests/CompactionTests.swift new file mode 100644 index 0000000..4cad2b6 --- /dev/null +++ b/Tests/WorkspaceTests/CompactionTests.swift @@ -0,0 +1,269 @@ +import Foundation +import Testing +@testable import Workspace + +@Suite("Storage compaction") +struct CompactionTests { + @Test + func `dry run projects the same pruning without changing storage`() async throws { + let root = try TestSupport.temporaryDirectory("CompactionDryRun") + defer { TestSupport.remove(root) } + let workspace = Workspace(workspaceID: UUID(), persistence: .directory(root)) + + try await workspace.writeText("/note", "one") + let first = try await workspace.createCheckpoint() + try await workspace.writeText("/note", "two") + let second = try await workspace.createCheckpoint() + try await workspace.writeText("/note", "three") + let third = try await workspace.createCheckpoint() + + let preview = try await workspace.compact( + retaining: .latest(1, preservingLabeled: false), + dryRun: true + ) + #expect(preview.dryRun) + #expect(Set(preview.removedCheckpointIDs) == [first.id, second.id]) + #expect(preview.rebasedCheckpointIDs == [third.id]) + #expect(preview.before.checkpointCount == 3) + #expect(preview.after.checkpointCount == 1) + #expect(try await workspace.checkpoints().count == 3) + #expect(try await workspace.storageStatistics() == preview.before) + + let applied = try await workspace.compact(retaining: .latest(1, preservingLabeled: false)) + #expect(!applied.dryRun) + #expect(applied.after == preview.after) + #expect(applied.removedCheckpointIDs == preview.removedCheckpointIDs) + #expect(try await workspace.storageStatistics() == applied.after) + #expect(try await workspace.checkpoints().map(\.id) == [third.id]) + #expect(try await workspace.checkpoint(id: third.id)?.parentID == nil) + #expect(try await workspace.history().count == 3) + await #expect(throws: WorkspaceError.self) { + _ = try await workspace.readText("/note", at: .checkpoint(first.id)) + } + } + + @Test + func `latest retention preserves labeled and selected checkpoints and rebases lineage`() async throws { + let workspace = Workspace() + try await workspace.writeText("/note", "one") + let labeled = try await workspace.createCheckpoint(label: "keep") + try await workspace.writeText("/note", "two") + let selected = try await workspace.createCheckpoint() + let removed = try await workspace.restore(to: labeled) + try await workspace.writeText("/note", "four") + let latest = try await workspace.createCheckpoint() + + let report = try await workspace.compact( + retaining: .latest(1, preservingLabeled: true, preserving: [selected.id]) + ) + #expect(report.removedCheckpointIDs == [removed.id]) + #expect(report.rebasedCheckpointIDs == [latest.id]) + let retained = try await workspace.checkpoints() + #expect(Set(retained.map(\.id)) == [labeled.id, selected.id, latest.id]) + #expect(retained.first(where: { $0.id == latest.id })?.parentID == selected.id) + } + + @Test + func `retained rollback keeps its provenance and recomputes its summary`() async throws { + let workspace = Workspace() + try await workspace.writeText("/note", "one") + let source = try await workspace.createCheckpoint() + try await workspace.writeText("/note", "two") + let middle = try await workspace.createCheckpoint() + let rollback = try await workspace.restore(to: source) + #expect(rollback.summary.changedPathCount == 1) + + let report = try await workspace.compact(retaining: .latest(1, preservingLabeled: false)) + #expect(report.removedCheckpointIDs == [middle.id]) + #expect(report.rebasedCheckpointIDs == [rollback.id]) + let retained = try await workspace.checkpoints() + #expect(Set(retained.map(\.id)) == [source.id, rollback.id]) + let rebased = try #require(retained.first(where: { $0.id == rollback.id })) + #expect(rebased.parentID == source.id) + #expect(rebased.summary.changedPathCount == 0) + } + + @Test + func `fork retention deterministically keeps the latest and explicitly selected tips`() async throws { + let workspaceID = UUID() + let store = InMemoryCheckpointStore() + let seed = try await store.saveRevision( + Self.snapshot(text: "seed"), + draft: Self.draft(workspaceID: workspaceID, parentID: nil) + ) + let firstTip = try await store.saveRevision( + Self.snapshot(text: "first"), + draft: Self.draft(workspaceID: workspaceID, parentID: seed.id) + ) + let secondTip = try await store.saveRevision( + Self.snapshot(text: "second"), + draft: Self.draft(workspaceID: workspaceID, parentID: seed.id) + ) + let tips = [firstTip, secondTip].sorted(by: Checkpoint.orderedBefore) + let selected = try #require(tips.first) + let latest = try #require(tips.last) + + let result = try await store.compact( + workspaceId: workspaceID, + retaining: .latest(1, preservingLabeled: false, preserving: [selected.id]), + dryRun: false + ) + #expect(result.report.removedCheckpointIDs == [seed.id]) + #expect(Set(result.report.rebasedCheckpointIDs) == [selected.id, latest.id]) + #expect(Set(result.checkpoints.map(\.id)) == [selected.id, latest.id]) + #expect(result.checkpoints.allSatisfy { $0.parentID == nil }) + } + + @Test + func `all retention sweeps orphan manifests and blobs`() async throws { + let root = try TestSupport.temporaryDirectory("CompactionOrphans") + defer { TestSupport.remove(root) } + let workspaceID = UUID() + let workspace = Workspace(workspaceID: workspaceID, persistence: .directory(root)) + try await workspace.writeText("/note", "kept") + _ = try await workspace.createCheckpoint() + + let workspaceRoot = root.appendingPathComponent(workspaceID.uuidString) + let orphanManifest = workspaceRoot.appendingPathComponent("snapshots") + .appendingPathComponent("\(UUID().uuidString).json") + let orphanBlob = workspaceRoot.appendingPathComponent("blobs").appendingPathComponent("orphan") + try Data("{}".utf8).write(to: orphanManifest) + try Data("garbage".utf8).write(to: orphanBlob) + + let report = try await workspace.compact(retaining: .all) + #expect(report.removedCheckpointCount == 0) + #expect(report.removedSnapshotCount == 1) + #expect(report.removedBlobCount == 1) + #expect(report.reclaimedBytes == UInt64(Data("garbage".utf8).count)) + #expect(!FileManager.default.fileExists(atPath: orphanManifest.path)) + #expect(!FileManager.default.fileExists(atPath: orphanBlob.path)) + #expect(try await workspace.readText("/note") == "kept") + } + + @Test + func `missing retained content aborts compaction before deletion`() async throws { + let root = try TestSupport.temporaryDirectory("CompactionCorrupt") + defer { TestSupport.remove(root) } + let workspaceID = UUID() + let workspace = Workspace(workspaceID: workspaceID, persistence: .directory(root)) + try await workspace.writeText("/note", "kept") + _ = try await workspace.createCheckpoint() + + let workspaceRoot = root.appendingPathComponent(workspaceID.uuidString) + let orphan = workspaceRoot.appendingPathComponent("blobs").appendingPathComponent("orphan") + try Data("orphan".utf8).write(to: orphan) + let keptHash = SHA256.hexDigest(of: Data("kept".utf8)) + try FileManager.default.removeItem( + at: workspaceRoot.appendingPathComponent("blobs").appendingPathComponent(keptHash) + ) + + await #expect(throws: WorkspaceError.self) { + _ = try await workspace.compact(retaining: .all) + } + #expect(FileManager.default.fileExists(atPath: orphan.path)) + #expect(try await workspace.checkpoints().count == 1) + } + + @Test + func `checkpoint commits and compaction serialize across store instances`() async throws { + let root = try TestSupport.temporaryDirectory("CompactionConcurrent") + defer { TestSupport.remove(root) } + let workspaceID = UUID() + let first = FileCheckpointStore(rootDirectory: root) + let second = FileCheckpointStore(rootDirectory: root) + let compactor = FileCheckpointStore(rootDirectory: root) + let seedSnapshot = Self.snapshot(text: "seed") + let seed = try await first.saveRevision( + seedSnapshot, + draft: Self.draft(workspaceID: workspaceID, parentID: nil) + ) + + try await withThrowingTaskGroup(of: Void.self) { group in + for index in 0..<16 { + if index.isMultiple(of: 3) { + group.addTask { + _ = try await compactor.compact( + workspaceId: workspaceID, + retaining: .latest(1, preservingLabeled: false), + dryRun: false + ) + } + } else { + let store = index.isMultiple(of: 2) ? first : second + group.addTask { + _ = try await store.saveRevision( + Self.snapshot(text: "value-\(index)"), + draft: Self.draft(workspaceID: workspaceID, parentID: seed.id) + ) + } + } + } + try await group.waitForAll() + } + + _ = try await compactor.compact(workspaceId: workspaceID, retaining: .all, dryRun: false) + let reader = FileCheckpointStore(rootDirectory: root) + let checkpoints = try await reader.listCheckpoints(workspaceId: workspaceID) + let statistics = try await reader.storageStatistics(workspaceId: workspaceID) + #expect(!checkpoints.isEmpty) + #expect(statistics.checkpointCount == checkpoints.count) + #expect(statistics.snapshotCount == checkpoints.count) + for checkpoint in checkpoints { + #expect(try await reader.loadSnapshot(id: checkpoint.snapshotId, workspaceId: workspaceID) != nil) + } + } + + @Test + func `workspace instances reconcile checkpoints removed by another instance`() async throws { + let root = try TestSupport.temporaryDirectory("CompactionReconcile") + defer { TestSupport.remove(root) } + let workspaceID = UUID() + let writer = Workspace(workspaceID: workspaceID, persistence: .directory(root)) + try await writer.writeText("/note", "one") + _ = try await writer.createCheckpoint() + try await writer.writeText("/note", "two") + let latest = try await writer.createCheckpoint() + + let reader = Workspace(workspaceID: workspaceID, persistence: .directory(root)) + #expect(try await reader.checkpoints().count == 2) + _ = try await writer.compact(retaining: .latest(1, preservingLabeled: false)) + #expect(try await reader.checkpoints().map(\.id) == [latest.id]) + } + + @Test + func `invalid retention is rejected without creating storage`() async throws { + let workspace = Workspace() + await #expect(throws: WorkspaceError.self) { + _ = try await workspace.compact(retaining: .latest(0)) + } + await #expect(throws: WorkspaceError.self) { + _ = try await workspace.compact(retaining: .latest(1, preserving: [UUID()])) + } + #expect(try await workspace.storageStatistics() == .empty) + } + + private static func snapshot(text: String) -> Snapshot { + Snapshot( + rootPath: .root, + entry: .directory( + .init( + path: .root, + permissions: .defaultDirectory, + children: [ + .file(.init(path: "/note", data: Data(text.utf8), permissions: .defaultFile)) + ] + ) + ) + ) + } + + private static func draft(workspaceID: UUID, parentID: UUID?) -> CheckpointDraft { + CheckpointDraft( + workspaceID: workspaceID, + label: nil, + preferredParentID: parentID, + origin: .manual, + mutationCursor: 0 + ) + } +} From 39c90a07b558e844335ee2ed6b6d866075003c05 Mon Sep 17 00:00:00 2001 From: Zac White Date: Sun, 12 Jul 2026 15:13:52 -0700 Subject: [PATCH 3/8] Add unified patch rendering --- Sources/Workspace/UnifiedPatch.swift | 70 ++++++++++++++++++++ Tests/WorkspaceTests/UnifiedPatchTests.swift | 39 +++++++++++ 2 files changed, 109 insertions(+) create mode 100644 Sources/Workspace/UnifiedPatch.swift create mode 100644 Tests/WorkspaceTests/UnifiedPatchTests.swift diff --git a/Sources/Workspace/UnifiedPatch.swift b/Sources/Workspace/UnifiedPatch.swift new file mode 100644 index 0000000..5a4ed95 --- /dev/null +++ b/Sources/Workspace/UnifiedPatch.swift @@ -0,0 +1,70 @@ +import Foundation + +extension TextDiff { + /// Renders this text diff as a unified patch suitable for logs, prompts, and patch interop. + public func unifiedPatch(oldPath: String, newPath: String) -> String { + var output = "--- \(oldPath)\n+++ \(newPath)\n" + for hunk in hunks { + output += "@@ -\(rangeDescription(start: hunk.oldStartLine, count: hunk.oldLineCount))" + output += " +\(rangeDescription(start: hunk.newStartLine, count: hunk.newLineCount)) @@\n" + for line in hunk.lines { + output.append(line.kind.patchPrefix) + output += line.text + output.append("\n") + if !line.hasTrailingNewline { + output += "\\ No newline at end of file\n" + } + } + } + return output + } + + private func rangeDescription(start: Int, count: Int) -> String { + let normalizedStart = count == 0 ? max(0, start - 1) : start + return count == 1 ? "\(normalizedStart)" : "\(normalizedStart),\(count)" + } +} + +extension ChangeSet { + /// Renders every available textual file change as a git-style unified patch. + /// + /// Binary files, size-limited files, and metadata-only changes remain represented by + /// ``changes`` and ``textDiffOmissions`` but do not produce patch sections. + public func unifiedPatch() -> String { + changes.compactMap { change in + guard let diff = change.diff else { return nil } + let oldPath: String + let newPath: String + switch change.effect { + case .created: + oldPath = "/dev/null" + newPath = patchPath(change.path, prefix: "b") + case .deleted: + oldPath = patchPath(change.path, prefix: "a") + newPath = "/dev/null" + case .modified: + oldPath = patchPath(change.path, prefix: "a") + newPath = patchPath(change.path, prefix: "b") + case .moved, .copied: + oldPath = patchPath(change.sourcePath ?? change.path, prefix: "a") + newPath = patchPath(change.path, prefix: "b") + } + return diff.unifiedPatch(oldPath: oldPath, newPath: newPath) + }.joined(separator: "\n") + } + + private func patchPath(_ path: WorkspacePath, prefix: String) -> String { + let value = path.string == "/" ? "" : String(path.string.drop(while: { $0 == "/" })) + return value.isEmpty ? prefix : "\(prefix)/\(value)" + } +} + +private extension TextDiff.Line.Kind { + var patchPrefix: Character { + switch self { + case .context: " " + case .added: "+" + case .removed: "-" + } + } +} diff --git a/Tests/WorkspaceTests/UnifiedPatchTests.swift b/Tests/WorkspaceTests/UnifiedPatchTests.swift new file mode 100644 index 0000000..d29faa0 --- /dev/null +++ b/Tests/WorkspaceTests/UnifiedPatchTests.swift @@ -0,0 +1,39 @@ +import Testing +@testable import Workspace + +@Suite("Unified patch") +struct UnifiedPatchTests { + @Test + func `text diff renders standard headers hunks and newline markers`() { + let diff = TextDiff.lineBased(from: "hello world", to: "hello Swift") + + #expect( + diff.unifiedPatch(oldPath: "a/note.txt", newPath: "b/note.txt") == """ + --- a/note.txt + +++ b/note.txt + @@ -1 +1 @@ + -hello world + \\ No newline at end of file + +hello Swift + \\ No newline at end of file + + """ + ) + } + + @Test + func `changeset uses dev null for file creation and deletion`() { + let created = TextDiff.lineBased(from: "", to: "new\n") + let deleted = TextDiff.lineBased(from: "old\n", to: "") + let changes = ChangeSet(changes: [ + .init(path: "/created.txt", kind: .file, effect: .created, diff: created), + .init(path: "/deleted.txt", kind: .file, effect: .deleted, diff: deleted), + .init(path: "/binary.dat", kind: .file, effect: .modified), + ]) + + let patch = changes.unifiedPatch() + #expect(patch.contains("--- /dev/null\n+++ b/created.txt\n@@ -0,0 +1 @@\n+new\n")) + #expect(patch.contains("--- a/deleted.txt\n+++ /dev/null\n@@ -1 +0,0 @@\n-old\n")) + #expect(!patch.contains("binary.dat")) + } +} From e14724d137d52eeafbc27bfd24d3ae5435a747d2 Mon Sep 17 00:00:00 2001 From: Zac White Date: Sun, 12 Jul 2026 15:16:20 -0700 Subject: [PATCH 4/8] Expose the workspace filesystem authority --- README.md | 6 +++++- Sources/Workspace/Workspace.swift | 5 +++++ Tests/WorkspaceTests/WorkspaceAPITests.swift | 11 +++++++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a0d0bd1..1246c1d 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,11 @@ let workspace = Workspace( ) ``` -The injected filesystem is intentionally not exposed from `Workspace`; mutations through it would bypass workspace history and events. +The injected filesystem remains available as `workspace.fileSystem` so one `Workspace` can be +shared as the filesystem authority across shells, tool runtimes, and other adapters. Reads through +that reference are always fine. Mutations made directly through it bypass Workspace history, +checkpoints, and events, so integrations should either route mutations through the Workspace edit +pipeline or deliberately accept that those changes are unrecorded. ## Edits and Changes diff --git a/Sources/Workspace/Workspace.swift b/Sources/Workspace/Workspace.swift index 04ba5c6..f06287a 100644 --- a/Sources/Workspace/Workspace.swift +++ b/Sources/Workspace/Workspace.swift @@ -25,6 +25,11 @@ public actor Workspace { nonisolated let workspaceId: UUID public nonisolated var workspaceID: UUID { workspaceId } nonisolated let filesystem: any FileSystem + /// The filesystem that owns this workspace's current state. + /// + /// Direct reads are safe. Direct mutations bypass Workspace history, checkpoints, and events; + /// integrations should use the Workspace edit pipeline when those records are required. + public nonisolated var fileSystem: any FileSystem { filesystem } public nonisolated let recording: RecordingPolicy let store: any CheckpointStore diff --git a/Tests/WorkspaceTests/WorkspaceAPITests.swift b/Tests/WorkspaceTests/WorkspaceAPITests.swift index 595a471..294840b 100644 --- a/Tests/WorkspaceTests/WorkspaceAPITests.swift +++ b/Tests/WorkspaceTests/WorkspaceAPITests.swift @@ -4,6 +4,17 @@ import Testing @Suite("Workspace API") struct WorkspaceAPITests { + @Test + func `workspace exposes its injected filesystem authority`() async throws { + let files = InMemoryFileSystem() + let workspace = Workspace(fileSystem: files) + + try await workspace.fileSystem.writeFile(path: "/direct.txt", data: Data("direct".utf8), append: false) + + #expect(try await workspace.readText("/direct.txt") == "direct") + #expect(try await workspace.history().isEmpty) + } + @Test func `one changeset flows through preview apply events and history`() async throws { let workspace = Workspace() From 88840e6c7b9d249c22af798c5f9e51eb4f1fba00 Mon Sep 17 00:00:00 2001 From: Zac White Date: Sun, 12 Jul 2026 15:17:59 -0700 Subject: [PATCH 5/8] Capture unchanged checkpoint files lazily --- README.md | 5 + Sources/Workspace/Checkpoint.swift | 20 ++ Sources/Workspace/CheckpointStore.swift | 213 +++++++++++++++++- Sources/Workspace/Workspace+Checkpoints.swift | 20 +- Tests/WorkspaceTests/PersistenceTests.swift | 72 ++++++ 5 files changed, 322 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 1246c1d..f428f37 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,11 @@ Compaction rebases retained checkpoints to their nearest retained ancestor and r Directory-backed stores serialize checkpoint commits, revision reads, and compaction with a persistent lifecycle lock. Mutation history is independent and is not pruned by checkpoint retention. +Directory-backed checkpoint capture also reuses existing CAS blobs when a file's size and available +modification timestamp are unchanged. It still walks directory metadata for correctness, but only +reads and hashes file contents that appear changed. Filesystems that do not provide modification +timestamps safely fall back to reading those files. + ## Transactions Transactions replace separate branch and merge APIs: diff --git a/Sources/Workspace/Checkpoint.swift b/Sources/Workspace/Checkpoint.swift index 35ae7d1..ed16e2f 100644 --- a/Sources/Workspace/Checkpoint.swift +++ b/Sources/Workspace/Checkpoint.swift @@ -103,6 +103,26 @@ public struct Checkpoint: Sendable, Codable, Equatable { ) } + static func make( + snapshotID: UUID, + draft: CheckpointDraft, + parent: Checkpoint?, + summary: ChangeSet.Summary + ) -> Checkpoint { + let previousCursor = parent?.mutationCursor ?? 0 + return Checkpoint( + workspaceId: draft.workspaceID, + label: draft.label, + parentCheckpointId: parent?.id, + origin: draft.origin, + firstMutationSequence: draft.mutationCursor > previousCursor ? previousCursor + 1 : nil, + lastMutationSequence: draft.mutationCursor > previousCursor ? draft.mutationCursor : nil, + mutationCursor: draft.mutationCursor, + snapshotId: snapshotID, + summary: summary + ) + } + func rebased(to parent: Checkpoint?, snapshot: Snapshot, parentSnapshot: Snapshot?) -> Checkpoint { var copy = self copy.parentID = parent?.id diff --git a/Sources/Workspace/CheckpointStore.swift b/Sources/Workspace/CheckpointStore.swift index bcaceb7..4b91b02 100644 --- a/Sources/Workspace/CheckpointStore.swift +++ b/Sources/Workspace/CheckpointStore.swift @@ -12,6 +12,7 @@ import Glibc /// any placeholder sequence; ``appendMutation(_:)`` returns the record with the next persisted /// monotonic number for that workspace, serialized under the mutations lock. protocol CheckpointStore: AnyObject, Sendable { + func captureRevision(from filesystem: any FileSystem, draft: CheckpointDraft) async throws -> Checkpoint func saveRevision(_ snapshot: Snapshot, draft: CheckpointDraft) async throws -> Checkpoint func loadCheckpoint(id: UUID, workspaceId: UUID) async throws -> Checkpoint? func listCheckpoints(workspaceId: UUID) async throws -> [Checkpoint] @@ -46,6 +47,10 @@ actor InMemoryCheckpointStore: CheckpointStore { init() {} + func captureRevision(from filesystem: any FileSystem, draft: CheckpointDraft) async throws -> Checkpoint { + try await saveRevision(Snapshot.capture(from: filesystem), draft: draft) + } + func saveRevision(_ snapshot: Snapshot, draft: CheckpointDraft) async throws -> Checkpoint { var list = checkpointsByWorkspace[draft.workspaceID] ?? [] let parent = resolvedParent(for: draft, checkpoints: list) @@ -303,8 +308,8 @@ actor FileCheckpointStore: CheckpointStore { /// A persisted snapshot: the tree structure with file contents replaced by content hashes. /// The bytes themselves live in per-workspace `blobs/` files, so identical content is /// stored once no matter how many snapshots reference it, and never base64-inflated. - private struct SnapshotManifest: Codable { - struct Node: Codable { + private struct SnapshotManifest: Codable, Sendable { + struct Node: Codable, Sendable { enum Kind: String, Codable { case file, directory, symlink, missing } @@ -314,6 +319,7 @@ actor FileCheckpointStore: CheckpointStore { var permissions: POSIXPermissions? var contentHash: String? var contentSize: UInt64? = nil + var modificationDate: Date? = nil var symlinkTarget: String? var children: [Node]? } @@ -324,6 +330,72 @@ actor FileCheckpointStore: CheckpointStore { var root: Node } + func captureRevision(from filesystem: any FileSystem, draft: CheckpointDraft) async throws -> Checkpoint { + try ensureWorkspaceDirectories(for: draft.workspaceID) + return try await withExclusiveLifecycleLock(at: lifecycleLockURL(workspaceId: draft.workspaceID)) { + let checkpoints = try self.loadAllCheckpointsUnlocked(workspaceId: draft.workspaceID) + let parent = self.resolvedParent(for: draft, checkpoints: checkpoints) + let parentManifest: SnapshotManifest? + if let parent { + guard let manifest = try self.loadManifestUnlocked( + id: parent.snapshotId, + workspaceId: draft.workspaceID + ) else { + throw WorkspaceError.storageCorrupted("checkpoint \(parent.id) has no snapshot") + } + parentManifest = manifest + } else { + parentManifest = nil + } + let snapshotID = UUID() + let root = try await self.captureManifestNode( + from: filesystem, + at: .root, + reusing: parentManifest?.root, + workspaceId: draft.workspaceID + ) + let manifest = SnapshotManifest(version: 2, id: snapshotID, rootPath: .root, root: root) + let beforeIndex = try parentManifest.map { + RevisionIndex( + id: $0.id, + root: $0.rootPath, + entry: try self.revisionEntry(from: $0.root, workspaceId: draft.workspaceID) + ) + } ?? RevisionIndex( + id: UUID(), + root: .root, + entry: .missing(path: .root) + ) + let afterIndex = RevisionIndex( + id: snapshotID, + root: .root, + entry: try self.revisionEntry(from: root, workspaceId: draft.workspaceID) + ) + let blobsRoot = self.blobsDirectoryURL(workspaceId: draft.workspaceID) + let beforeRoot = parentManifest?.root + let changes = try await ChangeSet.compare( + before: beforeIndex, + after: afterIndex, + maxTextBytes: 1_000_000, + loadBefore: { path in try Self.loadManifestFile(path, root: beforeRoot, blobsRoot: blobsRoot) }, + loadAfter: { path in try Self.loadManifestFile(path, root: root, blobsRoot: blobsRoot) } + ) + let checkpoint = Checkpoint.make( + snapshotID: snapshotID, + draft: draft, + parent: parent, + summary: changes.summary + ) + try self.write(manifest, to: self.snapshotURL(id: snapshotID, workspaceId: draft.workspaceID)) + try self.write( + checkpoint, + to: self.checkpointURL(id: checkpoint.id, workspaceId: draft.workspaceID) + ) + self.listCheckpointsCache[draft.workspaceID] = nil + return checkpoint + } + } + private func writeSnapshotUnlocked(_ snapshot: Snapshot, workspaceId: UUID) throws { let root = try writeManifestNode(snapshot.entry, workspaceId: workspaceId) let manifest = SnapshotManifest( @@ -335,6 +407,120 @@ actor FileCheckpointStore: CheckpointStore { try write(manifest, to: snapshotURL(id: snapshot.id, workspaceId: workspaceId)) } + private func loadManifestUnlocked(id: UUID, workspaceId: UUID) throws -> SnapshotManifest? { + let url = snapshotURL(id: id, workspaceId: workspaceId) + guard fileManager.fileExists(atPath: url.path) else { return nil } + let manifest = try decoder.decode(SnapshotManifest.self, from: Data(contentsOf: url)) + guard manifest.id == id else { + throw WorkspaceError.storageCorrupted("snapshot manifest id does not match \(id)") + } + return manifest + } + + private func captureManifestNode( + from filesystem: any FileSystem, + at path: WorkspacePath, + reusing previous: SnapshotManifest.Node?, + workspaceId: UUID + ) async throws -> SnapshotManifest.Node { + guard await filesystem.exists(path: path) else { + return SnapshotManifest.Node(kind: .missing, path: path) + } + let info = try await filesystem.stat(path: path) + switch info.kind { + case .file: + if previous?.kind == .file, + previous?.contentSize == info.size, + previous?.modificationDate != nil, + previous?.modificationDate == info.modificationDate, + let hash = previous?.contentHash, + Self.isValidContentHash(hash), + fileManager.fileExists(atPath: blobURL(hash: hash, workspaceId: workspaceId).path) { + return SnapshotManifest.Node( + kind: .file, + path: path, + permissions: info.permissions, + contentHash: hash, + contentSize: info.size, + modificationDate: info.modificationDate + ) + } + let data = try await filesystem.readFile(path: path) + let hash = SHA256.hexDigest(of: data) + let blob = blobURL(hash: hash, workspaceId: workspaceId) + if !fileManager.fileExists(atPath: blob.path) { try data.write(to: blob, options: .atomic) } + return SnapshotManifest.Node( + kind: .file, + path: path, + permissions: info.permissions, + contentHash: hash, + contentSize: UInt64(data.count), + modificationDate: info.modificationDate + ) + case .directory: + var previousChildren: [String: SnapshotManifest.Node] = [:] + for child in previous?.kind == .directory ? previous?.children ?? [] : [] { + guard previousChildren.updateValue(child, forKey: child.path.name) == nil else { + throw WorkspaceError.storageCorrupted( + "snapshot directory \(path) contains duplicate child \(child.path.name)" + ) + } + } + var children: [SnapshotManifest.Node] = [] + for entry in try await filesystem.listDirectory(path: path).sorted(by: { $0.name < $1.name }) { + children.append( + try await captureManifestNode( + from: filesystem, + at: path.appending(entry.name), + reusing: previousChildren[entry.name], + workspaceId: workspaceId + ) + ) + } + return SnapshotManifest.Node( + kind: .directory, + path: path, + permissions: info.permissions, + modificationDate: info.modificationDate, + children: children + ) + case .symlink: + return SnapshotManifest.Node( + kind: .symlink, + path: path, + permissions: info.permissions, + modificationDate: info.modificationDate, + symlinkTarget: try await filesystem.readSymlink(path: path) + ) + } + } + + private static func loadManifestFile( + _ path: WorkspacePath, + root: SnapshotManifest.Node?, + blobsRoot: URL + ) throws -> Data { + guard let root, let node = manifestNodeStatic(at: path, in: root), node.kind == .file, + let hash = node.contentHash, isValidContentHash(hash) + else { throw NSError(domain: NSPOSIXErrorDomain, code: Int(ENOENT)) } + let url = blobsRoot.appendingPathComponent(hash, isDirectory: false) + guard FileManager.default.fileExists(atPath: url.path) else { + throw WorkspaceError.storageCorrupted("missing content blob \(hash) for \(path)") + } + return try Data(contentsOf: url) + } + + private static func manifestNodeStatic( + at path: WorkspacePath, + in node: SnapshotManifest.Node + ) -> SnapshotManifest.Node? { + if node.path == path { return node } + for child in node.children ?? [] where child.path == path || path.string.hasPrefix(child.path.string + "/") { + return manifestNodeStatic(at: path, in: child) + } + return nil + } + func loadSnapshot(id: UUID, workspaceId: UUID) async throws -> Snapshot? { try validateWorkspaceFormatIfPresent(workspaceId) guard fileManager.fileExists(atPath: workspaceDirectoryURL(workspaceId: workspaceId).path) else { return nil } @@ -1048,6 +1234,21 @@ actor FileCheckpointStore: CheckpointStore { } #if canImport(Darwin) || canImport(Glibc) + private func withExclusiveLifecycleLock( + at url: URL, + _ body: () async throws -> R + ) async throws -> R { + let path = url.path + let fd = open(path, O_RDWR | O_CREAT, 0o644) + guard fd >= 0 else { throw LockFileError(message: "could not open lock file at \(path)") } + defer { close(fd) } + while flock(fd, LOCK_EX) != 0 { + if errno != EINTR { throw LockFileError(message: "could not acquire exclusive lock at \(path)") } + } + defer { flock(fd, LOCK_UN) } + return try await body() + } + private static func withExclusiveLock(at url: URL, _ body: () throws -> R) throws -> R { try withLock(at: url, operation: LOCK_EX, body) } @@ -1072,6 +1273,14 @@ actor FileCheckpointStore: CheckpointStore { return try body() } #else + private func withExclusiveLifecycleLock( + at url: URL, + _ body: () async throws -> R + ) async throws -> R { + _ = url + return try await body() + } + private static func withExclusiveLock(at url: URL, _ body: () throws -> R) throws -> R { try body() } diff --git a/Sources/Workspace/Workspace+Checkpoints.swift b/Sources/Workspace/Workspace+Checkpoints.swift index e6a8f2b..9eff347 100644 --- a/Sources/Workspace/Workspace+Checkpoints.swift +++ b/Sources/Workspace/Workspace+Checkpoints.swift @@ -5,13 +5,21 @@ extension Workspace { public func createCheckpoint(label: String? = nil) async throws -> Checkpoint { try await ensureLoaded() try await reconcileCheckpointsWithStore() - let snapshot = try await Snapshot.capture(from: filesystem) - return try await persistCheckpoint( - snapshot: snapshot, - label: label, - parentCheckpointId: headCheckpointId, - origin: .manual + let checkpoint = try await store.captureRevision( + from: filesystem, + draft: CheckpointDraft( + workspaceID: workspaceId, + label: label, + preferredParentID: headCheckpointId, + origin: .manual, + mutationCursor: latestMutationSequence() + ) ) + checkpoints.append(checkpoint) + checkpoints.sort(by: checkpointSort) + headCheckpointId = Checkpoint.lineageHeadID(in: checkpoints) + emitWorkspaceEvent(.checkpoint(checkpoint)) + return checkpoint } /// Restores the workspace to a prior checkpoint and records the rollback as a new checkpoint. diff --git a/Tests/WorkspaceTests/PersistenceTests.swift b/Tests/WorkspaceTests/PersistenceTests.swift index 94884bf..d11538d 100644 --- a/Tests/WorkspaceTests/PersistenceTests.swift +++ b/Tests/WorkspaceTests/PersistenceTests.swift @@ -47,6 +47,37 @@ struct PersistenceTests { } } + @Test + func `directory checkpoints only reread files whose metadata changed`() async throws { + let filesRoot = try TestSupport.temporaryDirectory("LazyCaptureFiles") + let historyRoot = try TestSupport.temporaryDirectory("LazyCaptureHistory") + defer { + TestSupport.remove(filesRoot) + TestSupport.remove(historyRoot) + } + let local = try LocalFileSystem(root: filesRoot) + let files = ReadCountingFileSystem(base: local) + let workspace = Workspace(fileSystem: files, persistence: .directory(historyRoot)) + + try await workspace.writeText("/note", "first") + await files.resetReadCount() + _ = try await workspace.createCheckpoint() + #expect(await files.readCount == 1) + + _ = try await workspace.createCheckpoint() + #expect(await files.readCount == 1) + + try await workspace.writeText("/note", "second") + await files.resetReadCount() + try FileManager.default.setAttributes( + [.modificationDate: Date().addingTimeInterval(10)], + ofItemAtPath: filesRoot.appendingPathComponent("note").path + ) + let changed = try await workspace.createCheckpoint() + #expect(await files.readCount == 1) + #expect(try await workspace.readText("/note", at: .checkpoint(changed.id)) == "second") + } + @Test func `torn final JSONL record is ignored and repaired by the next append`() async throws { let root = try TestSupport.temporaryDirectory("TornLog") @@ -88,3 +119,44 @@ struct PersistenceTests { #expect(next.parentID == expectedHead?.id) } } + +private actor ReadCountingFileSystem: FileSystem { + let base: any FileSystem + private(set) var readCount = 0 + + init(base: any FileSystem) { self.base = base } + + func resetReadCount() { readCount = 0 } + + func stat(path: WorkspacePath) async throws -> FileInfo { try await base.stat(path: path) } + func listDirectory(path: WorkspacePath) async throws -> [DirectoryEntry] { + try await base.listDirectory(path: path) + } + func readFile(path: WorkspacePath) async throws -> Data { + readCount += 1 + return try await base.readFile(path: path) + } + func exists(path: WorkspacePath) async -> Bool { await base.exists(path: path) } + func glob(pattern: String, currentDirectory: WorkspacePath) async throws -> [WorkspacePath] { + try await base.glob(pattern: pattern, currentDirectory: currentDirectory) + } + func writeFile(path: WorkspacePath, data: Data, append: Bool) async throws { + try await base.writeFile(path: path, data: data, append: append) + } + func createDirectory(path: WorkspacePath, recursive: Bool) async throws { + try await base.createDirectory(path: path, recursive: recursive) + } + func remove(path: WorkspacePath, recursive: Bool) async throws { + try await base.remove(path: path, recursive: recursive) + } + func move(from sourcePath: WorkspacePath, to destinationPath: WorkspacePath) async throws { + try await base.move(from: sourcePath, to: destinationPath) + } + func copy(from sourcePath: WorkspacePath, to destinationPath: WorkspacePath, recursive: Bool) async throws { + try await base.copy(from: sourcePath, to: destinationPath, recursive: recursive) + } + func readSymlink(path: WorkspacePath) async throws -> String { try await base.readSymlink(path: path) } + func setPermissions(path: WorkspacePath, permissions: POSIXPermissions) async throws { + try await base.setPermissions(path: path, permissions: permissions) + } +} From b11989361c1eebc5591fc53ce41e3b4ed8583025 Mon Sep 17 00:00:00 2001 From: Zac White Date: Sun, 12 Jul 2026 15:24:28 -0700 Subject: [PATCH 6/8] Push checkpoint and external filesystem events --- README.md | 6 + Sources/Workspace/CheckpointStore.swift | 11 ++ Sources/Workspace/FS/FileSystem.swift | 6 + .../Workspace/FS/ReadWriteFilesystem.swift | 90 +++++++++++++ .../Workspace/Support/FileChangeStreams.swift | 121 ++++++++++++++++++ Sources/Workspace/Workspace+Checkpoints.swift | 1 + Sources/Workspace/Workspace+Edits.swift | 1 + Sources/Workspace/Workspace+Internals.swift | 49 +++++-- Sources/Workspace/Workspace.swift | 5 +- Sources/Workspace/WorkspaceArchive.swift | 1 + Sources/Workspace/WorkspaceEvent.swift | 22 +++- Sources/Workspace/WorkspaceTransaction.swift | 1 + Tests/WorkspaceTests/PersistenceTests.swift | 33 +++++ Tests/WorkspaceTests/WorkspaceAPITests.swift | 50 ++++++++ 14 files changed, 383 insertions(+), 14 deletions(-) create mode 100644 Sources/Workspace/Support/FileChangeStreams.swift diff --git a/README.md b/README.md index f428f37..a6ea5fb 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,12 @@ let mutations = try await workspace.history() Change events, returned edit results, and mutation history all carry the same `ChangeSet` representation. Checkpoint events share the same stream. +`LocalFileSystem` also pushes out-of-band filesystem invalidations through FSEvents on macOS and +inotify on Linux. `events()` and the path-focused `watchChanges(at:recursive:)` turn those +invalidations into structured `ChangeSet` values. External changes remain unrecorded in mutation +history, matching the direct-filesystem contract above. Directory-backed checkpoint stores use the +same push model for cross-instance checkpoint events; there is no periodic polling loop. + ## Filesystems and Safety Layers Built-in filesystems use consistent names and constructor-driven configuration: diff --git a/Sources/Workspace/CheckpointStore.swift b/Sources/Workspace/CheckpointStore.swift index 4b91b02..3066884 100644 --- a/Sources/Workspace/CheckpointStore.swift +++ b/Sources/Workspace/CheckpointStore.swift @@ -12,6 +12,7 @@ import Glibc /// any placeholder sequence; ``appendMutation(_:)`` returns the record with the next persisted /// monotonic number for that workspace, serialized under the mutations lock. protocol CheckpointStore: AnyObject, Sendable { + func changes(workspaceId: UUID) async throws -> AsyncStream func captureRevision(from filesystem: any FileSystem, draft: CheckpointDraft) async throws -> Checkpoint func saveRevision(_ snapshot: Snapshot, draft: CheckpointDraft) async throws -> Checkpoint func loadCheckpoint(id: UUID, workspaceId: UUID) async throws -> Checkpoint? @@ -47,6 +48,11 @@ actor InMemoryCheckpointStore: CheckpointStore { init() {} + func changes(workspaceId: UUID) async throws -> AsyncStream { + _ = workspaceId + return AsyncStream { _ in } + } + func captureRevision(from filesystem: any FileSystem, draft: CheckpointDraft) async throws -> Checkpoint { try await saveRevision(Snapshot.capture(from: filesystem), draft: draft) } @@ -246,6 +252,11 @@ actor FileCheckpointStore: CheckpointStore { self.compactEncoder = JSONEncoder() } + func changes(workspaceId: UUID) async throws -> AsyncStream { + try ensureWorkspaceDirectories(for: workspaceId) + return try DirectoryChangeStream.observe(checkpointsDirectoryURL(workspaceId: workspaceId)) + } + func saveRevision(_ snapshot: Snapshot, draft: CheckpointDraft) async throws -> Checkpoint { try ensureWorkspaceDirectories(for: draft.workspaceID) return try Self.withExclusiveLock(at: lifecycleLockURL(workspaceId: draft.workspaceID)) { diff --git a/Sources/Workspace/FS/FileSystem.swift b/Sources/Workspace/FS/FileSystem.swift index f5d1a72..c0bf20a 100644 --- a/Sources/Workspace/FS/FileSystem.swift +++ b/Sources/Workspace/FS/FileSystem.swift @@ -166,6 +166,12 @@ public protocol FileSystem: AnyObject, Sendable { func createFile(path: WorkspacePath, data: Data) async throws } +/// A filesystem that can push invalidations when its contents change outside Workspace APIs. +public protocol FileSystemChangeSource: FileSystem { + /// Emits after one or more entries may have changed. Consumers should re-read the affected tree. + func changes() async throws -> AsyncStream +} + extension FileSystem { /// The default implementation throws ``WorkspaceError/unsupported(_:)``. public func createSymlink(path: WorkspacePath, target: String) async throws { diff --git a/Sources/Workspace/FS/ReadWriteFilesystem.swift b/Sources/Workspace/FS/ReadWriteFilesystem.swift index 5c62080..6fd6199 100644 --- a/Sources/Workspace/FS/ReadWriteFilesystem.swift +++ b/Sources/Workspace/FS/ReadWriteFilesystem.swift @@ -2,6 +2,9 @@ import Foundation #if canImport(Darwin) import Darwin +#if os(macOS) +import CoreServices +#endif #elseif canImport(Glibc) import Glibc #endif @@ -508,3 +511,90 @@ public final class LocalFileSystem: FileSystem, @unchecked Sendable { ) } } + +#if os(macOS) +extension LocalFileSystem: FileSystemChangeSource { + public func changes() async throws -> AsyncStream { + let root = try requireConfiguration().rootURL + return try FSEventsChangeStream.observe(root) + } +} + +enum FSEventsChangeStream { + private final class Box: @unchecked Sendable { + let continuation: AsyncStream.Continuation + init(_ continuation: AsyncStream.Continuation) { self.continuation = continuation } + } + + private final class Observation: @unchecked Sendable { + private let stream: FSEventStreamRef + private let box: Unmanaged + + init(root: URL, continuation: AsyncStream.Continuation) throws { + box = .passRetained(Box(continuation)) + var context = FSEventStreamContext( + version: 0, + info: box.toOpaque(), + retain: nil, + release: nil, + copyDescription: nil + ) + let callback: FSEventStreamCallback = { _, info, _, _, _, _ in + guard let info else { return } + Unmanaged.fromOpaque(info).takeUnretainedValue().continuation.yield() + } + let flags = FSEventStreamCreateFlags( + kFSEventStreamCreateFlagFileEvents | kFSEventStreamCreateFlagNoDefer + ) + guard let stream = FSEventStreamCreate( + kCFAllocatorDefault, + callback, + &context, + [root.path] as CFArray, + FSEventStreamEventId(kFSEventStreamEventIdSinceNow), + 0.04, + flags + ) else { + box.release() + throw WorkspaceError.unsupported("could not create an FSEvents stream") + } + self.stream = stream + FSEventStreamSetDispatchQueue(stream, .global(qos: .utility)) + guard FSEventStreamStart(stream) else { + FSEventStreamInvalidate(stream) + FSEventStreamRelease(stream) + box.release() + throw WorkspaceError.unsupported("could not start an FSEvents stream") + } + } + + func cancel() { + FSEventStreamStop(stream) + FSEventStreamInvalidate(stream) + FSEventStreamRelease(stream) + box.release() + } + } + + static func observe(_ root: URL) throws -> AsyncStream { + var failure: Error? + let stream = AsyncStream { continuation in + do { + let observation = try Observation(root: root, continuation: continuation) + continuation.onTermination = { _ in observation.cancel() } + } catch { + failure = error + continuation.finish() + } + } + if let failure { throw failure } + return stream + } +} +#elseif os(Linux) +extension LocalFileSystem: FileSystemChangeSource { + public func changes() async throws -> AsyncStream { + try DirectoryChangeStream.observe(requireConfiguration().rootURL, recursive: true) + } +} +#endif diff --git a/Sources/Workspace/Support/FileChangeStreams.swift b/Sources/Workspace/Support/FileChangeStreams.swift new file mode 100644 index 0000000..20f25cb --- /dev/null +++ b/Sources/Workspace/Support/FileChangeStreams.swift @@ -0,0 +1,121 @@ +import Foundation + +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#endif + +enum DirectoryChangeStream { + static func observe(_ directory: URL, recursive: Bool = false) throws -> AsyncStream { + #if os(macOS) + return try FSEventsChangeStream.observe(directory) + #elseif canImport(Darwin) + let descriptor = open(directory.path, O_EVTONLY) + guard descriptor >= 0 else { throw POSIXError(.init(rawValue: errno) ?? .EIO) } + return AsyncStream { continuation in + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: descriptor, + eventMask: [.write, .rename, .delete], + queue: .global(qos: .utility) + ) + source.setEventHandler { [source] in + _ = source + continuation.yield() + } + source.setCancelHandler { close(descriptor) } + continuation.onTermination = { _ in source.cancel() } + source.resume() + } + #elseif canImport(Glibc) + return AsyncStream { continuation in + do { + let observation = try LinuxDirectoryObservation( + directory: directory, + recursive: recursive, + continuation: continuation + ) + continuation.onTermination = { _ in observation.cancel() } + } catch { + continuation.finish() + } + } + #else + throw WorkspaceError.unsupported("filesystem change streams are not supported on this platform") + #endif + } +} + +#if canImport(Glibc) +private final class LinuxDirectoryObservation: @unchecked Sendable { + private let directory: URL + private let recursive: Bool + private let continuation: AsyncStream.Continuation + private let descriptor: Int32 + private let source: DispatchSourceRead + private var watchesByPath: [String: Int32] = [:] + + init( + directory: URL, + recursive: Bool, + continuation: AsyncStream.Continuation + ) throws { + self.directory = directory + self.recursive = recursive + self.continuation = continuation + descriptor = inotify_init1(Int32(IN_NONBLOCK | IN_CLOEXEC)) + guard descriptor >= 0 else { throw POSIXError(.init(rawValue: errno) ?? .EIO) } + source = DispatchSource.makeReadSource(fileDescriptor: descriptor, queue: .global(qos: .utility)) + do { + try addMissingWatches() + } catch { + close(descriptor) + throw error + } + source.setEventHandler { [weak self] in self?.handleEvents() } + source.setCancelHandler { [descriptor] in close(descriptor) } + source.resume() + } + + func cancel() { source.cancel() } + + private func handleEvents() { + var buffer = [UInt8](repeating: 0, count: 16 * 1024) + var received = false + while read(descriptor, &buffer, buffer.count) > 0 { received = true } + guard received else { return } + try? addMissingWatches() + continuation.yield() + } + + private func addMissingWatches() throws { + let paths: [String] + if recursive { + let enumerator = FileManager.default.enumerator( + at: directory, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ) + var discovered = [directory.path] + while let url = enumerator?.nextObject() as? URL { + if (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true { + discovered.append(url.path) + } + } + paths = discovered + } else { + paths = [directory.path] + } + watchesByPath = watchesByPath.filter { FileManager.default.fileExists(atPath: $0.key) } + let mask = UInt32( + IN_CREATE | IN_DELETE | IN_CLOSE_WRITE | IN_MOVED_FROM | IN_MOVED_TO | IN_ATTRIB + | IN_DELETE_SELF | IN_MOVE_SELF + ) + for path in paths where watchesByPath[path] == nil { + let watch = path.withCString { inotify_add_watch(descriptor, $0, mask) } + guard watch >= 0 else { throw POSIXError(.init(rawValue: errno) ?? .EIO) } + watchesByPath[path] = watch + } + } +} +#endif diff --git a/Sources/Workspace/Workspace+Checkpoints.swift b/Sources/Workspace/Workspace+Checkpoints.swift index 9eff347..08f80df 100644 --- a/Sources/Workspace/Workspace+Checkpoints.swift +++ b/Sources/Workspace/Workspace+Checkpoints.swift @@ -37,6 +37,7 @@ extension Workspace { let previousSnapshot = try await Snapshot.capture(from: filesystem) try await Snapshot.restore(targetSnapshot, to: filesystem) let restoredSnapshot = try await Snapshot.capture(from: filesystem) + noteWorkspaceSnapshot(restoredSnapshot) let changes = changeSet(from: previousSnapshot, to: restoredSnapshot) if !changes.isEmpty { diff --git a/Sources/Workspace/Workspace+Edits.swift b/Sources/Workspace/Workspace+Edits.swift index e57cccd..61ddad3 100644 --- a/Sources/Workspace/Workspace+Edits.swift +++ b/Sources/Workspace/Workspace+Edits.swift @@ -50,6 +50,7 @@ extension Workspace { } let after = try await Snapshot.capture(from: filesystem) + noteWorkspaceSnapshot(after) let changes = changeSet(from: before, to: after) if !changes.isEmpty { try await appendMutation(operation: .edit, changes: changes) diff --git a/Sources/Workspace/Workspace+Internals.swift b/Sources/Workspace/Workspace+Internals.swift index 4bfba1f..96002a9 100644 --- a/Sources/Workspace/Workspace+Internals.swift +++ b/Sources/Workspace/Workspace+Internals.swift @@ -95,18 +95,21 @@ extension Workspace { return string } - func ensureCheckpointPolling() { - guard checkpointPollingTask == nil else { return } - checkpointPollingTask = Task { [weak self] in - while !Task.isCancelled, let workspace = self { - let interval = await workspace.checkpointEventPollInterval - try? await Task.sleep(for: interval) - await workspace.pollCheckpointEvents() + func ensureCheckpointObservation() async { + guard checkpointObservationTask == nil else { return } + guard (try? await ensureLoaded()) != nil else { return } + let store = store + let workspaceID = workspaceId + guard let stream = try? await store.changes(workspaceId: workspaceID) else { return } + checkpointObservationTask = Task { [weak self] in + for await _ in stream where !Task.isCancelled { + try? await Task.sleep(for: .milliseconds(20)) + await self?.refreshCheckpointEvents() } } } - func pollCheckpointEvents() async { + func refreshCheckpointEvents() async { guard eventWatchers.values.contains(where: { $0.filter.includeCheckpoints }) else { return } guard (try? await ensureLoaded()) != nil else { return } guard let stored = try? await store.listCheckpoints(workspaceId: workspaceId) else { return } @@ -124,6 +127,36 @@ extension Workspace { } } + func ensureFilesystemObservation() async { + guard filesystemObservationTask == nil, + let source = filesystem as? any FileSystemChangeSource + else { return } + guard let stream = try? await source.changes(), + let initial = try? await Snapshot.capture(from: source) + else { return } + observedFilesystemSnapshot = initial + filesystemObservationTask = Task { [weak self] in + for await _ in stream where !Task.isCancelled { + try? await Task.sleep(for: .milliseconds(40)) + await self?.refreshFilesystemEvents() + } + } + } + + func noteWorkspaceSnapshot(_ snapshot: Snapshot) { + guard filesystemObservationTask != nil else { return } + observedFilesystemSnapshot = snapshot + } + + func refreshFilesystemEvents() async { + guard let before = observedFilesystemSnapshot, + let after = try? await Snapshot.capture(from: filesystem) + else { return } + observedFilesystemSnapshot = after + let changes = changeSet(from: before, to: after) + if !changes.isEmpty { emitWorkspaceEvent(.changes(changes)) } + } + func checkpointSort(lhs: Checkpoint, rhs: Checkpoint) -> Bool { Checkpoint.orderedBefore(lhs, rhs) } diff --git a/Sources/Workspace/Workspace.swift b/Sources/Workspace/Workspace.swift index f06287a..61bb3ca 100644 --- a/Sources/Workspace/Workspace.swift +++ b/Sources/Workspace/Workspace.swift @@ -39,8 +39,9 @@ public actor Workspace { var mutations: [Mutation] = [] var headCheckpointId: UUID? var eventWatchers: [UUID: EventWatcher] = [:] - var checkpointPollingTask: Task? - var checkpointEventPollInterval: Duration = .milliseconds(500) + var checkpointObservationTask: Task? + var filesystemObservationTask: Task? + var observedFilesystemSnapshot: Snapshot? /// Creates a workspace over a filesystem. public init( diff --git a/Sources/Workspace/WorkspaceArchive.swift b/Sources/Workspace/WorkspaceArchive.swift index f206744..f5bd6cc 100644 --- a/Sources/Workspace/WorkspaceArchive.swift +++ b/Sources/Workspace/WorkspaceArchive.swift @@ -49,6 +49,7 @@ extension Workspace { let entry = Self.snapshotEntry(archive.entry, replacing: archive.root, with: destination) try await Snapshot.restore(Snapshot(rootPath: destination, entry: entry), to: filesystem) let after = try await Snapshot.capture(from: filesystem) + noteWorkspaceSnapshot(after) let changes = changeSet(from: before, to: after) if !changes.isEmpty { try await appendMutation(operation: .archiveRestore, changes: changes) diff --git a/Sources/Workspace/WorkspaceEvent.swift b/Sources/Workspace/WorkspaceEvent.swift index 4253db2..33e889f 100644 --- a/Sources/Workspace/WorkspaceEvent.swift +++ b/Sources/Workspace/WorkspaceEvent.swift @@ -22,7 +22,7 @@ public enum WorkspaceEvent: Sendable, Codable, Equatable { extension Workspace { /// Observes workspace changes and checkpoints through one stream. - public func events(_ filter: WorkspaceEvent.Filter = .all) -> AsyncStream { + public func events(_ filter: WorkspaceEvent.Filter = .all) async -> AsyncStream { let id = UUID() var continuation: AsyncStream.Continuation? let stream = AsyncStream { continuation = $0 } @@ -32,8 +32,9 @@ extension Workspace { Task { await self?.removeEventWatcher(id) } } if filter.includeCheckpoints { - ensureCheckpointPolling() + await ensureCheckpointObservation() } + await ensureFilesystemObservation() return stream } @@ -41,8 +42,13 @@ extension Workspace { eventWatchers.removeValue(forKey: id) let hasCheckpointEventWatcher = eventWatchers.values.contains { $0.filter.includeCheckpoints } if !hasCheckpointEventWatcher { - checkpointPollingTask?.cancel() - checkpointPollingTask = nil + checkpointObservationTask?.cancel() + checkpointObservationTask = nil + } + if eventWatchers.isEmpty { + filesystemObservationTask?.cancel() + filesystemObservationTask = nil + observedFilesystemSnapshot = nil } } @@ -52,6 +58,14 @@ extension Workspace { } } + /// Compatibility spelling for observing future workspace changes at a path. + public func watchChanges( + at path: WorkspacePath = .root, + recursive: Bool = true + ) async -> AsyncStream { + await events(.init(path: path, recursive: recursive, includeCheckpoints: false)) + } + private static func matches(_ event: WorkspaceEvent, filter: WorkspaceEvent.Filter) -> Bool { switch event { case .checkpoint: diff --git a/Sources/Workspace/WorkspaceTransaction.swift b/Sources/Workspace/WorkspaceTransaction.swift index ecb5492..2a8fc0a 100644 --- a/Sources/Workspace/WorkspaceTransaction.swift +++ b/Sources/Workspace/WorkspaceTransaction.swift @@ -387,6 +387,7 @@ extension Workspace { throw error } try await appendMutation(operation: .transaction, changes: applied) + noteWorkspaceSnapshot(mergedSnapshot) let checkpoint = try await persistCheckpoint( snapshot: mergedSnapshot, label: label, diff --git a/Tests/WorkspaceTests/PersistenceTests.swift b/Tests/WorkspaceTests/PersistenceTests.swift index d11538d..7954d62 100644 --- a/Tests/WorkspaceTests/PersistenceTests.swift +++ b/Tests/WorkspaceTests/PersistenceTests.swift @@ -118,6 +118,39 @@ struct PersistenceTests { let next = try await reloaded.createCheckpoint(label: "next") #expect(next.parentID == expectedHead?.id) } + + @Test + func `checkpoint events are pushed across workspace instances`() async throws { + let root = try TestSupport.temporaryDirectory("CheckpointPush") + defer { TestSupport.remove(root) } + let id = UUID() + let reader = Workspace(workspaceID: id, persistence: .directory(root)) + let writer = Workspace(workspaceID: id, persistence: .directory(root)) + let stream = await reader.events(.init(includeCheckpoints: true)) + + let checkpoint = try await writer.createCheckpoint(label: "pushed") + + #expect(await nextPersistenceEvent(in: stream) == .checkpoint(checkpoint)) + } +} + +private func nextPersistenceEvent( + in stream: AsyncStream, + timeout: Duration = .seconds(2) +) async -> WorkspaceEvent? { + await withTaskGroup(of: WorkspaceEvent?.self) { group in + group.addTask { + var iterator = stream.makeAsyncIterator() + return await iterator.next() + } + group.addTask { + try? await Task.sleep(for: timeout) + return nil + } + let result = await group.next() ?? nil + group.cancelAll() + return result + } } private actor ReadCountingFileSystem: FileSystem { diff --git a/Tests/WorkspaceTests/WorkspaceAPITests.swift b/Tests/WorkspaceTests/WorkspaceAPITests.swift index 294840b..3c77e39 100644 --- a/Tests/WorkspaceTests/WorkspaceAPITests.swift +++ b/Tests/WorkspaceTests/WorkspaceAPITests.swift @@ -15,6 +15,37 @@ struct WorkspaceAPITests { #expect(try await workspace.history().isEmpty) } + @Test + func `external local filesystem mutations reach watchChanges without entering history`() async throws { + let root = try TestSupport.temporaryDirectory("ExternalEvents") + defer { TestSupport.remove(root) } + let files = try LocalFileSystem(root: root) + let workspace = Workspace(fileSystem: files) + let stream = await workspace.watchChanges() + + try await files.writeFile(path: "/external.txt", data: Data("outside".utf8), append: false) + + guard case let .changes(changes)? = await nextEvent(in: stream) else { + Issue.record("expected an external filesystem change event") + return + } + #expect(changes.touchedPaths == ["/external.txt"]) + #expect(try await workspace.history().isEmpty) + } + + @Test + func `local filesystem watcher does not duplicate workspace edit events`() async throws { + let root = try TestSupport.temporaryDirectory("EventDeduplication") + defer { TestSupport.remove(root) } + let workspace = Workspace(fileSystem: try LocalFileSystem(root: root)) + let stream = await workspace.watchChanges() + + let expected = try await workspace.writeText("/note", "once") + + #expect(await nextEvent(in: stream) == .changes(expected)) + #expect(await nextEvent(in: stream, timeout: .milliseconds(200)) == nil) + } + @Test func `one changeset flows through preview apply events and history`() async throws { let workspace = Workspace() @@ -145,3 +176,22 @@ struct WorkspaceAPITests { #expect(try await workspace.history().last?.operation == .rollback) } } + +private func nextEvent( + in stream: AsyncStream, + timeout: Duration = .seconds(2) +) async -> WorkspaceEvent? { + await withTaskGroup(of: WorkspaceEvent?.self) { group in + group.addTask { + var iterator = stream.makeAsyncIterator() + return await iterator.next() + } + group.addTask { + try? await Task.sleep(for: timeout) + return nil + } + let result = await group.next() ?? nil + group.cancelAll() + return result + } +} From 02844ac61327b2a0b3985ded60c62fdfe65e8418 Mon Sep 17 00:00:00 2001 From: Zac White Date: Sun, 12 Jul 2026 15:27:12 -0700 Subject: [PATCH 7/8] Add vendor-neutral workspace tool adapters --- README.md | 21 ++ Sources/Workspace/WorkspaceToolAdapter.swift | 332 ++++++++++++++++++ .../WorkspaceToolAdapterTests.swift | 67 ++++ 3 files changed, 420 insertions(+) create mode 100644 Sources/Workspace/WorkspaceToolAdapter.swift create mode 100644 Tests/WorkspaceTests/WorkspaceToolAdapterTests.swift diff --git a/README.md b/README.md index a6ea5fb..51cc31f 100644 --- a/README.md +++ b/README.md @@ -267,6 +267,27 @@ let workspace = Workspace(fileSystem: authorized) Authorization supports one-shot, duration-limited, and session approvals plus bounded audit records. Prefix rules use path-component boundaries and require both paths to match for copy and move. +## LLM Tool Adapters + +`WorkspaceToolAdapter` exposes dependency-free function definitions for read, glob, search, apply, +diff, and checkpoint operations. Each definition contains an ordinary JSON Schema object, so an +integration can translate it into its model provider's tool format without adding a provider SDK to +Workspace: + +```swift +let adapter = WorkspaceToolAdapter(workspace: workspace) +let definitions = WorkspaceToolAdapter.definitions + +let resultJSON = try await adapter.call( + name: "workspace_diff", + arguments: Data(#"{"from_checkpoint_id":"...","unified_patch":true}"#.utf8) +) +``` + +All mutating adapter calls use the Workspace edit/checkpoint APIs, so they retain the same history, +event, transaction, authorization, and limit behavior as direct callers. The diff result carries the +structured `ChangeSet` and can optionally include unified-patch text for prompt or CLI interop. + ## Testing ```bash diff --git a/Sources/Workspace/WorkspaceToolAdapter.swift b/Sources/Workspace/WorkspaceToolAdapter.swift new file mode 100644 index 0000000..31b425b --- /dev/null +++ b/Sources/Workspace/WorkspaceToolAdapter.swift @@ -0,0 +1,332 @@ +import Foundation + +/// A vendor-neutral JSON value used by tool definitions and adapters. +public enum JSONValue: Sendable, Codable, Equatable { + case string(String) + case number(Double) + case boolean(Bool) + case object([String: JSONValue]) + case array([JSONValue]) + case null + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { self = .null } + else if let value = try? container.decode(Bool.self) { self = .boolean(value) } + else if let value = try? container.decode(Double.self) { self = .number(value) } + else if let value = try? container.decode(String.self) { self = .string(value) } + else if let value = try? container.decode([JSONValue].self) { self = .array(value) } + else { self = .object(try container.decode([String: JSONValue].self)) } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case let .string(value): try container.encode(value) + case let .number(value): try container.encode(value) + case let .boolean(value): try container.encode(value) + case let .object(value): try container.encode(value) + case let .array(value): try container.encode(value) + case .null: try container.encodeNil() + } + } +} + +/// A portable function-tool definition with an ordinary JSON Schema input object. +public struct WorkspaceToolDefinition: Sendable, Codable, Equatable { + public var name: String + public var description: String + public var inputSchema: JSONValue + + public init(name: String, description: String, inputSchema: JSONValue) { + self.name = name + self.description = description + self.inputSchema = inputSchema + } +} + +public enum WorkspaceToolError: Error, Sendable, Equatable, CustomStringConvertible { + case unknownTool(String) + case invalidArguments(String) + + public var description: String { + switch self { + case let .unknownTool(name): "unknown workspace tool: \(name)" + case let .invalidArguments(message): "invalid workspace tool arguments: \(message)" + } + } +} + +/// Dependency-free tool definitions and execution over one shared Workspace authority. +public actor WorkspaceToolAdapter { + public let workspace: Workspace + + public init(workspace: Workspace) { self.workspace = workspace } + + public nonisolated static let definitions: [WorkspaceToolDefinition] = [ + .init( + name: "workspace_read", + description: "Read a UTF-8 file from the current workspace or a checkpoint.", + inputSchema: .schema( + properties: [ + "path": .stringSchema("Absolute workspace path."), + "checkpoint_id": .stringSchema("Optional checkpoint UUID."), + ], + required: ["path"] + ) + ), + .init( + name: "workspace_glob", + description: "List workspace paths matching a glob in deterministic order.", + inputSchema: .schema( + properties: [ + "pattern": .stringSchema("Glob pattern."), + "current_directory": .stringSchema("Directory used to resolve the pattern."), + "checkpoint_id": .stringSchema("Optional checkpoint UUID."), + ], + required: ["pattern"] + ) + ), + .init( + name: "workspace_search", + description: "Search UTF-8 files by literal text or regular expression.", + inputSchema: .schema( + properties: [ + "pattern": .stringSchema("Text or regular expression to find."), + "regular_expression": .booleanSchema("Interpret pattern as a regular expression."), + "case_sensitive": .booleanSchema("Use case-sensitive literal matching."), + "root": .stringSchema("Root directory to search."), + "include": .arraySchema(items: .stringSchema("Include glob.")), + "exclude": .arraySchema(items: .stringSchema("Exclude glob.")), + "context_lines": .integerSchema("Context lines before and after each match."), + ], + required: ["pattern"] + ) + ), + .init( + name: "workspace_apply", + description: "Atomically apply recorded text, directory, removal, copy, or move edits.", + inputSchema: .schema( + properties: [ + "edits": .arraySchema( + items: .schema( + properties: [ + "kind": .enumSchema(["write_text", "append_text", "create_directory", "remove", "copy", "move"]), + "path": .stringSchema("Target workspace path."), + "content": .stringSchema("Text for write_text or append_text."), + "source": .stringSchema("Source path for copy or move."), + "recursive": .booleanSchema("Recursive directory behavior."), + ], + required: ["kind", "path"] + ) + ), + ], + required: ["edits"] + ) + ), + .init( + name: "workspace_diff", + description: "Compare checkpoints or current state and return a ChangeSet plus optional unified patch.", + inputSchema: .schema( + properties: [ + "from_checkpoint_id": .stringSchema("Source checkpoint UUID; omit for current state."), + "to_checkpoint_id": .stringSchema("Destination checkpoint UUID; omit for current state."), + "unified_patch": .booleanSchema("Include git-style textual patch output."), + "max_text_bytes": .integerSchema("Maximum bytes per file for text diffing."), + ] + ) + ), + .init( + name: "workspace_checkpoint", + description: "Create a checkpoint of the current workspace state.", + inputSchema: .schema(properties: ["label": .stringSchema("Optional checkpoint label.")]) + ), + ] + + /// Executes one tool call from a JSON object and returns a JSON-encoded result. + public func call(name: String, arguments: Data) async throws -> Data { + let decoder = JSONDecoder() + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + do { + switch name { + case "workspace_read": + let request = try decoder.decode(ReadArguments.self, from: arguments) + let revision = try request.revision() + return try encoder.encode( + ReadOutput(path: request.path, content: try await workspace.readText(request.path, at: revision)) + ) + case "workspace_glob": + let request = try decoder.decode(GlobArguments.self, from: arguments) + return try encoder.encode( + try await workspace.glob( + request.pattern, + currentDirectory: request.currentDirectory ?? .root, + at: request.revision() + ) + ) + case "workspace_search": + let request = try decoder.decode(SearchArguments.self, from: arguments) + return try encoder.encode(try await workspace.search(request.request)) + case "workspace_apply": + let request = try decoder.decode(ApplyArguments.self, from: arguments) + return try encoder.encode(try await workspace.apply(try request.edits.map { try $0.edit })) + case "workspace_diff": + let request = try decoder.decode(DiffArguments.self, from: arguments) + let changes = try await workspace.diff( + from: try request.revision(request.fromCheckpointID), + to: try request.revision(request.toCheckpointID), + options: .init(maxTextBytes: request.maxTextBytes ?? DiffOptions.default.maxTextBytes) + ) + return try encoder.encode( + DiffOutput(changes: changes, unifiedPatch: request.unifiedPatch == true ? changes.unifiedPatch() : nil) + ) + case "workspace_checkpoint": + let request = try decoder.decode(CheckpointArguments.self, from: arguments) + return try encoder.encode(try await workspace.createCheckpoint(label: request.label)) + default: + throw WorkspaceToolError.unknownTool(name) + } + } catch let error as WorkspaceToolError { + throw error + } catch let error as DecodingError { + throw WorkspaceToolError.invalidArguments(String(describing: error)) + } + } +} + +private struct ReadArguments: Decodable { + var path: WorkspacePath + var checkpointID: String? + enum CodingKeys: String, CodingKey { case path; case checkpointID = "checkpoint_id" } + func revision() throws -> Revision { try toolRevision(checkpointID) } +} + +private struct ReadOutput: Encodable { var path: WorkspacePath; var content: String } + +private struct GlobArguments: Decodable { + var pattern: String + var currentDirectory: WorkspacePath? + var checkpointID: String? + enum CodingKeys: String, CodingKey { + case pattern + case currentDirectory = "current_directory" + case checkpointID = "checkpoint_id" + } + func revision() throws -> Revision { try toolRevision(checkpointID) } +} + +private struct SearchArguments: Decodable { + var pattern: String + var regularExpression = false + var caseSensitive = true + var root: WorkspacePath = .root + var include: [String] = ["**/*"] + var exclude: [String] = [] + var contextLines = 0 + enum CodingKeys: String, CodingKey { + case pattern, root, include, exclude + case regularExpression = "regular_expression" + case caseSensitive = "case_sensitive" + case contextLines = "context_lines" + } + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + pattern = try container.decode(String.self, forKey: .pattern) + regularExpression = try container.decodeIfPresent(Bool.self, forKey: .regularExpression) ?? false + caseSensitive = try container.decodeIfPresent(Bool.self, forKey: .caseSensitive) ?? true + root = try container.decodeIfPresent(WorkspacePath.self, forKey: .root) ?? .root + include = try container.decodeIfPresent([String].self, forKey: .include) ?? ["**/*"] + exclude = try container.decodeIfPresent([String].self, forKey: .exclude) ?? [] + contextLines = try container.decodeIfPresent(Int.self, forKey: .contextLines) ?? 0 + } + var request: SearchRequest { + SearchRequest( + pattern: regularExpression ? .regularExpression(pattern) : .literal(pattern, caseSensitive: caseSensitive), + files: .init(root: root, include: include, exclude: exclude), + contextLines: contextLines + ) + } +} + +private struct ApplyArguments: Decodable { var edits: [ToolEdit] } + +private struct ToolEdit: Decodable { + var kind: String + var path: WorkspacePath + var content: String? + var source: WorkspacePath? + var recursive: Bool? + + var edit: Edit { + get throws { + switch kind { + case "write_text": .writeText(path, try required(content, "content")) + case "append_text": .appendText(path, try required(content, "content")) + case "create_directory": .createDirectory(path, recursive: recursive ?? true) + case "remove": .remove(path, recursive: recursive ?? true) + case "copy": .copy(from: try required(source, "source"), to: path, recursive: recursive ?? true) + case "move": .move(from: try required(source, "source"), to: path) + default: throw WorkspaceToolError.invalidArguments("unsupported edit kind \(kind)") + } + } + } +} + +private struct DiffArguments: Decodable { + var fromCheckpointID: String? + var toCheckpointID: String? + var unifiedPatch: Bool? + var maxTextBytes: Int? + enum CodingKeys: String, CodingKey { + case fromCheckpointID = "from_checkpoint_id" + case toCheckpointID = "to_checkpoint_id" + case unifiedPatch = "unified_patch" + case maxTextBytes = "max_text_bytes" + } + func revision(_ value: String?) throws -> Revision { try toolRevision(value) } +} + +private struct DiffOutput: Encodable { var changes: ChangeSet; var unifiedPatch: String? } +private struct CheckpointArguments: Decodable { var label: String? } + +private func toolRevision(_ value: String?) throws -> Revision { + guard let value else { return .current } + guard let id = UUID(uuidString: value) else { + throw WorkspaceToolError.invalidArguments("checkpoint id is not a UUID: \(value)") + } + return .checkpoint(id) +} + +private func required(_ value: T?, _ name: String) throws -> T { + guard let value else { throw WorkspaceToolError.invalidArguments("missing \(name)") } + return value +} + +private extension JSONValue { + static func schema(properties: [String: JSONValue], required: [String] = []) -> JSONValue { + var value: [String: JSONValue] = [ + "type": .string("object"), + "properties": .object(properties), + "additionalProperties": .boolean(false), + ] + if !required.isEmpty { value["required"] = .array(required.map(JSONValue.string)) } + return .object(value) + } + + static func stringSchema(_ description: String) -> JSONValue { + .object(["type": .string("string"), "description": .string(description)]) + } + static func booleanSchema(_ description: String) -> JSONValue { + .object(["type": .string("boolean"), "description": .string(description)]) + } + static func integerSchema(_ description: String) -> JSONValue { + .object(["type": .string("integer"), "description": .string(description)]) + } + static func arraySchema(items: JSONValue) -> JSONValue { + .object(["type": .string("array"), "items": items]) + } + static func enumSchema(_ values: [String]) -> JSONValue { + .object(["type": .string("string"), "enum": .array(values.map(JSONValue.string))]) + } +} diff --git a/Tests/WorkspaceTests/WorkspaceToolAdapterTests.swift b/Tests/WorkspaceTests/WorkspaceToolAdapterTests.swift new file mode 100644 index 0000000..39091db --- /dev/null +++ b/Tests/WorkspaceTests/WorkspaceToolAdapterTests.swift @@ -0,0 +1,67 @@ +import Foundation +import Testing +@testable import Workspace + +@Suite("Workspace tool adapter") +struct WorkspaceToolAdapterTests { + @Test + func `definitions encode as vendor neutral JSON schemas`() throws { + let definitions = WorkspaceToolAdapter.definitions + #expect(definitions.map(\.name) == [ + "workspace_read", "workspace_glob", "workspace_search", "workspace_apply", + "workspace_diff", "workspace_checkpoint", + ]) + let data = try JSONEncoder().encode(definitions) + let json = try #require(JSONSerialization.jsonObject(with: data) as? [[String: Any]]) + #expect((json[0]["inputSchema"] as? [String: Any])?["type"] as? String == "object") + } + + @Test + func `apply read checkpoint and diff share one workspace`() async throws { + let workspace = Workspace() + let adapter = WorkspaceToolAdapter(workspace: workspace) + + let applied = try await adapter.call( + name: "workspace_apply", + arguments: Data(#"{"edits":[{"kind":"write_text","path":"/note","content":"one\n"}]}"#.utf8) + ) + let result = try JSONDecoder().decode(EditResult.self, from: applied) + #expect(result.changes.touchedPaths == ["/note"]) + + let checkpointData = try await adapter.call( + name: "workspace_checkpoint", + arguments: Data(#"{"label":"before"}"#.utf8) + ) + let checkpoint = try JSONDecoder().decode(Checkpoint.self, from: checkpointData) + _ = try await workspace.writeText("/note", "two\n") + + let read = try await adapter.call( + name: "workspace_read", + arguments: Data("{\"path\":\"/note\",\"checkpoint_id\":\"\(checkpoint.id)\"}".utf8) + ) + let readJSON = try #require(JSONSerialization.jsonObject(with: read) as? [String: Any]) + #expect(readJSON["content"] as? String == "one\n") + + let diff = try await adapter.call( + name: "workspace_diff", + arguments: Data("{\"from_checkpoint_id\":\"\(checkpoint.id)\",\"unified_patch\":true}".utf8) + ) + let diffJSON = try #require(JSONSerialization.jsonObject(with: diff) as? [String: Any]) + #expect((diffJSON["unifiedPatch"] as? String)?.contains("-one") == true) + #expect((diffJSON["unifiedPatch"] as? String)?.contains("+two") == true) + } + + @Test + func `invalid tool arguments report adapter errors`() async throws { + let adapter = WorkspaceToolAdapter(workspace: Workspace()) + await #expect(throws: WorkspaceToolError.self) { + _ = try await adapter.call( + name: "workspace_apply", + arguments: Data(#"{"edits":[{"kind":"write_text","path":"/note"}]}"#.utf8) + ) + } + await #expect(throws: WorkspaceToolError.self) { + _ = try await adapter.call(name: "missing", arguments: Data("{}".utf8)) + } + } +} From e1e32f8e4c7ea6899f7047c8210dec7f334f2b51 Mon Sep 17 00:00:00 2001 From: Zac White Date: Sun, 12 Jul 2026 15:36:52 -0700 Subject: [PATCH 8/8] Match filesystem filenames to public types --- .../FS/{InMemoryFilesystem.swift => InMemoryFileSystem.swift} | 0 .../FS/{ReadWriteFilesystem.swift => LocalFileSystem.swift} | 0 .../FS/{MountableFilesystem.swift => MountedFileSystem.swift} | 0 .../FS/{OverlayFilesystem.swift => OverlayFileSystem.swift} | 0 .../FS/{SandboxFilesystem.swift => SandboxFileSystem.swift} | 0 ...urityScopedFilesystem.swift => SecurityScopedFileSystem.swift} | 0 6 files changed, 0 insertions(+), 0 deletions(-) rename Sources/Workspace/FS/{InMemoryFilesystem.swift => InMemoryFileSystem.swift} (100%) rename Sources/Workspace/FS/{ReadWriteFilesystem.swift => LocalFileSystem.swift} (100%) rename Sources/Workspace/FS/{MountableFilesystem.swift => MountedFileSystem.swift} (100%) rename Sources/Workspace/FS/{OverlayFilesystem.swift => OverlayFileSystem.swift} (100%) rename Sources/Workspace/FS/{SandboxFilesystem.swift => SandboxFileSystem.swift} (100%) rename Sources/Workspace/FS/{SecurityScopedFilesystem.swift => SecurityScopedFileSystem.swift} (100%) diff --git a/Sources/Workspace/FS/InMemoryFilesystem.swift b/Sources/Workspace/FS/InMemoryFileSystem.swift similarity index 100% rename from Sources/Workspace/FS/InMemoryFilesystem.swift rename to Sources/Workspace/FS/InMemoryFileSystem.swift diff --git a/Sources/Workspace/FS/ReadWriteFilesystem.swift b/Sources/Workspace/FS/LocalFileSystem.swift similarity index 100% rename from Sources/Workspace/FS/ReadWriteFilesystem.swift rename to Sources/Workspace/FS/LocalFileSystem.swift diff --git a/Sources/Workspace/FS/MountableFilesystem.swift b/Sources/Workspace/FS/MountedFileSystem.swift similarity index 100% rename from Sources/Workspace/FS/MountableFilesystem.swift rename to Sources/Workspace/FS/MountedFileSystem.swift diff --git a/Sources/Workspace/FS/OverlayFilesystem.swift b/Sources/Workspace/FS/OverlayFileSystem.swift similarity index 100% rename from Sources/Workspace/FS/OverlayFilesystem.swift rename to Sources/Workspace/FS/OverlayFileSystem.swift diff --git a/Sources/Workspace/FS/SandboxFilesystem.swift b/Sources/Workspace/FS/SandboxFileSystem.swift similarity index 100% rename from Sources/Workspace/FS/SandboxFilesystem.swift rename to Sources/Workspace/FS/SandboxFileSystem.swift diff --git a/Sources/Workspace/FS/SecurityScopedFilesystem.swift b/Sources/Workspace/FS/SecurityScopedFileSystem.swift similarity index 100% rename from Sources/Workspace/FS/SecurityScopedFilesystem.swift rename to Sources/Workspace/FS/SecurityScopedFileSystem.swift