From 035ca545a646b39c1cc0a6d2a7dc892b8fed892e Mon Sep 17 00:00:00 2001 From: Alexander Ivanov Date: Sat, 22 Aug 2026 14:19:07 +0300 Subject: [PATCH 1/2] Exclude caches --- .../design.md | 34 +++++++++++ .../proposal.md | 31 ++++++++++ .../specs/persistent-workbench-runs/spec.md | 20 +++++++ .../tasks.md | 12 ++++ package-lock.json | 4 +- packages/core/package.json | 4 +- packages/core/src/checkpoint.test.ts | 48 +++++++++++++++ packages/core/src/checkpoint.ts | 60 +++++++++++++++++-- packages/core/src/workbench-recovery.test.ts | 30 ++++++++++ packages/extension/package.json | 4 +- .../src/implementation-sessions.test.ts | 29 +++++++++ 11 files changed, 264 insertions(+), 12 deletions(-) create mode 100644 openspec/changes/exclude-checkpoint-environment-and-caches/design.md create mode 100644 openspec/changes/exclude-checkpoint-environment-and-caches/proposal.md create mode 100644 openspec/changes/exclude-checkpoint-environment-and-caches/specs/persistent-workbench-runs/spec.md create mode 100644 openspec/changes/exclude-checkpoint-environment-and-caches/tasks.md diff --git a/openspec/changes/exclude-checkpoint-environment-and-caches/design.md b/openspec/changes/exclude-checkpoint-environment-and-caches/design.md new file mode 100644 index 00000000..60fb8b84 --- /dev/null +++ b/openspec/changes/exclude-checkpoint-environment-and-caches/design.md @@ -0,0 +1,34 @@ +## Context + +Checkpoint capture currently excludes selected JavaScript build and dependency +directories, but not common Python generated state. Persisted checkpoints may +also contain paths that become excluded after an extension upgrade. + +## Decisions + +### Exclude generated state by exact basename + +Directory exclusions use exact basenames for established generated locations: +`.cache`, `__pycache__`, `.mypy_cache`, `.pytest_cache`, `.ruff_cache`, +`.hypothesis`, `.tox`, `.nox`, `.venv`, and `venv`. File exclusions use exact +basenames for `.env` and `.eslintcache`. Exact matching avoids treating +application source directories containing words such as `cache` as generated +state. + +### Sanitize checkpoints during deserialization + +Deserialization filters excluded paths from `before`, `after`, and `delta`. +Both extension and standalone recovery already persist deserialized sessions, +so loading an existing journal rewrites it without excluded content. Workspace +files are not removed. Rollback intentionally stops covering excluded paths. + +The serialized checkpoint version remains `1` because the representation does +not change and older version-1 documents remain readable. + +## Risks + +- A user who intentionally keeps source in an exactly named excluded directory + cannot restore it through workbench rollback. The exclusions are limited to + conventional generated-state names to reduce this risk. +- Historical `.env` data remains on disk until the upgraded extension activates + for that workspace and successfully rewrites its journal. diff --git a/openspec/changes/exclude-checkpoint-environment-and-caches/proposal.md b/openspec/changes/exclude-checkpoint-environment-and-caches/proposal.md new file mode 100644 index 00000000..d3a98fe3 --- /dev/null +++ b/openspec/changes/exclude-checkpoint-environment-and-caches/proposal.md @@ -0,0 +1,31 @@ +## Why + +Workbench checkpoints scan the entire workspace before mutating operations. +Python virtual environments and generated caches can exceed checkpoint limits, +while `.env` contents can persist secrets that must never be stored in run +history. + +## What Changes + +- Exclude `.env`, generated cache directories, cache files, and local virtual + environments from new workbench checkpoints. +- Remove those paths from checkpoints restored from existing run journals and + persist the sanitized journal without deleting workspace files. +- Cover new capture and migration behavior with core tests. + +## Capabilities + +### New Capabilities + +(none) + +### Modified Capabilities + +- `persistent-workbench-runs`: Checkpoint capture and recovery omit sensitive + environment files and generated local state. + +## Impact + +- `packages/core/src/checkpoint.ts` +- `packages/core/src/checkpoint.test.ts` +- Core and extension patch versions diff --git a/openspec/changes/exclude-checkpoint-environment-and-caches/specs/persistent-workbench-runs/spec.md b/openspec/changes/exclude-checkpoint-environment-and-caches/specs/persistent-workbench-runs/spec.md new file mode 100644 index 00000000..1bfe2b91 --- /dev/null +++ b/openspec/changes/exclude-checkpoint-environment-and-caches/specs/persistent-workbench-runs/spec.md @@ -0,0 +1,20 @@ +## MODIFIED Requirements + +### Requirement: Checkpoint persistence excludes sensitive and generated state + +The workbench SHALL omit sensitive environment files, generated caches, and +local virtual environments from checkpoint capture and rollback data. + +#### Scenario: Capture a workspace containing generated local state + +- **WHEN** a checkpoint is captured for a workspace containing `.env`, a local + virtual environment, or a supported generated cache directory +- **THEN** those paths are not read into the checkpoint +- **AND** ordinary workspace files remain covered by rollback + +#### Scenario: Restore a historical checkpoint + +- **WHEN** a version-1 run journal contains paths that are excluded by the + current checkpoint policy +- **THEN** recovery removes those paths from the checkpoint snapshots and delta +- **AND** the sanitized journal is persisted without deleting workspace files \ No newline at end of file diff --git a/openspec/changes/exclude-checkpoint-environment-and-caches/tasks.md b/openspec/changes/exclude-checkpoint-environment-and-caches/tasks.md new file mode 100644 index 00000000..b4b26f7c --- /dev/null +++ b/openspec/changes/exclude-checkpoint-environment-and-caches/tasks.md @@ -0,0 +1,12 @@ +## 1. Checkpoint exclusions + +- [x] 1.1 Exclude sensitive environment and generated cache paths before file + contents are read. +- [x] 1.2 Sanitize excluded paths from deserialized historical checkpoints. +- [x] 1.3 Add focused capture and migration tests. + +## 2. Release and verification + +- [x] 2.1 Bump affected package patch versions. +- [x] 2.2 Run focused core tests and typecheck. +- [x] 2.3 Validate this OpenSpec change strictly. diff --git a/package-lock.json b/package-lock.json index aa9d0e10..1b131497 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10778,7 +10778,7 @@ }, "packages/core": { "name": "@openspec-ui/core", - "version": "0.20.0", + "version": "0.20.1", "dependencies": { "cross-spawn": "^7.0.6", "simple-git": "^3.27.0" @@ -10790,7 +10790,7 @@ }, "packages/extension": { "name": "openspec-ui-vscode", - "version": "0.16.0", + "version": "0.16.1", "license": "MIT", "dependencies": { "@openspec-ui/core": "*", diff --git a/packages/core/package.json b/packages/core/package.json index 06cd588a..444463d7 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,7 +1,7 @@ { "name": "@openspec-ui/core", "private": true, - "version": "0.20.0", + "version": "0.20.1", "type": "module", "main": "src/index.ts", "exports": { @@ -21,4 +21,4 @@ "@types/node": "^26.2.0", "@types/cross-spawn": "^6.0.6" } -} \ No newline at end of file +} diff --git a/packages/core/src/checkpoint.test.ts b/packages/core/src/checkpoint.test.ts index b5126632..ab77e760 100644 --- a/packages/core/src/checkpoint.test.ts +++ b/packages/core/src/checkpoint.test.ts @@ -78,6 +78,29 @@ describe("Workbench checkpoints", () => { expect(checkpoint.coverage.excludedDirectories).toContain("node_modules"); }); + it("excludes environment files, virtual environments, and generated caches", async () => { + const root = await temporaryRoot(); + await mkdir(path.join(root, ".venv"), { recursive: true }); + await mkdir(path.join(root, ".mypy_cache"), { recursive: true }); + await mkdir(path.join(root, "src", "__pycache__"), { recursive: true }); + await writeFile(path.join(root, ".env"), "SECRET=value"); + await writeFile(path.join(root, ".eslintcache"), "generated"); + await writeFile(path.join(root, ".venv", "dependency.py"), "ignored"); + await writeFile(path.join(root, ".mypy_cache", "state.json"), "ignored"); + await writeFile(path.join(root, "src", "__pycache__", "module.pyc"), "ignored"); + await writeFile(path.join(root, "tracked.txt"), "tracked"); + + const checkpoint = await captureCheckpoint(root); + + expect([...checkpoint.before.keys()]).toEqual(["tracked.txt"]); + expect(checkpoint.coverage.skippedFiles).toEqual([".env", ".eslintcache"]); + expect(checkpoint.coverage.excludedDirectories).toEqual(expect.arrayContaining([ + ".mypy_cache", + ".venv", + "__pycache__", + ])); + }); + it("round-trips persisted checkpoints and rejects unsafe paths", async () => { const root = await temporaryRoot(); await writeFile(path.join(root, "work.txt"), "before"); @@ -93,6 +116,31 @@ describe("Workbench checkpoints", () => { serialized.before[0]!.path = "../outside.txt"; expect(() => deserializeCheckpoint(serialized)).toThrow("Invalid checkpoint path"); }); + + it("removes newly excluded paths from historical checkpoints", async () => { + const root = await temporaryRoot(); + await writeFile(path.join(root, "tracked.txt"), "before"); + const checkpoint = await captureCheckpoint(root); + await writeFile(path.join(root, "tracked.txt"), "after"); + await finalizeCheckpoint(checkpoint); + const serialized = serializeCheckpoint(checkpoint); + const snapshot = serialized.before[0]!; + const afterSnapshot = serialized.after![0]!; + serialized.before.push({ ...snapshot, path: ".env" }); + serialized.before.push({ ...snapshot, path: ".mypy_cache/cache.json" }); + serialized.after!.push({ ...afterSnapshot, path: ".env" }); + serialized.after!.push({ ...afterSnapshot, path: ".mypy_cache/cache.json" }); + serialized.delta!.push({ path: ".env", kind: "modified" }); + serialized.delta!.push({ path: ".mypy_cache/cache.json", kind: "modified" }); + + const restored = deserializeCheckpoint(serialized); + + expect([...restored.before.keys()]).toEqual(["tracked.txt"]); + expect([...restored.after!.keys()]).toEqual(["tracked.txt"]); + expect(restored.delta?.map((item) => item.path)).toEqual(["tracked.txt"]); + expect(restored.coverage.skippedFiles).toContain(".env"); + expect(restored.coverage.excludedDirectories).toContain(".mypy_cache"); + }); }); describe("rollbackChangeCheckpoints", () => { diff --git a/packages/core/src/checkpoint.ts b/packages/core/src/checkpoint.ts index 8a00c8f5..17e1dedb 100644 --- a/packages/core/src/checkpoint.ts +++ b/packages/core/src/checkpoint.ts @@ -5,6 +5,16 @@ import path from "node:path"; const DEFAULT_EXCLUDED_DIRECTORIES = new Set([ ".git", ".openspec-ui", + ".cache", + "__pycache__", + ".mypy_cache", + ".pytest_cache", + ".ruff_cache", + ".hypothesis", + ".tox", + ".nox", + ".venv", + "venv", "node_modules", "dist", "build", @@ -13,6 +23,24 @@ const DEFAULT_EXCLUDED_DIRECTORIES = new Set([ ".vscode-test", ]); +const DEFAULT_EXCLUDED_FILES = new Set([".env", ".eslintcache"]); + +function checkpointPathParts(filePath: string): string[] { + return filePath.split(/[\\/]/); +} + +function isExcludedCheckpointPath(filePath: string): boolean { + const parts = checkpointPathParts(filePath); + const fileName = parts.at(-1); + return parts.slice(0, -1).some((part) => DEFAULT_EXCLUDED_DIRECTORIES.has(part)) + || (fileName !== undefined && DEFAULT_EXCLUDED_FILES.has(fileName)); +} + +function isExcludedCheckpointFile(filePath: string): boolean { + const fileName = checkpointPathParts(filePath).at(-1); + return fileName !== undefined && DEFAULT_EXCLUDED_FILES.has(fileName); +} + export interface CheckpointLimits { maxFiles?: number; maxBytes?: number; @@ -92,8 +120,12 @@ async function scanWorkspace(root: string, limits: Required): continue; } if (!entry.isFile()) continue; - const content = await readFile(absolutePath); const relativePath = path.relative(root, absolutePath); + if (DEFAULT_EXCLUDED_FILES.has(entry.name)) { + skippedFiles.push(relativePath); + continue; + } + const content = await readFile(absolutePath); if (content.byteLength > limits.maxFileBytes) { skippedFiles.push(relativePath); continue; @@ -205,17 +237,33 @@ export function serializeCheckpoint(checkpoint: WorkbenchCheckpoint): Serialized export function deserializeCheckpoint(serialized: SerializedWorkbenchCheckpoint): WorkbenchCheckpoint { if (serialized.version !== 1) throw new Error(`Unsupported checkpoint version: ${String(serialized.version)}`); + for (const item of serialized.before) assertSafeRelativePath(item.path); + for (const item of serialized.after ?? []) assertSafeRelativePath(item.path); for (const item of serialized.delta ?? []) assertSafeRelativePath(item.path); + const before = serialized.before.filter((item) => !isExcludedCheckpointPath(item.path)); + const after = serialized.after?.filter((item) => !isExcludedCheckpointPath(item.path)); + const delta = serialized.delta?.filter((item) => !isExcludedCheckpointPath(item.path)); + const newlySkippedFiles = [ + ...serialized.before.map((item) => item.path), + ...(serialized.after?.map((item) => item.path) ?? []), + ...(serialized.delta?.map((item) => item.path) ?? []), + ].filter(isExcludedCheckpointFile); return { id: serialized.id, root: path.resolve(serialized.root), createdAt: serialized.createdAt, - before: deserializeFiles(serialized.before), - after: serialized.after ? deserializeFiles(serialized.after) : undefined, - delta: serialized.delta?.map((item) => ({ ...item })), + before: deserializeFiles(before), + after: after ? deserializeFiles(after) : undefined, + delta: delta?.map((item) => ({ ...item })), coverage: { - excludedDirectories: [...serialized.coverage.excludedDirectories], - skippedFiles: [...serialized.coverage.skippedFiles], + excludedDirectories: [...new Set([ + ...serialized.coverage.excludedDirectories, + ...DEFAULT_EXCLUDED_DIRECTORIES, + ])].sort(), + skippedFiles: [...new Set([ + ...serialized.coverage.skippedFiles, + ...newlySkippedFiles, + ])].sort(), }, }; } diff --git a/packages/core/src/workbench-recovery.test.ts b/packages/core/src/workbench-recovery.test.ts index 38567e96..42772580 100644 --- a/packages/core/src/workbench-recovery.test.ts +++ b/packages/core/src/workbench-recovery.test.ts @@ -36,6 +36,36 @@ describe("WorkbenchRecoveryService", () => { expect(service.details("run-1")).toMatchObject({ canRollback: true, delta: [{ path: "tracked.txt", kind: "modified" }] }); }); + it("rewrites historical journals without newly excluded checkpoint paths", async () => { + const root = await createRoot(); + await writeFile(path.join(root, "tracked.txt"), "before", "utf8"); + const checkpoint = await captureCheckpoint(root); + await writeFile(path.join(root, "tracked.txt"), "after", "utf8"); + await finalizeCheckpoint(checkpoint); + const serialized = serializeCheckpoint(checkpoint); + const beforeSnapshot = serialized.before[0]!; + const afterSnapshot = serialized.after![0]!; + serialized.before.push({ ...beforeSnapshot, path: ".env" }); + serialized.before.push({ ...beforeSnapshot, path: ".mypy_cache/state.json" }); + serialized.after!.push({ ...afterSnapshot, path: ".env" }); + serialized.after!.push({ ...afterSnapshot, path: ".mypy_cache/state.json" }); + serialized.delta!.push({ path: ".env", kind: "modified" }); + serialized.delta!.push({ path: ".mypy_cache/state.json", kind: "modified" }); + const journal = new WorkbenchRunJournal(root); + await journal.save({ + processes: [{ id: "run-1", operation: "implement", mutating: true, state: "completed", createdAt: "2026-08-01T00:00:00.000Z" }], + checkpointSessions: [{ processId: "run-1", checkpoint: serialized }], + }); + + await WorkbenchRecoveryService.open(root); + const persisted = await journal.load(); + const restoredCheckpoint = persisted.checkpointSessions[0]!.checkpoint; + + expect(restoredCheckpoint.before.map((item) => item.path)).toEqual(["tracked.txt"]); + expect(restoredCheckpoint.after?.map((item) => item.path)).toEqual(["tracked.txt"]); + expect(restoredCheckpoint.delta?.map((item) => item.path)).toEqual(["tracked.txt"]); + }); + it("rolls back conflict-free files and reports later conflicts without partial writes", async () => { const root = await createRoot(); const filePath = path.join(root, "tracked.txt"); diff --git a/packages/extension/package.json b/packages/extension/package.json index 154432b1..a9450190 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -3,7 +3,7 @@ "displayName": "OpenSpec Workbench", "description": "A dashboard + VS Code extension for OpenSpec, with Claude, Copilot, Codex, and Gemini agents built in.", "publisher": "openspec-ui", - "version": "0.16.0", + "version": "0.16.1", "icon": "media/icon.png", "license": "MIT", "repository": { @@ -358,4 +358,4 @@ "test:integration": "node src/test/run.mjs", "package": "npm run build && vsce package --no-dependencies" } -} \ No newline at end of file +} diff --git a/packages/extension/src/implementation-sessions.test.ts b/packages/extension/src/implementation-sessions.test.ts index c4f345ae..a7e8bb7d 100644 --- a/packages/extension/src/implementation-sessions.test.ts +++ b/packages/extension/src/implementation-sessions.test.ts @@ -70,6 +70,35 @@ describe("ImplementationSessionManager", () => { expect(await readFile(filePath, "utf8")).toBe("before"); }); + it("sanitizes excluded paths when restoring historical sessions", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "openspec-session-")); + roots.push(root); + await writeFile(path.join(root, "code.ts"), "before"); + const serialized = serializeCheckpoint(await captureCheckpoint(root)); + const snapshot = serialized.before[0]!; + serialized.before.push({ ...snapshot, path: ".env" }); + serialized.before.push({ ...snapshot, path: ".pytest_cache/state.json" }); + const scheduler = new WorkbenchProcessScheduler([{ + id: "recovered", + operation: "archive", + changeName: "demo", + mutating: true, + state: "completed", + createdAt: "2026-08-08T10:00:00.000Z", + }]); + let persistRequests = 0; + const manager = new ImplementationSessionManager(scheduler, () => { persistRequests += 1; }); + + await manager.restore([{ + processId: "recovered", + changeName: "demo", + checkpoint: serialized, + }]); + + expect(manager.exportPersisted()[0]!.checkpoint.before.map((item) => item.path)).toEqual(["code.ts"]); + expect(persistRequests).toBe(1); + }); + it("preserves rollback state when a lifecycle mutation fails", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "openspec-session-")); roots.push(root); From 3a8d16ad4f711f7887fb11e396fe5ca5b2a01b23 Mon Sep 17 00:00:00 2001 From: Alexander Ivanov Date: Sat, 22 Aug 2026 14:32:26 +0300 Subject: [PATCH 2/2] Honor Git ignores in checkpoints --- README.md | 8 +-- .../design.md | 15 ++++ .../proposal.md | 4 ++ .../specs/persistent-workbench-runs/spec.md | 17 ++++- .../tasks.md | 5 ++ .../specs/persistent-workbench-runs/spec.md | 32 +++++++++ package-lock.json | 4 +- packages/core/package.json | 2 +- packages/core/src/checkpoint.test.ts | 39 +++++++++++ packages/core/src/checkpoint.ts | 70 ++++++++++++++----- packages/extension/CHANGELOG.md | 9 +++ packages/extension/package.json | 2 +- 12 files changed, 181 insertions(+), 26 deletions(-) rename openspec/changes/{exclude-checkpoint-environment-and-caches => archive/2026-08-22-exclude-checkpoint-environment-and-caches}/design.md (64%) rename openspec/changes/{exclude-checkpoint-environment-and-caches => archive/2026-08-22-exclude-checkpoint-environment-and-caches}/proposal.md (80%) rename openspec/changes/{exclude-checkpoint-environment-and-caches => archive/2026-08-22-exclude-checkpoint-environment-and-caches}/specs/persistent-workbench-runs/spec.md (59%) rename openspec/changes/{exclude-checkpoint-environment-and-caches => archive/2026-08-22-exclude-checkpoint-environment-and-caches}/tasks.md (57%) diff --git a/README.md b/README.md index 36bfacc7..a7ce9da4 100644 --- a/README.md +++ b/README.md @@ -181,10 +181,10 @@ release artifact. Current release versions are: | Package | Version | Release role | | --- | ---: | --- | -| `@openspec-ui/core` | 0.15.0 | Shared behavior and persistence contract | -| `openspec-ui-vscode` | 0.9.0 | VS Code delivery | -| `@openspec-ui/server` | 1.6.0 | Standalone server delivery | -| `@openspec-ui/webui` | 1.7.0 | Shared browser UI | +| `@openspec-ui/core` | 0.20.2 | Shared behavior and persistence contract | +| `openspec-ui-vscode` | 0.16.2 | VS Code delivery | +| `@openspec-ui/server` | 1.8.0 | Standalone server delivery | +| `@openspec-ui/webui` | 1.9.0 | Shared browser UI | | `@openspec-ui/cli` | 0.1.0 | CI merge-gate delivery | `@vscode/vsce` (the extension's packager) already names the built diff --git a/openspec/changes/exclude-checkpoint-environment-and-caches/design.md b/openspec/changes/archive/2026-08-22-exclude-checkpoint-environment-and-caches/design.md similarity index 64% rename from openspec/changes/exclude-checkpoint-environment-and-caches/design.md rename to openspec/changes/archive/2026-08-22-exclude-checkpoint-environment-and-caches/design.md index 60fb8b84..8b2426c4 100644 --- a/openspec/changes/exclude-checkpoint-environment-and-caches/design.md +++ b/openspec/changes/archive/2026-08-22-exclude-checkpoint-environment-and-caches/design.md @@ -6,6 +6,19 @@ also contain paths that become excluded after an extension upgrade. ## Decisions +### Delegate repository ignore semantics to Git + +For a Git workspace, checkpoint capture asks Git for tracked and untracked +non-ignored files using `git ls-files --cached --others --exclude-standard`. +This honors root and nested `.gitignore` files, negated patterns, +`.git/info/exclude`, and the user's global Git excludes. Tracked files remain +covered even when they match a later ignore rule. If the workspace is not in a +Git repository, capture falls back to the existing filesystem traversal and +the mandatory exact-name exclusions. + +Git only returns paths; checkpoint code still validates each path, skips +symbolic links, enforces size limits, and reads file contents itself. + ### Exclude generated state by exact basename Directory exclusions use exact basenames for established generated locations: @@ -32,3 +45,5 @@ not change and older version-1 documents remain readable. conventional generated-state names to reduce this risk. - Historical `.env` data remains on disk until the upgraded extension activates for that workspace and successfully rewrites its journal. +- Git-ignored files are intentionally outside rollback coverage. Teams should + not ignore source files that must be recoverable through workbench rollback. diff --git a/openspec/changes/exclude-checkpoint-environment-and-caches/proposal.md b/openspec/changes/archive/2026-08-22-exclude-checkpoint-environment-and-caches/proposal.md similarity index 80% rename from openspec/changes/exclude-checkpoint-environment-and-caches/proposal.md rename to openspec/changes/archive/2026-08-22-exclude-checkpoint-environment-and-caches/proposal.md index d3a98fe3..4ca92e66 100644 --- a/openspec/changes/exclude-checkpoint-environment-and-caches/proposal.md +++ b/openspec/changes/archive/2026-08-22-exclude-checkpoint-environment-and-caches/proposal.md @@ -9,6 +9,8 @@ history. - Exclude `.env`, generated cache directories, cache files, and local virtual environments from new workbench checkpoints. +- Use the workspace repository's Git ignore rules so language- and + project-specific generated files are omitted without a hard-coded catalog. - Remove those paths from checkpoints restored from existing run journals and persist the sanitized journal without deleting workspace files. - Cover new capture and migration behavior with core tests. @@ -28,4 +30,6 @@ history. - `packages/core/src/checkpoint.ts` - `packages/core/src/checkpoint.test.ts` +- `packages/extension/CHANGELOG.md` +- `README.md` release version table - Core and extension patch versions diff --git a/openspec/changes/exclude-checkpoint-environment-and-caches/specs/persistent-workbench-runs/spec.md b/openspec/changes/archive/2026-08-22-exclude-checkpoint-environment-and-caches/specs/persistent-workbench-runs/spec.md similarity index 59% rename from openspec/changes/exclude-checkpoint-environment-and-caches/specs/persistent-workbench-runs/spec.md rename to openspec/changes/archive/2026-08-22-exclude-checkpoint-environment-and-caches/specs/persistent-workbench-runs/spec.md index 1bfe2b91..aa0c61a9 100644 --- a/openspec/changes/exclude-checkpoint-environment-and-caches/specs/persistent-workbench-runs/spec.md +++ b/openspec/changes/archive/2026-08-22-exclude-checkpoint-environment-and-caches/specs/persistent-workbench-runs/spec.md @@ -1,4 +1,4 @@ -## MODIFIED Requirements +## ADDED Requirements ### Requirement: Checkpoint persistence excludes sensitive and generated state @@ -17,4 +17,17 @@ local virtual environments from checkpoint capture and rollback data. - **WHEN** a version-1 run journal contains paths that are excluded by the current checkpoint policy - **THEN** recovery removes those paths from the checkpoint snapshots and delta -- **AND** the sanitized journal is persisted without deleting workspace files \ No newline at end of file +- **AND** the sanitized journal is persisted without deleting workspace files + +#### Scenario: Capture a Git workspace with project-specific ignores + +- **WHEN** a checkpoint is captured in a Git workspace with root or nested + `.gitignore` rules +- **THEN** untracked paths ignored by Git are omitted from the checkpoint +- **AND** negated untracked paths and tracked files remain covered + +#### Scenario: Capture a workspace outside Git + +- **WHEN** Git cannot enumerate files for the workspace +- **THEN** checkpoint capture falls back to filesystem traversal +- **AND** mandatory sensitive and generated-state exclusions still apply \ No newline at end of file diff --git a/openspec/changes/exclude-checkpoint-environment-and-caches/tasks.md b/openspec/changes/archive/2026-08-22-exclude-checkpoint-environment-and-caches/tasks.md similarity index 57% rename from openspec/changes/exclude-checkpoint-environment-and-caches/tasks.md rename to openspec/changes/archive/2026-08-22-exclude-checkpoint-environment-and-caches/tasks.md index b4b26f7c..2009722b 100644 --- a/openspec/changes/exclude-checkpoint-environment-and-caches/tasks.md +++ b/openspec/changes/archive/2026-08-22-exclude-checkpoint-environment-and-caches/tasks.md @@ -4,9 +4,14 @@ contents are read. - [x] 1.2 Sanitize excluded paths from deserialized historical checkpoints. - [x] 1.3 Add focused capture and migration tests. +- [x] 1.4 Honor repository Git ignore rules, including nested and negated + patterns, while retaining a non-Git fallback. +- [x] 1.5 Add focused Git-ignore checkpoint tests. ## 2. Release and verification - [x] 2.1 Bump affected package patch versions. - [x] 2.2 Run focused core tests and typecheck. - [x] 2.3 Validate this OpenSpec change strictly. +- [x] 2.4 Bump core and extension patch versions for Git-ignore support. +- [x] 2.5 Re-run repository verification and strict change validation. diff --git a/openspec/specs/persistent-workbench-runs/spec.md b/openspec/specs/persistent-workbench-runs/spec.md index 53944ac6..49bcc18c 100644 --- a/openspec/specs/persistent-workbench-runs/spec.md +++ b/openspec/specs/persistent-workbench-runs/spec.md @@ -173,3 +173,35 @@ in the setting's own description. - **THEN** processes created before the cutoff, and their checkpoint sessions, are removed together +### Requirement: Checkpoint persistence excludes sensitive and generated state + +The workbench SHALL omit sensitive environment files, generated caches, and +local virtual environments from checkpoint capture and rollback data. + +#### Scenario: Capture a workspace containing generated local state + +- **WHEN** a checkpoint is captured for a workspace containing `.env`, a local + virtual environment, or a supported generated cache directory +- **THEN** those paths are not read into the checkpoint +- **AND** ordinary workspace files remain covered by rollback + +#### Scenario: Restore a historical checkpoint + +- **WHEN** a version-1 run journal contains paths that are excluded by the + current checkpoint policy +- **THEN** recovery removes those paths from the checkpoint snapshots and delta +- **AND** the sanitized journal is persisted without deleting workspace files + +#### Scenario: Capture a Git workspace with project-specific ignores + +- **WHEN** a checkpoint is captured in a Git workspace with root or nested + `.gitignore` rules +- **THEN** untracked paths ignored by Git are omitted from the checkpoint +- **AND** negated untracked paths and tracked files remain covered + +#### Scenario: Capture a workspace outside Git + +- **WHEN** Git cannot enumerate files for the workspace +- **THEN** checkpoint capture falls back to filesystem traversal +- **AND** mandatory sensitive and generated-state exclusions still apply + diff --git a/package-lock.json b/package-lock.json index 1b131497..99b631bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10778,7 +10778,7 @@ }, "packages/core": { "name": "@openspec-ui/core", - "version": "0.20.1", + "version": "0.20.2", "dependencies": { "cross-spawn": "^7.0.6", "simple-git": "^3.27.0" @@ -10790,7 +10790,7 @@ }, "packages/extension": { "name": "openspec-ui-vscode", - "version": "0.16.1", + "version": "0.16.2", "license": "MIT", "dependencies": { "@openspec-ui/core": "*", diff --git a/packages/core/package.json b/packages/core/package.json index 444463d7..03f1d382 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,7 +1,7 @@ { "name": "@openspec-ui/core", "private": true, - "version": "0.20.1", + "version": "0.20.2", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/core/src/checkpoint.test.ts b/packages/core/src/checkpoint.test.ts index ab77e760..93a10e7e 100644 --- a/packages/core/src/checkpoint.test.ts +++ b/packages/core/src/checkpoint.test.ts @@ -1,6 +1,7 @@ import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import simpleGit from "simple-git"; import { afterEach, describe, expect, it } from "vitest"; import { captureCheckpoint, @@ -101,6 +102,44 @@ describe("Workbench checkpoints", () => { ])); }); + it("honors nested Git ignore rules, negation, and tracked files", async () => { + const root = await temporaryRoot(); + await mkdir(path.join(root, "src"), { recursive: true }); + await writeFile(path.join(root, ".gitignore"), "*.cache\nsrc/generated/\n!keep.cache\n"); + await writeFile(path.join(root, "ignored.cache"), "ignored"); + await writeFile(path.join(root, "keep.cache"), "included"); + await writeFile(path.join(root, "tracked.cache"), "tracked"); + await writeFile(path.join(root, "src", ".gitignore"), "*.tmp\n!keep.tmp\n"); + await writeFile(path.join(root, "src", "ignored.tmp"), "ignored"); + await writeFile(path.join(root, "src", "keep.tmp"), "included"); + await mkdir(path.join(root, "src", "generated"), { recursive: true }); + await writeFile(path.join(root, "src", "generated", "output.bin"), "ignored"); + const git = simpleGit(root); + await git.init(); + await git.add([".gitignore", "src/.gitignore"]); + await git.raw(["add", "-f", "tracked.cache"]); + + const checkpoint = await captureCheckpoint(root); + + expect([...checkpoint.before.keys()]).toEqual([ + ".gitignore", + "keep.cache", + "src/.gitignore", + "src/keep.tmp", + "tracked.cache", + ]); + }); + + it("falls back to filesystem traversal outside a Git repository", async () => { + const root = await temporaryRoot(); + await writeFile(path.join(root, ".gitignore"), "ignored.txt\n"); + await writeFile(path.join(root, "ignored.txt"), "included without Git"); + + const checkpoint = await captureCheckpoint(root); + + expect([...checkpoint.before.keys()]).toEqual([".gitignore", "ignored.txt"]); + }); + it("round-trips persisted checkpoints and rejects unsafe paths", async () => { const root = await temporaryRoot(); await writeFile(path.join(root, "work.txt"), "before"); diff --git a/packages/core/src/checkpoint.ts b/packages/core/src/checkpoint.ts index 17e1dedb..a4621a10 100644 --- a/packages/core/src/checkpoint.ts +++ b/packages/core/src/checkpoint.ts @@ -1,6 +1,7 @@ import { createHash, randomUUID } from "node:crypto"; -import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { lstat, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; import path from "node:path"; +import simpleGit from "simple-git"; const DEFAULT_EXCLUDED_DIRECTORIES = new Set([ ".git", @@ -105,11 +106,56 @@ interface WorkspaceScan { skippedFiles: string[]; } +async function gitCheckpointPaths(root: string): Promise { + try { + const output = await simpleGit(root).raw([ + "ls-files", + "--cached", + "--others", + "--exclude-standard", + "-z", + ]); + return output.split("\0").filter(Boolean).sort(); + } catch { + return undefined; + } +} + +function isMissingFile(error: unknown): boolean { + return error instanceof Error && "code" in error && error.code === "ENOENT"; +} + async function scanWorkspace(root: string, limits: Required): Promise { const files = new Map(); const skippedFiles: string[] = []; let totalBytes = 0; + async function captureFile(relativePath: string): Promise { + assertSafeRelativePath(relativePath); + if (isExcludedCheckpointPath(relativePath)) { + if (isExcludedCheckpointFile(relativePath)) skippedFiles.push(relativePath); + return; + } + const absolutePath = path.join(root, relativePath); + try { + const fileStats = await lstat(absolutePath); + if (!fileStats.isFile() || fileStats.isSymbolicLink()) return; + } catch (error) { + if (isMissingFile(error)) return; + throw error; + } + const content = await readFile(absolutePath); + if (content.byteLength > limits.maxFileBytes) { + skippedFiles.push(relativePath); + return; + } + totalBytes += content.byteLength; + if (files.size >= limits.maxFiles || totalBytes > limits.maxBytes) { + throw new Error("Workbench checkpoint exceeds configured size limits"); + } + files.set(relativePath, { hash: hash(content), content }); + } + async function visit(directory: string): Promise { const entries = await readdir(directory, { withFileTypes: true }); for (const entry of entries) { @@ -121,24 +167,16 @@ async function scanWorkspace(root: string, limits: Required): } if (!entry.isFile()) continue; const relativePath = path.relative(root, absolutePath); - if (DEFAULT_EXCLUDED_FILES.has(entry.name)) { - skippedFiles.push(relativePath); - continue; - } - const content = await readFile(absolutePath); - if (content.byteLength > limits.maxFileBytes) { - skippedFiles.push(relativePath); - continue; - } - totalBytes += content.byteLength; - if (files.size >= limits.maxFiles || totalBytes > limits.maxBytes) { - throw new Error("Workbench checkpoint exceeds configured size limits"); - } - files.set(relativePath, { hash: hash(content), content }); + await captureFile(relativePath); } } - await visit(root); + const gitPaths = await gitCheckpointPaths(root); + if (gitPaths) { + for (const relativePath of gitPaths) await captureFile(relativePath); + } else { + await visit(root); + } return { files, skippedFiles }; } diff --git a/packages/extension/CHANGELOG.md b/packages/extension/CHANGELOG.md index dbc3eb60..07a3c020 100644 --- a/packages/extension/CHANGELOG.md +++ b/packages/extension/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 0.16.2 + +- Fixed: mutating operations such as archive no longer fail checkpoint limits + because of ignored project output. Checkpoint capture now honors root and + nested `.gitignore` rules, `.git/info/exclude`, and global Git excludes while + retaining tracked and negated files. Mandatory `.env`, virtual-environment, + and generated-cache exclusions remain active, and historical journals are + sanitized on workspace activation without deleting project files. + ## 0.16.0 - Templates tree and the standalone Templates tab now group templates diff --git a/packages/extension/package.json b/packages/extension/package.json index a9450190..1c0c19d5 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -3,7 +3,7 @@ "displayName": "OpenSpec Workbench", "description": "A dashboard + VS Code extension for OpenSpec, with Claude, Copilot, Codex, and Gemini agents built in.", "publisher": "openspec-ui", - "version": "0.16.1", + "version": "0.16.2", "icon": "media/icon.png", "license": "MIT", "repository": {