Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions .github/workflows/pr-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
name: PR Tests

on:
pull_request:
types:
- opened
- reopened
- synchronize
- ready_for_review

concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true

jobs:
swift-tests:
name: Swift Tests
runs-on: macos-latest
timeout-minutes: 15

permissions:
actions: read
checks: write
contents: read

steps:
- name: Checkout repository
uses: actions/checkout@v5

- name: Set up Swift
uses: swift-actions/setup-swift@v3
with:
swift-version: "6.2"

- name: Show Swift version
run: swift --version

- name: Run test suite
shell: bash
run: |
mkdir -p .build/test-results
swift test \
--disable-xctest \
--enable-swift-testing \
--enable-code-coverage \
--xunit-output .build/test-results/xunit.xml

- name: Publish test report
if: ${{ !cancelled() }}
uses: dorny/test-reporter@v3
with:
name: Swift Test Report
path: .build/test-results/xunit.xml
reporter: swift-xunit
fail-on-error: false
use-actions-summary: true
badge-title: swift tests
list-suites: failed
list-tests: failed

- name: Publish coverage summary
if: always()
shell: bash
run: |
python3 <<'PY'
import json
import os
import pathlib

summary_path = pathlib.Path(os.environ["GITHUB_STEP_SUMMARY"])
coverage_files = sorted(pathlib.Path(".build").glob("**/debug/codecov/*.json"))

if not coverage_files:
with summary_path.open("a", encoding="utf-8") as handle:
handle.write("\n## Coverage\n\n- No coverage report was generated.\n")
raise SystemExit(0)

coverage = json.loads(coverage_files[0].read_text(encoding="utf-8"))
totals = coverage.get("data", [{}])[0].get("totals", {}).get("lines", {})
count = totals.get("count")
covered = totals.get("covered")
percent = totals.get("percent")

with summary_path.open("a", encoding="utf-8") as handle:
handle.write("\n## Coverage\n\n")
if count is None or covered is None or percent is None:
handle.write("- Coverage totals were unavailable.\n")
else:
handle.write(f"- Lines: {percent:.2f}% ({covered}/{count})\n")
PY

- name: Upload test artifacts
if: always()
uses: actions/upload-artifact@v6
with:
name: pr-test-results-${{ github.event.pull_request.number }}
if-no-files-found: warn
include-hidden-files: true
path: |
.build/test-results/xunit.xml
.build/**/debug/codecov/*.json
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ Many agent and tooling flows need more than plain disk I/O:
## What It Provides

- `Workspace`: high-level actor API for common file operations and batch edits
- `ChangeEvent`: structured change notifications emitted by `Workspace.watchChanges(at:recursive:)`
- `FileSystem`: low-level protocol for custom filesystem backends (see also `ReadableFileSystem` / `WritableFileSystem`)
- `ReadWriteFilesystem`: real disk access rooted to a configured directory
- `InMemoryFilesystem`: fully in-memory filesystem for isolated sessions and tests
Expand Down Expand Up @@ -98,6 +99,23 @@ let config = try await workspace.readJSON(Config.self, from: "/config.json")
print(config.enabled) // true
```

### Change Watching

```swift
import Workspace

let workspace = Workspace(filesystem: InMemoryFilesystem())
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")
```

## Common Patterns

### Rooted Disk Workspace
Expand Down
42 changes: 42 additions & 0 deletions Sources/Workspace/ChangeEvent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
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
}
}
Loading
Loading