Skip to content

Implement copy-on-write overlay filesystem and three-way merge#7

Merged
zac merged 7 commits into
mainfrom
core-improvements
Jul 9, 2026
Merged

Implement copy-on-write overlay filesystem and three-way merge#7
zac merged 7 commits into
mainfrom
core-improvements

Conversation

@zac

@zac zac commented Jul 9, 2026

Copy link
Copy Markdown
Member

This PR introduces a complete redesign of the overlay filesystem and adds three-way merge support for workspaces.

Summary

The OverlayFilesystem is reimplemented as a true copy-on-write (CoW) system with lazy pass-through reads to a lower disk layer and mutations recorded in an upper in-memory layer. Additionally, workspaces now support three-way merging against a branch's base checkpoint, enabling merges even when the parent workspace has advanced since branching.

Key Changes

Overlay Filesystem Redesign

  • Converts OverlayFilesystem from eager snapshot-based to lazy copy-on-write architecture
  • Reads pass through to the lower (disk) layer only when needed; mutations land in the upper (in-memory) layer
  • Implements "cuts" (whiteouts) to hide deleted lower-layer entries while allowing upper-layer shadowing
  • reload() discards the upper layer and whiteouts to reflect the current disk state
  • Adds support for symlinks, permissions, and proper directory merging between layers
  • Implements glob(), readSymlink(), resolveRealPath(), and other missing filesystem operations

Three-Way Merge

  • Adds Workspace.mergeThreeWay(_:label:) for per-path three-way resolution against a branch's base checkpoint
  • Resolves conflicts by taking the side that changed relative to the base; identical changes merge cleanly
  • Returns ThreeWayMergeResult with either a checkpoint or a list of MergeConflict entries
  • Enables merging even when the parent workspace has uncheckpointed changes (unlike merge(_:label:))
  • Includes comprehensive conflict detection and reporting with optional text diffs

Tracking Policy

  • Introduces Workspace.TrackingPolicy to control mutation history detail:
    • .fullDiffs(maxDiffBytes:) records full diffs with optional size cap (default)
    • .pathsOnly records touched paths and effects without diffs
    • .disabled disables mutation history entirely
  • Allows workspaces to optimize storage and performance based on merge requirements

Snapshot Storage Optimization

  • Refactors FileCheckpointStore to deduplicate snapshot content into content-addressed blobs
  • Snapshots are persisted as SnapshotManifest with file contents replaced by SHA-256 hashes
  • Identical content across snapshots is stored once in blobs/<sha256> files
  • Adds SHA256 utility for content addressing (FIPS 180-4 compliant)

Filesystem Improvements

  • Adds readFile(path:offset:length:) for ranged reads without loading entire files
  • Adds readFileChunks(path:chunkSize:) for streaming large files incrementally
  • Adds FileSystemCapabilities to advertise optional features (symlinks, hardlinks, permissions, real path resolution)
  • Implements lstat semantics in ReadWriteFilesystem.stat() to handle symlinks without following them
  • Adds proper symlink handling that prevents escaping the filesystem root

Mutation Record Improvements

  • Makes MutationRecord public with improved documentation
  • Adds CheckpointStore.pruneMutationRecords() to clean up old mutation history while retaining the latest record
  • Adds rollback mutation kind to record rollback operations in the mutation log

Platform Support

  • Adds conditional imports for Darwin and Glibc to support both macOS and Linux
  • Updates CI workflow to test on both macOS and Linux

Notable Implementation Details

  • The overlay filesystem uses a Set<WorkspacePath> to track "cuts" (deleted entries) efficiently
  • Three-way merge flattens snapshot trees into path-indexed maps for efficient per-path comparison
  • Snapshot manifests use content hashing to enable deduplication while maintaining full reconstruction capability
  • The ReadWriteFilesystem validates symlink targets to prevent escaping the configured root directory
  • Glob patterns properly distinguish between * (single component), ? (single character), and ** (any characters including /)

https://claude.ai/code/session_01G29SXqzHqDycqXGa4dRr4x

claude added 7 commits July 9, 2026 03:29
Run the test suite on ubuntu-24.04 alongside macOS. Guard the
Darwin-only surfaces so the package builds on Linux:
- SecurityScopedFilesystem (security scopes and URL bookmarks) is
  compiled only where Darwin is available.
- SandboxFilesystem app-group resolution throws unsupported on
  platforms without container APIs.
- OverlayFilesystem import classifies entries via FileManager
  attributes instead of URL resource values.

The workflow also runs on pushes to core-improvements so changes are
verified before a PR exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G29SXqzHqDycqXGa4dRr4x
…tency

- CI: run the Linux job inside the official swift:6.2-noble container;
  setup-swift's GPG verification fails on ubuntu runners.
