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
17 changes: 15 additions & 2 deletions bin/aimhooman.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -356,10 +356,23 @@ function cmdPrecommit(args) {
const paths = [...new Set(blocks.map((f) => f.path).filter(Boolean))];
let emptied = false;
try {
emptied = repairStagedBlocks(repo, blocks, paths);
const repair = repairStagedBlocks(repo, blocks, paths);
emptied = repair.emptied;
const stopped = emptied
? (repair.collateral.length
? ' — the index is empty now, so the commit is stopped rather than left empty'
: ' — nothing else was staged, so the commit is stopped rather than left empty')
: '';
process.stderr.write(
`aimhooman: unstaged ${paths.length} file(s) from this commit: ${paths.map(visible).join(', ')} (index only; nothing on disk was deleted)${emptied ? ' — nothing else was staged, so the commit is stopped rather than left empty' : ''}\n`
`aimhooman: unstaged ${paths.length} file(s) from this commit: ${paths.map(visible).join(', ')} (index only; nothing on disk was deleted)${stopped}\n`
);
if (repair.collateral.length) {
process.stderr.write(
`aimhooman: also restored ${repair.collateral.length} staged deletion(s) that could be the source of a rename Git cannot detect: ` +
`${repair.collateral.map(visible).join(', ')} — stage the removal again with 'git rm --cached <path>', ` +
"or 'git rm <path>' if you deleted it on disk too\n"
);
}
} catch (e) {
process.stderr.write(
`aimhooman: could not unstage protected files: ${e.message} ` +
Expand Down
10 changes: 5 additions & 5 deletions rules/paths.json
Original file line number Diff line number Diff line change
Expand Up @@ -218,20 +218,20 @@
},
{
"id": "generic.agent-instructions",
"version": 2,
"version": 3,
"provider": "generic",
"category": "ambiguous-instructions",
"confidence": "medium",
"kind": "path",
"match": {
"paths": [
"AGENTS.md",
"[Aa][Gg][Ee][Nn][Tt][Ss].[Mm][Dd]",
"**/AGENTS.md",
"CLAUDE.md",
"[Cc][Ll][Aa][Uu][Dd][Ee].[Mm][Dd]",
"**/CLAUDE.md",
"GEMINI.md",
"[Gg][Ee][Mm][Ii][Nn][Ii].[Mm][Dd]",
"**/GEMINI.md",
".github/copilot-instructions.md",
".github/[Cc][Oo][Pp][Ii][Ll][Oo][Tt]-[Ii][Nn][Ss][Tt][Rr][Uu][Cc][Tt][Ii][Oo][Nn][Ss].[Mm][Dd]",
"**/.github/copilot-instructions.md"
]
},
Expand Down
11 changes: 9 additions & 2 deletions src/guard.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -161,8 +161,15 @@ export function repairStagedBlocks(repo, blocks, paths) {
if (!pending.length) break;
unstagePaths(repo, pending);
}
return stagedBefore !== null
&& stagedBefore.every((path) => unstageTargets.has(path));
// collateral is what the repair took beyond the blocked paths themselves:
// staged deletions kept as possible rename sources. The caller has to name
// them, or the developer commits a message describing a removal the commit
// no longer carries.
const blocked = new Set(paths);
return {
emptied: stagedBefore !== null && stagedBefore.every((path) => unstageTargets.has(path)),
collateral: [...unstageTargets].filter((path) => !blocked.has(path)),
};
}

// resolveIntroduced maps each proposed update to the commits it introduces,
Expand Down
41 changes: 37 additions & 4 deletions tests/cli.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -239,10 +239,13 @@ test('precommit: a zero-similarity blocked rename restores the possible source d
execFileSync('git', ['add', '-f', '.playwright-mcp/trace.json'], { cwd: dir });

const out = result('precommit', [], dir);
// Repairing the index leaves nothing staged, so the commit stops
// rather than land empty.
// Repairing the index leaves nothing staged, so the commit stops rather
// than land empty. The deletion it restored was staged work, so the
// report names it instead of claiming nothing else was there.
assert.equal(out.status, 10, out.stderr);
assert.match(out.stderr, /nothing else was staged/);
assert.match(out.stderr, /the index is empty now/);
assert.match(out.stderr, /also restored 1 staged deletion\(s\)/);
assert.match(out.stderr, /old\.txt/);
const staged = execFileSync('git', ['diff', '--cached', '--name-status'], {
cwd: dir,
encoding: 'utf8',
Expand Down Expand Up @@ -735,7 +738,37 @@ test('clean precommit fully unstages a blocked rename', () => {
encoding: 'utf8',
}).trim();
assert.equal(staged, '');
assert.match(out.stderr, /nothing else was staged/);
// `git mv` stages a deletion of the source, which the repair restores,
// so the report has to account for it rather than say nothing else was
// staged.
assert.match(out.stderr, /the index is empty now/);
assert.match(out.stderr, /README\.md/);
} finally { rmSync(dir, { recursive: true, force: true }); }
});

// The sweep that restores possible rename sources is deliberate, but it takes
// paths the developer staged on purpose. Saying nothing leaves them committing a
// message that describes an untracking the commit does not contain.
test('clean precommit names the staged deletion its repair restored', () => {
const dir = makeRepo('clean');
try {
writeFileSync(join(dir, 'legacy.txt'), 'tracked\n');
execFileSync('git', ['add', 'legacy.txt'], { cwd: dir });
execFileSync('git', ['commit', '--no-verify', '-q', '-m', 'add legacy'], { cwd: dir });
execFileSync('git', ['rm', '--cached', '-q', 'legacy.txt'], { cwd: dir });
mkdirSync(join(dir, '.claude'), { recursive: true });
writeFileSync(join(dir, '.claude/session.json'), '{}');
execFileSync('git', ['add', '-f', '.claude/session.json'], { cwd: dir });

const out = result('precommit', [], dir);
assert.equal(out.status, 10, out.stderr);
assert.match(out.stderr, /legacy\.txt/, 'the repair must name the deletion it restored');
assert.match(out.stderr, /git rm/, 'and say how to stage the removal again');
assert.doesNotMatch(
out.stderr,
/nothing else was staged/,
'something else was staged, and the repair took it',
);
} finally { rmSync(dir, { recursive: true, force: true }); }
});

Expand Down
5 changes: 3 additions & 2 deletions tests/guard.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -141,14 +141,15 @@ test('repairStagedBlocks unstages the blocked paths and reports an emptied index
writeFileSync(join(dir, 'keep.txt'), 'keep\n');
git(dir, ['add', '-f', '.claude.json', 'keep.txt']);
const partial = repairStagedBlocks(repo, [{ path: '.claude.json' }], ['.claude.json']);
assert.equal(partial, false, 'other work stays staged, so the commit may proceed');
assert.equal(partial.emptied, false, 'other work stays staged, so the commit may proceed');
assert.deepEqual(partial.collateral, [], 'nothing beyond the blocked path was taken');
assert.deepEqual(stagedPaths(repo), ['keep.txt']);
assert.ok(existsSync(join(dir, '.claude.json')), 'the worktree file survives');

git(dir, ['restore', '--staged', 'keep.txt']);
git(dir, ['add', '-f', '.claude.json']);
const emptied = repairStagedBlocks(repo, [{ path: '.claude.json' }], ['.claude.json']);
assert.equal(emptied, true, 'the repair removed everything that was staged');
assert.equal(emptied.emptied, true, 'the repair removed everything that was staged');
assert.deepEqual(stagedPaths(repo), []);
} finally {
rmSync(dir, { recursive: true, force: true });
Expand Down
17 changes: 11 additions & 6 deletions tests/rule-contracts.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ test('corner-cut labels match case-insensitively without broadening near misses'
}
});

test('session and policy paths match case-insensitively; instruction paths do not', () => {
test('session and policy paths match case-insensitively; instruction paths fold case at the root only', () => {
const engine = newEngine('strict');
// On APFS and NTFS these are the same file as their lowercase spellings, and
// Git records the case the caller typed rather than the case on disk.
Expand All @@ -231,16 +231,21 @@ test('session and policy paths match case-insensitively; instruction paths do no
['.Codex/sessions/rollout.jsonl', 'codex.session-state'],
['.Cursor/chats/feature.json', 'cursor.session-state'],
['.Aimhooman.json', 'generic.project-policy'],
// Same argument, same filesystems: a root instruction file is the
// guarded one whatever case the caller typed.
['agents.md', 'generic.agent-instructions'],
['claude.md', 'generic.agent-instructions'],
['Gemini.md', 'generic.agent-instructions'],
['AGENTS.MD', 'generic.agent-instructions'],
['.github/Copilot-Instructions.md', 'generic.agent-instructions'],
];
for (const [path, ruleId] of cases) {
assert.ok(matchFor(engine.checkPaths([path]), ruleId), path);
}

// The negative control for the opt-in: generic.agent-instructions stays
// case-sensitive, so folding case is still a per-rule decision rather than a
// global one. Its names lowercase to ordinary documentation filenames, which
// must keep committing freely.
for (const path of ['docs/claude.md', 'docs/gemini.md', 'website/content/blog/agents.md']) {
// The near-miss control: only the root names fold. Deeper in the tree these
// are ordinary documentation filenames and must keep committing freely.
for (const path of ['docs/claude.md', 'docs/gemini.md', 'website/content/blog/agents.md', 'agentsxmd']) {
assert.equal(engine.checkPaths([path]).length, 0, path);
}
});
Expand Down