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
59 changes: 35 additions & 24 deletions .frame/STRUCTURE.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"version": "1.1",
"description": "Auto-generated module structure",
"lastUpdated": "2026-09-30",
"lastUpdated": "2026-10-01",
"architecture": {},
"modules": {
".claude/settings.json": {
Expand Down Expand Up @@ -17657,6 +17657,7 @@
"description": "STRUCTURE for a commit — the map built from Git's staged snapshot",
"exports": [
"publishStaged",
"blobId",
"repairPathspecIndex",
"reconcileTrackedMap",
"trackedReceiptPath",
Expand All @@ -17681,38 +17682,46 @@
],
"functions": {
"sha256": {
"line": 45,
"line": 46,
"params": [
"data"
]
},
"git": {
"line": 63,
"line": 64,
"params": [
"root",
"args",
"options = {}"
],
"purpose": "Run Git for this checkout and its effective index. `GIT_INDEX_FILE` from"
},
"blobId": {
"line": 90,
"params": [
"bytes",
"like"
],
"purpose": "Git's object id for a blob, computed here instead of piping the bytes"
},
"resolveIndex": {
"line": 78,
"line": 96,
"params": [
"root",
"env = process.env"
],
"purpose": "The index this commit uses: the hook's GIT_INDEX_FILE, else the checkout's."
},
"listEntries": {
"line": 88,
"line": 106,
"params": [
"root",
"env = process.env"
],
"purpose": "Staged entries of the effective index. Any unmerged stage makes the"
},
"readBlobs": {
"line": 106,
"line": 124,
"params": [
"root",
"ids",
Expand All @@ -17721,20 +17730,20 @@
"purpose": "Read blobs with one `cat-file --batch` process. Returns Map(id → Buffer)."
},
"enoent": {
"line": 127,
"line": 145,
"params": [
"p"
]
},
"fakeStat": {
"line": 133,
"line": 151,
"params": [
"kind",
"size"
]
},
"createIndexFs": {
"line": 150,
"line": 168,
"params": [
"root",
"entries",
Expand All @@ -17743,41 +17752,41 @@
"purpose": "A read-only fs over the staged tree, enough for STR-01 discovery and"
},
"buildStaged": {
"line": 239,
"line": 257,
"params": [
"root",
"options = {}"
],
"purpose": "Build the commit's map from the effective index."
},
"indexIdentity": {
"line": 329,
"line": 347,
"params": [
"indexFile"
]
},
"receiptPath": {
"line": 337,
"line": 355,
"params": [
"root"
]
},
"writeReceipt": {
"line": 341,
"line": 359,
"params": [
"root",
"value"
]
},
"writeJson": {
"line": 345,
"line": 363,
"params": [
"file",
"value"
]
},
"mirrorToDisk": {
"line": 362,
"line": 380,
"params": [
"root",
"mapPath",
Expand All @@ -17788,50 +17797,51 @@
"purpose": "Set the tracked map on disk to the commit's map (STR-02c D6), so a normal"
},
"repairPathspecIndex": {
"line": 395,
"line": 412,
"params": [
"root",
"options = {}"
],
"purpose": "A pathspec commit (`git commit -- <paths>`) runs the hook on a temporary"
},
"trackedReceiptPath": {
"line": 421,
"line": 437,
"params": [
"root"
]
},
"reconcileTrackedMap": {
"line": 441,
"line": 457,
"params": [
"root",
"options = {}"
],
"purpose": "Bring the tracked map on disk back to the committed view when that loses"
},
"reconcileTracked": {
"line": 453,
"line": 469,
"params": [
"root",
"env"
]
},
"publishStaged": {
"line": 522,
"line": 537,
"params": [
"root",
"options = {}"
],
"purpose": "Build the commit's map and publish it into the effective index."
}
},
"sizeBytes": 22558,
"sizeBytes": 23037,
"extraction": {
"status": "parsed"
},
"provenance": {
"description": "e2d9198bdb4c6a16",
"purposes": {
"blobId": "300104a3c1e3211a",
"buildStaged": "adaf4c93fc0707d5",
"createIndexFs": "f35ade5b3634de6c",
"git": "98c4595cc6e8df9b",
Expand Down Expand Up @@ -23570,7 +23580,8 @@
"path",
"child_process",
"scripts/structure-commit",
"scripts/structure-lifecycle"
"scripts/structure-lifecycle",
"crypto"
],
"functions": {
"repo": {
Expand Down Expand Up @@ -23600,7 +23611,7 @@
]
}
},
"sizeBytes": 16049,
"sizeBytes": 17362,
"extraction": {
"status": "parsed"
},
Expand Down Expand Up @@ -25959,6 +25970,6 @@
"truncated": false,
"samples": []
},
"revision": "859fa6c387a5b70589dad88c312990b84f705db6989cb690fb37138df146c433"
"revision": "1a4e0e0f7a37dbcee76b8c468106e982c6cbe8b610c1a113e53af64b6b7309d9"
}
}
32 changes: 24 additions & 8 deletions scripts/structure-commit.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const snapshot = require('./structure-snapshot');
const MODE_FILE = new Set(['100644', '100755']);
const MODE_SYMLINK = '120000';
const MODE_GITLINK = '160000';
const GIT_TIMEOUT_MS = 60000;

function sha256(data) {
return crypto.createHash('sha256').update(data).digest('hex');
Expand All @@ -65,15 +66,32 @@ function git(root, args, options = {}) {
cwd: root,
env: options.env || process.env,
input: options.input,
maxBuffer: 1024 * 1024 * 1024
maxBuffer: 1024 * 1024 * 1024,
// a commit hook must never hang the commit: a stuck Git call fails this run
timeout: options.timeout || GIT_TIMEOUT_MS,
killSignal: 'SIGKILL'
});
if (result.error) throw new CommitUnavailable('git-unavailable', result.error.message);
if (result.error) {
const reason = result.error.code === 'ETIMEDOUT' ? 'git-timeout' : 'git-unavailable';
throw new CommitUnavailable(reason, result.error.message);
}
if (result.status !== 0 && !options.allowFailure) {
throw new CommitUnavailable(options.failureReason || 'git-failed', String(result.stderr || '').trim().split('\n')[0]);
}
return result;
}

/**
* Git's object id for a blob, computed here instead of piping the bytes
* through `git hash-object --stdin` (no filters apply to either). The hash
* function follows the id it is compared with: 40 hex digits SHA-1, 64
* SHA-256.
*/
function blobId(bytes, like) {
const algorithm = typeof like === 'string' && like.length === 64 ? 'sha256' : 'sha1';
return crypto.createHash(algorithm).update(`blob ${bytes.length}\0`).update(bytes).digest('hex');
}

/** The index this commit uses: the hook's GIT_INDEX_FILE, else the checkout's. */
function resolveIndex(root, env = process.env) {
if (env.GIT_INDEX_FILE) return path.resolve(root, env.GIT_INDEX_FILE);
Expand Down Expand Up @@ -371,8 +389,7 @@ function mirrorToDisk(root, mapPath, candidate, stagedId, env) {
if (current && current.equals(bytes)) return 'unchanged';
try {
if (current) {
const diskId = git(root, ['hash-object', '--stdin'], { env, input: current }).stdout.toString().trim();
if (diskId !== stagedId) return 'kept';
if (blobId(current, stagedId) !== stagedId) return 'kept';
}
fs.mkdirSync(path.dirname(file), { recursive: true });
const tmp = `${file}.tmp-${process.pid}`;
Expand Down Expand Up @@ -409,8 +426,7 @@ function repairPathspecIndex(root, options = {}) {
} catch (e) {
return 'skipped';
}
const diskId = git(root, ['hash-object', '--stdin'], { env, input: disk }).stdout.toString().trim();
if (diskId !== headId) return 'clean';
if (blobId(disk, headId) !== headId) return 'clean';
const update = git(root, ['update-index', '--cacheinfo', `${match[1]},${headId},${rel}`], { env, allowFailure: true });
return update.status === 0 ? 'repaired' : 'failed';
} catch (e) {
Expand Down Expand Up @@ -465,8 +481,7 @@ function reconcileTracked(root, env) {
return { status: 'skipped', reason: 'missing' };
}
const diskDigest = sha256(disk);
const diskId = git(root, ['hash-object', '--stdin'], { env, input: disk }).stdout.toString().trim();
if (diskId === match[2]) return { status: 'clean', diskDigest };
if (blobId(disk, match[2]) === match[2]) return { status: 'clean', diskDigest };

const repair = repairPathspecIndex(root, { env });
if (repair === 'repaired') return { status: 'repaired', diskDigest };
Expand Down Expand Up @@ -572,6 +587,7 @@ function publishStaged(root, options = {}) {

module.exports = {
publishStaged,
blobId,
repairPathspecIndex,
reconcileTrackedMap,
trackedReceiptPath,
Expand Down
23 changes: 23 additions & 0 deletions test/structureCommit.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -348,3 +348,26 @@ test('repairPathspecIndex skips a repository whose HEAD has no map', (t) => {
const { dir } = committedRepo(t);
assert.equal(commit.repairPathspecIndex(dir), 'skipped');
});

/* ------------- a commit hook never hangs, never pipes the map through Git ------------- */

test('blobId matches git hash-object for SHA-1 ids and follows a SHA-256 id', (t) => {
const { dir } = repo(t);
for (const bytes of [Buffer.from(''), Buffer.from('{"a":1}\n'), Buffer.from('ç\u0000x'.repeat(5000))]) {
const expected = spawnSync('git', ['hash-object', '--stdin'], { cwd: dir, input: bytes, encoding: 'utf8' }).stdout.trim();
assert.equal(commit.blobId(bytes, expected), expected);
}
const sha256 = commit.blobId(Buffer.from('x'), 'f'.repeat(64));
assert.equal(sha256.length, 64);
assert.equal(sha256, require('crypto').createHash('sha256').update('blob 1\0x').digest('hex'));
});

test('a Git call that does not return in time fails the run instead of hanging the commit', (t) => {
const bin = fs.mkdtempSync(path.join(os.tmpdir(), 'frame-slow-git-'));
t.after(() => fs.rmSync(bin, { recursive: true, force: true }));
fs.writeFileSync(path.join(bin, 'git'), '#!/bin/sh\nsleep 5\n', { mode: 0o755 });
const env = { ...process.env, PATH: `${bin}${path.delimiter}${process.env.PATH}` };
const started = Date.now();
assert.throws(() => commit.git(bin, ['status'], { env, timeout: 200 }), (err) => err.reason === 'git-timeout');
assert.ok(Date.now() - started < 3000);
});
Loading