- WorkspacePath.globToRegex: * and ? no longer cross path separators,
  ** matches recursively, and [!abc] negation is translated correctly.
  Previously /docs/*.txt also matched /docs/sub/deep.txt.
- ReadWriteFilesystem: entry-level operations (stat, exists, remove,
  move source, readSymlink) now use lstat semantics behind the same
  parent containment check. Symlinks whose targets lie outside the
  root were previously unstattable and undeletable, and exists()
  bypassed the jail entirely, following links and acting as an
  existence oracle for arbitrary external paths. Content operations
  still refuse to dereference an escaping link.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G29SXqzHqDycqXGa4dRr4x
FileSystem gains four operations, all with default implementations so
existing conformances keep compiling:
- readFile(path:offset:length:) reads a byte range; ReadWriteFilesystem
  overrides it with a seeking file handle so large files are not loaded
  fully into memory.
- readFileChunks(path:chunkSize:) streams contents as AsyncThrowingStream
  chunks; the disk backend reads incrementally.
- createFile(path:data:) creates exclusively and fails with EEXIST; the
  disk backend uses an atomic no-overwrite write and the in-memory
  backend checks-and-writes inside its actor.
- capabilities() advertises optional features (symlinks, hard links,
  permissions, real-path resolution) so callers can branch instead of
  probing for unsupported errors.

Wrappers (Permissioned, Mountable, Overlay, Sandbox, SecurityScoped)
forward the new operations; PermissionedFileSystem maps them onto the
existing readFile/writeFile permission operations. Workspace exposes
readData(from:offset:length:).

Also updates the three glob tests that asserted the old
crossing-separator wildcard behavior, and marks the checkout safe for
git inside the Linux CI container so the test reporter can run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G29SXqzHqDycqXGa4dRr4x
The previous implementation eagerly copied the entire source tree,
contents included, into memory at configure time, so overlaying a real
project cost memory and startup time proportional to the whole tree.

The overlay is now a merged view of two layers: reads pass through
lazily to the source directory, mutations land in an in-memory upper
layer with on-demand copy-up (append copies the file up, whole-tree
moves and copies materialize the affected subtree), and deletions are
whiteout cuts that hide lower entries without touching the disk.
Directory listings merge both layers with upper entries shadowing
lower ones, re-created directories do not resurrect hidden lower
children, symlinks resolve across layers at the final component, and
stat passes through source permissions and modification dates
(previously mtimes were lost on import). reload() and configure()
drop the upper layer and whiteouts in O(1) instead of re-importing.

Behavioral note: files that change on disk are now visible through the
overlay immediately, since reads are pass-through rather than a
configure-time snapshot.

Also fixes the escaped-separator expectation in the globToRegex test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G29SXqzHqDycqXGa4dRr4x
FileCheckpointStore now persists snapshots as manifests plus
content-addressed blobs:
- File bytes live in per-workspace blobs/<sha256> files, written once
  per unique content, so consecutive checkpoints share unchanged files
  instead of re-serializing the whole tree, and contents are stored
  raw instead of base64-inflated inside pretty-printed JSON.
- Snapshot manifests carry the tree structure, permissions, and
  content hashes. Legacy inline-snapshot JSON files still load.
- Hashing uses a small dependency-free SHA-256 (FIPS 180-4) with test
  vectors, keeping the package free of external dependencies.
- A missing blob surfaces as the new WorkspaceError.storageCorrupted.

Store robustness:
- rollback(to:) now records the tree changes it performs as a new
  `rollback` mutation kind, closing the gap where the mutation log
  missed exactly the changes made by rollbacks.
- CheckpointStore gains pruneMutationRecords(workspaceId:throughSequence:)
  and Workspace gains pruneMutationHistory(throughSequence:), which
  bound the mutation log while always retaining the newest record so
  sequence numbers stay monotonic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G29SXqzHqDycqXGa4dRr4x
…anning

Workspace gains a TrackingPolicy controlling how much detail tracked
writes record:
- .full (default) keeps today's behavior: full mutation records with
  per-file line diffs.
- .fullDiffs(maxDiffBytes:) caps the content size for which diffs are
  computed, so huge files no longer trigger whole-content diffing.
- .pathsOnly records touched paths and effects without diffs.
- .disabled records no history; change events still fire, and the
  policy is inherited by branches. Merge's uncheckpointed-changes
  guard cannot see edits made in this mode (documented).

MutationRecord and mutationRecords() are now public, so consumers can
actually read the history the workspace records.

Event planning for deletions and moves now walks structure only
(stats and listings) instead of capturing full file contents, removing
a whole-subtree content read from every removeItem/moveItem/copyItem.
Replacement change events are driven by replacement counts instead of
diff presence so they keep firing when diffs are suppressed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G29SXqzHqDycqXGa4dRr4x
mergeThreeWay(_:label:) resolves each path against the branch's base
checkpoint snapshot: the side that changed relative to the base wins,
identical changes on both sides merge cleanly, and branch deletions
propagate. Unlike merge(_:), the parent may have advanced -- with or
without checkpoints -- since the branch was created.

When both sides changed the same path differently, nothing is applied
and the result carries structured MergeConflict values (bothModified,
modifiedAndDeleted, bothCreated) with base-to-ours and base-to-theirs
text diffs, so callers can present or resolve conflicts instead of
handling a thrown error. A clean merge restores the merged tree,
records a mergeWorkspace mutation, and creates a checkpoint carrying
the mergedFrom lineage fields.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G29SXqzHqDycqXGa4dRr4x
@zac
zac merged commit 50949bb into main Jul 9, 2026
4 checks passed
@zac
zac deleted the core-improvements branch July 9, 2026 04:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants