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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Attachment reads after checkpoint and Git GC recover from a stale mktree
process with one fresh-process retry. Broken-pipe and closed-input errors are
classified at the dependency protocol boundary; unrelated errors still fail.
This now uses published git-cas 6.5.11 and Plumbing 3.3.2; the temporary
source-checkout Plumbing patch is removed.

- Staged-file and committed-tree path guards now reject machine-local temporary
worktree paths as well as personal home paths. A real pre-commit regression
verifies that cleaning the working copy cannot conceal unsafe staged content.
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 @@ -163,8 +163,8 @@
"@flyingrobots/bijou-node": "^7.2.0",
"@flyingrobots/bijou-tui": "^7.2.0",
"@git-stunts/alfred": "^0.10.4",
"@git-stunts/git-cas": "^6.5.10",
"@git-stunts/plumbing": "^3.3.1",
"@git-stunts/git-cas": "^6.5.11",
"@git-stunts/plumbing": "^3.3.2",
"@git-stunts/trailer-codec": "^2.1.1",
"@noble/hashes": "^2.2.0",
"cbor-x": "^1.6.0",
Expand Down
4 changes: 2 additions & 2 deletions test/runtime/deno/deno.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"imports": {
"@git-stunts/alfred": "npm:@git-stunts/alfred@^0.10.3",
"@git-stunts/git-cas": "npm:@git-stunts/git-cas@^6.5.10",
"@git-stunts/plumbing": "npm:@git-stunts/plumbing@^3.3.1",
"@git-stunts/git-cas": "npm:@git-stunts/git-cas@^6.5.11",
"@git-stunts/plumbing": "npm:@git-stunts/plumbing@^3.3.2",
"@git-stunts/trailer-codec": "npm:@git-stunts/trailer-codec@^2.1.1",
"@noble/hashes/blake3.js": "npm:@noble/hashes@^2.2.0/blake3.js",
"cbor-x": "npm:cbor-x@^1.6.0",
Expand Down
104 changes: 104 additions & 0 deletions test/unit/infrastructure/adapters/mktree-session-recovery.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { describe, expect, it } from 'vitest';
import { GitPersistenceAdapter } from '@git-stunts/git-cas';
import { CommandSession, GitMktreeSession, GitPlumbingError, GitProtocolError } from '@git-stunts/plumbing';
import { Readable } from 'node:stream';
import { TextEncoder } from 'node:util';

const OID = 'a'.repeat(40);
const TREE = [`100644 blob ${'b'.repeat(40)}\tcontent`];

function command(failure) {
let finish;
const finished = new Promise((resolve) => { finish = resolve; });
function settle({ code, terminated }) {
finish({ code, error: null, signal: null, stderr: '', terminated, timedOut: false });
}
return new CommandSession({
stdoutStream: Readable.from([new TextEncoder().encode(`${OID}\n`)]),
finished,
write: async () => { if (failure !== null) { throw failure; } },
closeInput: async () => { settle({ code: 0, terminated: false }); },
terminate: () => { settle({ code: 1, terminated: true }); },
});
}

function fixture(failures) {
let openings = 0;
const persistence = new GitPersistenceAdapter({
plumbing: {
openMktreeSession: async () => {
const failure = failures[openings] ?? null;
openings += 1;
return new GitMktreeSession(command(failure));
},
},
policy: { execute: (operation) => operation() },
sessionIdleTimeoutMs: 60_000,
});
return { persistence, openings: () => openings };
}

function brokenPipe() {
return Object.assign(new Error('broken pipe'), { code: 'EPIPE' });
}

describe('mktree transport recovery', () => {
it.each(['one tree', 'tree batch'])('reopens the process once for a broken pipe writing %s', async (mode) => {
const { persistence, openings } = fixture([brokenPipe()]);
try {
const result = mode === 'one tree'
? await persistence.writeTree(TREE)
: await persistence.writeTrees([TREE]);
expect(result).toEqual(mode === 'one tree' ? OID : [OID]);
expect(openings()).toBe(2);
} finally {
await persistence.close();
}
});

it('also recovers when process completion wins the race with the next write', async () => {
const failure = new GitPlumbingError('input closed', 'write', { code: 'SESSION_INPUT_CLOSED' });
const { persistence, openings } = fixture([failure]);
try {
await expect(persistence.writeTree(TREE)).resolves.toBe(OID);
expect(openings()).toBe(2);
} finally {
await persistence.close();
}
});

it('reports a typed failure after two broken processes, without an unbounded retry', async () => {
const { persistence, openings } = fixture([brokenPipe(), brokenPipe()]);
try {
await expect(persistence.writeTree(TREE)).rejects.toBeInstanceOf(GitProtocolError);
expect(openings()).toBe(2);
} finally {
await persistence.close();
}
});

it('preserves other write failures and does not retry them', async () => {
const failure = Object.assign(new Error('permission denied'), { code: 'EACCES' });
const { persistence, openings } = fixture([failure]);
try {
await expect(persistence.writeTree(TREE)).rejects.toBe(failure);
expect(openings()).toBe(1);
} finally {
await persistence.close();
}
});

it('preserves a producer failure even when it has the same error code', async () => {
const failure = brokenPipe();
const session = new GitMktreeSession(command(null));
async function* entries() {
yield { mode: '100644', type: 'blob', oid: 'b'.repeat(40), name: 'content' };
throw failure;
}
try {
await expect(session.write(entries())).rejects.toBe(failure);
} finally {
await session.terminate();
}
});
});
8 changes: 6 additions & 2 deletions test/unit/scripts/dependency-hygiene.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,13 @@ import { z } from 'zod';

const REPO_ROOT = new URL('../../../', import.meta.url);

const PATCH_PACKAGE_FILES: readonly string[] = ['@git-stunts+trailer-codec+2.1.1.patch'];
const PATCH_PACKAGE_FILES: readonly string[] = [
'@git-stunts+trailer-codec+2.1.1.patch',
];

const PATCH_PACKAGE_README_HEADINGS: readonly string[] = ['### `@git-stunts/trailer-codec@2.1.1`'];
const PATCH_PACKAGE_README_HEADINGS: readonly string[] = [
'### `@git-stunts/trailer-codec@2.1.1`',
];

const PACKAGE_FILE_SCHEMA = z.object({
dependencies: z.record(z.string()),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,10 @@ describe('v18 v17 public-read legacy reading builder', () => {
'alice',
]);
// git-cas stamps its package version into the manifest formatVersion, so dependency
// bumps intentionally advance this migration-reading golden handle.
// bumps intentionally advance this migration-reading golden handle and manifestHash.
// Verified against published git-cas 6.5.11 with unchanged payload bytes.
expect(reading.facts.find((fact) => fact.factKey === 'node:alpha:_content')?.value)
.toBe('git-cas:1:asset:manifest-tree:cbor:sha1:09e785fbd98adf5c00f250598d3a92b0e3e75a33');
.toBe('git-cas:1:asset:manifest-tree:cbor:sha1:2b67e47826ccb797a7441019756abc3e697792d3');
});

it('fails closed when a restored v17 writer ref drifts after restore', async () => {
Expand Down
Loading