Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
3 changes: 3 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,9 @@ import { GitStorage } from '@git-stunts/git-warp/storage';
const storage = await GitStorage.open({ cwd: '.' });
const warp = await openWarp({ storage, writer: 'agent-1' });
const team = await warp.timeline('team');

// After the final lane operation:
await storage.close();
```

Application code writes with `timeline.write(intent)` and reads with
Expand Down
11 changes: 9 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added `Timeline.draft(name)`, draft writes, `Timeline.previewJoin(draft)`,
and `Timeline.join(draft)` with join receipts for first-use speculative
workflows.
- Added idempotent `GitStorage.close()` and async-disposal support to release
local Git and git-cas processes without changing history or retention.
- Added the `lint:test-law` gate to reject conditional bare `return;`
statements in test bodies so skipped assertions cannot masquerade as passing
tests.
Expand Down Expand Up @@ -50,8 +52,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Raised the coverage ratchet from `92.10%` to `92.62%` after adding targeted
coverage for bounded query node paging and memory-budget rejection paths and
removing retired compatibility surfaces.
- Upgraded `@git-stunts/git-cas` to `^6.5.0` so Git-backed materializations can
use managed `CacheSet` retention and opaque page and bundle handles.
- Upgraded `@git-stunts/git-cas` to `^6.5.3` so Git-backed materializations can
use managed `CacheSet` retention, opaque page and bundle handles, and
persistent Git object sessions.
- Moved shadow-trie leaf and branch storage from unretained raw Git blobs and
trees to bounded git-cas pages and composable bundle handles. Production
storage now shares one git-cas facade with the trie adapter, and the storage
Expand All @@ -72,6 +75,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Reused repository-scoped Git object sessions across immutable history and
CAS operations, then closed history, materialization, and CAS owners at
application, CLI, migration, and test boundaries. This removes repeated
`cat-file` and `mktree` process startup without leaking subprocesses.
- Live exact node-property reads now retain collision-safe property shards as
an independently addressed materialization root. Each node uses its full
BLAKE3 route key and a deterministic schema-v2 entry-bag envelope; writes and
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,13 @@ if (role.receipt.outcome === 'accepted') {
} else {
console.error(role.receipt.reason, role.receipt.repairHints);
}

await storage.close();
```

`GitStorage.close()` releases local Git and git-cas processes only. It does not
delete timelines, rewrite history, or change retention anchors.

The v18 graph-first API is not exported in v19. Migrate uses of
`openWarpWorldline()`, `openWarpGraph()`, `WarpApp`, `WarpCore`,
`GitGraphAdapter`, patch builders, and graph operation creators before
Expand Down
50 changes: 39 additions & 11 deletions bin/cli/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,23 +30,51 @@ export type CliStorageBinding = {
readonly hookPaths: HookPathPort;
};

const activeCliStorages = new Set<GitStorage>();

/** Releases every storage composition opened by the current CLI invocation. */
export async function closeCliStorages(): Promise<void> {
const storages = [...activeCliStorages];
activeCliStorages.clear();
const results = await Promise.allSettled(storages.map(async (storage) => await storage.close()));
const failures = results
.filter((result): result is PromiseRejectedResult => result.status === 'rejected')
.map((result) => result.reason as unknown);
if (failures.length > 0) {
throw new AggregateError(failures, 'CLI storage failed to close cleanly');
}
}

/**
* Creates a persistence adapter for the given repository path.
*/
export async function createPersistence(repoPath: string): Promise<CliStorageBinding> {
const storage = await GitStorage.open({ cwd: repoPath });
const binding = resolveWarpStorage(storage);
if (!(binding.history instanceof GitTimelineHistoryAdapter)
|| binding.createTrustChain === undefined
|| binding.hookPaths === undefined) {
throw usageError('GitStorage returned an incomplete CLI storage binding');
try {
const binding = resolveWarpStorage(storage);
if (!(binding.history instanceof GitTimelineHistoryAdapter)
|| binding.createTrustChain === undefined
|| binding.hookPaths === undefined) {
throw usageError('GitStorage returned an incomplete CLI storage binding');
}
activeCliStorages.add(storage);
return {
persistence: binding.history,
runtimeStorage: binding.runtimeStorage,
createTrustChain: binding.createTrustChain,
hookPaths: binding.hookPaths,
};
} catch (error) {
try {
await storage.close();
} catch (closeError) {
throw new AggregateError(
[error, closeError],
'CLI storage binding failed and local resources did not close cleanly',
);
}
throw error;
}
return {
persistence: binding.history,
runtimeStorage: binding.runtimeStorage,
createTrustChain: binding.createTrustChain,
hookPaths: binding.hookPaths,
};
}

/**
Expand Down
18 changes: 16 additions & 2 deletions bin/warp-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { installDefaultRuntimeHostNodePorts } from '../src/application/RuntimeHo
import { EXIT_CODES, HELP_TEXT, CliError, parseArgs, usageError } from './cli/infrastructure.ts';
import { stableStringify, compactStringify } from './presenters/json.ts';
import { COMMANDS } from './cli/commands/registry.ts';
import { closeCliStorages } from './cli/shared.ts';

installDefaultRuntimeHostNodePorts();

Expand Down Expand Up @@ -76,7 +77,13 @@ function installShutdownHandlers(close: () => Promise<void>): void {
const shutdown = async (): Promise<void> => {
if (closing) { return; }
closing = true;
await close();
const results = await Promise.allSettled([close(), closeCliStorages()]);
const failures = results
.filter((result): result is PromiseRejectedResult => result.status === 'rejected')
.map((result) => result.reason as unknown);
if (failures.length > 0) {
throw new AggregateError(failures, 'CLI shutdown failed');
}
process.exit(EXIT_CODES.OK);
};
process.on('SIGINT', () => { shutdown().catch(() => process.exit(1)); });
Expand Down Expand Up @@ -121,10 +128,17 @@ async function main(): Promise<void> {
return; // Keep the process alive
}

await closeCliStorages();
process.exit(normalized.exitCode);
}

main().catch((error: unknown) => {
main().catch(async (caught: unknown) => {
let error = caught;
try {
await closeCliStorages();
} catch (closeError) {
error = new AggregateError([caught, closeError], 'CLI command and storage cleanup failed');
}
const exitCode = error instanceof CliError ? error.exitCode : EXIT_CODES.INTERNAL;
const code = error instanceof CliError ? error.code : 'E_INTERNAL';
const message = error instanceof Error ? error.message : 'Unknown error';
Expand Down
4 changes: 3 additions & 1 deletion docs/TECHNICAL_TEARDOWN.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,9 @@ flowchart TB
```

The root import has no storage bootstrap side effect. `GitStorage.open()` owns
the Node Git and git-cas composition used by first-use applications.
the Node Git and git-cas composition used by first-use applications;
`GitStorage.close()` releases that composition's local processes without
changing history or retention.

## System Architecture

Expand Down
3 changes: 3 additions & 0 deletions docs/migrations/v19/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ const warp = await openWarp({
});

const events = await warp.timeline('events');

// After the final lane operation:
await storage.close();
```

The old names are accurate implementation names, but they leak substrate
Expand Down
8 changes: 7 additions & 1 deletion docs/topics/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,13 @@ methods so there is no boolean dry-run mode.

`GitStorage` is one opaque repository-scoped handle. It composes timeline
history and git-cas services internally; application code does not construct
plumbing, CAS, cache, or retention adapters.
plumbing, CAS, cache, or retention adapters. Close it when the application is
finished to release local Git and git-cas processes. Closing storage does not
delete timelines, rewrite history, or change retention anchors.

```typescript
await storage.close();
```

WARP history lives under `refs/warp/**`, separate from source branches such as
`refs/heads/main`. Writing a timeline does not create a source-tree commit on
Expand Down
46 changes: 44 additions & 2 deletions docs/topics/git-perf.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ only as a process-startup control; the retained harness invokes it once after
timed scenarios to record the exact Git build in result metadata. No production
recommendation invokes it.

The current git-cas adapter starts a fresh process for each blob write, tree
write, blob stream, tree lookup, and uncached object-info lookup:
Before git-cas v6.5.2, the adapter started a fresh process for each blob write,
tree write, blob stream, tree lookup, and uncached object-info lookup:

- `hash-object -w --stdin` is invoked per blob. [cite: `git-cas/src/infrastructure/adapters/GitPersistenceAdapter.js#57-65@49b7d5cb9d589d73fa17d393e48d40bd6f139e57`]
- `mktree` is invoked per tree. [cite: `git-cas/src/infrastructure/adapters/GitPersistenceAdapter.js#68-79@49b7d5cb9d589d73fa17d393e48d40bd6f139e57`]
Expand All @@ -49,6 +49,13 @@ On the measured Apple M1 Pro, one Git process had an approximately 18-20 ms
startup floor. The problem is therefore both process overhead and our use of
the process boundary: paying that floor once per object is self-inflicted.

git-cas v6.5.2 shipped bounded persistent object sessions, and v6.5.3 corrected
their visibility/retirement policy: `cat-file` survives successful immutable
writes, `mktree` survives loose writes, and `mktree` is retired after a bounded
`fast-import` pack write. The original process-per-object measurements below
remain the diagnosis and baseline, not a description of the current adapter.
[cite: `git-cas/src/infrastructure/adapters/GitPersistenceAdapter.js#93-175@7bdcbf1f9eccd16acd324c94d576e1ecd2e11d98`]

The 128-operation profile makes the distinction concrete:

| Packed workload | One process per operation | Persistent Git | NodeGit |
Expand All @@ -64,6 +71,41 @@ Persistent Git matches or beats libgit2 for object metadata and packed blob
reads. Parsing one bounded tree page and caching its immutable entry index beats
re-entering either Git implementation for every path lookup.

### Published v6.5.3 consumer checkpoint

git-warp consumed the published npm artifact `@git-stunts/git-cas@6.5.3` with
`@git-stunts/plumbing@3.2.0` and repeated the temporary 128-node cold retained
materialization diagnostic three times. The injected plumbing counter began
after repository initialization, so it observed 398 workload Git children per
run; adding the excluded `git init` and two `git config` setup commands yields
the same 401 total recorded for the release candidate.

Every run produced the same major command counts:

| Command | Processes |
| -------------- | --------: |
| `hash-object` | 140 |
| `rev-parse` | 87 |
| `symbolic-ref` | 43 |
| `update-ref` | 43 |
| `commit-tree` | 39 |
| `rev-list` | 37 |
| `cat-file` | 4 |
| `mktree` | 1 |

The three local cold-materialization timings were 7.02 s, 7.18 s, and 6.67 s.
They are diagnostics only: this rerun used Node 26, did not capture process-tree
CPU/RSS, and intentionally did not reproduce the old timing harness. The
structural process counts are the comparable result. Focused real-Git retained
materialization, materialization-store, and trie integration tests passed 11/11
against the registry artifact.

This checkpoint proves that npm v6.5.3 matches the audited session topology. It
does not satisfy the versioned cold/warm/incremental benchmark contract in
[#759](https://github.com/git-stunts/git-warp/issues/759): that issue still
requires a committed corpus, process-tree CPU/RSS, repeated base/head CI runs,
and blocking regression thresholds.

## Bounded page reads

The resource workload used random 4 KiB objects so compression could not hide
Expand Down
10 changes: 10 additions & 0 deletions docs/topics/querying.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,16 @@ executable `Optic`, and the type-only `Witness`. Capture a coordinate with
`captureCoordinate(timeline)`; ordinary application reads should continue to
use `reading.*`.

## Close Storage

After the final reading or diagnostic inspection, close the storage handle to
release its local Git and git-cas processes. This does not change admitted
history or retention anchors.

```typescript
await storage.close();
```

## Removed Compatibility Surface

The graph-first `openWarpWorldline()` and `openWarpGraph()` package exports are
Expand Down
4 changes: 2 additions & 2 deletions docs/topics/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ Git-backed storage for first-use applications.
Source: `storage.ts`. Count: 1.

```text
GitStorage @ storage.ts#L16
GitStorage @ storage.ts#L38
```

### Type exports
Expand Down Expand Up @@ -192,7 +192,7 @@ ReceiptSubstrateInspection @ diagnostics.ts#L15
Structured CLI errors for `--json` and `--ndjson` use the payload shape
`{ error: { code, message, cause? } }` from the CLI entry point.

Source: `bin/warp-graph.ts#L131`.
Source: `bin/warp-graph.ts#L145`.

## Public error classes

Expand Down
18 changes: 9 additions & 9 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,8 @@
},
"dependencies": {
"@git-stunts/alfred": "^0.10.4",
"@git-stunts/git-cas": "^6.5.0",
"@git-stunts/plumbing": "^3.1.0",
"@git-stunts/git-cas": "^6.5.3",
"@git-stunts/plumbing": "^3.2.0",
"@git-stunts/trailer-codec": "^2.1.1",
"@noble/hashes": "^2.2.0",
"boxen": "^7.1.1",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ export async function replayGraphModelMigrationScratchIntoRuntime(
runtimeRepositoryPath = await mkdtemp(join(tmpdir(), 'git-warp-v18-runtime-replay-'));
shouldCleanup = true;
}
let persistence: GitTimelineHistoryAdapter | null = null;
let runtimeStorage: GitCasRepositoryAdapter | null = null;
try {
const operations = await readGraphModelMigrationScratchOperationRecords({
repositoryPath: sourceRepositoryPath,
Expand All @@ -115,8 +117,8 @@ export async function replayGraphModelMigrationScratchIntoRuntime(
await plumbing.execute({ args: ['init', '-q'] });
await plumbing.execute({ args: ['config', 'user.email', 'git-warp@example.invalid'] });
await plumbing.execute({ args: ['config', 'user.name', 'git-warp migration replay'] });
const persistence = new GitTimelineHistoryAdapter({ plumbing });
const runtimeStorage = new GitCasRepositoryAdapter({ plumbing, history: persistence });
persistence = new GitTimelineHistoryAdapter({ plumbing });
runtimeStorage = new GitCasRepositoryAdapter({ plumbing, history: persistence });
const graph = await openRuntimeHostProduct({
persistence,
runtimeStorage,
Expand All @@ -133,8 +135,16 @@ export async function replayGraphModelMigrationScratchIntoRuntime(
state,
});
} finally {
if (shouldCleanup && runtimeRepositoryPath !== null) {
await rm(runtimeRepositoryPath, { recursive: true, force: true });
try {
await runtimeStorage?.close();
} finally {
try {
await persistence?.close();
} finally {
if (shouldCleanup && runtimeRepositoryPath !== null) {
await rm(runtimeRepositoryPath, { recursive: true, force: true });
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
}
}
Expand Down
Loading
Loading