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: 4 additions & 2 deletions src/assets/scripts/hooks/background-memory-update
Original file line number Diff line number Diff line change
Expand Up @@ -399,9 +399,11 @@ if cd "$CWD" 2>/dev/null && git rev-parse --git-dir >/dev/null 2>&1; then
if [ -z "$BRANCH" ] && [ -n "$HEAD_SHA" ]; then
BRANCH="(detached)"
fi
GIT_STATUS=$(git status --short 2>/dev/null | head -20)
# D-NO-FSMONITOR (pre-compact-memory): the index reads turn off the
# repository's `core.fsmonitor` command, which git would otherwise run.
GIT_STATUS=$(git -c core.fsmonitor=false status --short 2>/dev/null | head -20)
GIT_LOG=$(git log --oneline -5 2>/dev/null || echo "")
GIT_DIFF=$(git diff --stat HEAD 2>/dev/null | tail -10)
GIT_DIFF=$(git -c core.fsmonitor=false diff --stat HEAD 2>/dev/null | tail -10)
GIT_STATE="Branch: ${BRANCH}
HEAD: ${HEAD_SHA}
Recent commits:
Expand Down
7 changes: 6 additions & 1 deletion src/assets/scripts/hooks/json-helper.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -199,11 +199,16 @@ function isCollisionScanExcluded(relPath) {
* project root is not a git working tree or the `git` binary is unavailable;
* callers fall back to `listFsWalkFiles`.
*
* D-NO-FSMONITOR: `ls-files` reads the index, and reading the index runs the
* command a repository's config names in `core.fsmonitor` — code chosen by the
* repository this hook runs inside. The call turns it off for itself
* (`-c core.fsmonitor=false`), so the listing stays a pure read.
*
* @param {string} projectRoot
* @returns {string[]} project-relative paths
*/
function listGitTrackedFiles(projectRoot) {
const out = execFileSync('git', ['ls-files', '-z'], {
const out = execFileSync('git', ['-c', 'core.fsmonitor=false', 'ls-files', '-z'], {
cwd: projectRoot,
stdio: ['ignore', 'pipe', 'ignore'],
});
Expand Down
9 changes: 7 additions & 2 deletions src/assets/scripts/hooks/pre-compact-memory
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,14 @@ if cd "$CWD" 2>/dev/null && git rev-parse --git-dir >/dev/null 2>&1; then
if [ -z "$GIT_BRANCH" ] && is_hex_sha "$GIT_HEAD_SHA" 40 40; then
GIT_BRANCH="(detached)"
fi
GIT_STATUS=$(git status --porcelain 2>/dev/null | head -30 || echo "")
# D-NO-FSMONITOR: `status` and `diff` read the index, and reading the index
# runs the command the repository's config names in `core.fsmonitor` — code
# chosen by the repository this hook runs inside. Each index read turns it
# off for itself (`-c core.fsmonitor=false`); rev-parse, branch and log never
# read the index.
GIT_STATUS=$(git -c core.fsmonitor=false status --porcelain 2>/dev/null | head -30 || echo "")
GIT_LOG=$(git log --oneline -10 2>/dev/null || echo "")
GIT_DIFF_STAT=$(git diff --stat HEAD 2>/dev/null || echo "")
GIT_DIFF_STAT=$(git -c core.fsmonitor=false diff --stat HEAD 2>/dev/null || echo "")
dbg "GIT_BRANCH=$GIT_BRANCH HEAD=$GIT_HEAD_SHA"
fi

Expand Down
4 changes: 2 additions & 2 deletions src/assets/scripts/resolve-settings.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -671,8 +671,8 @@ function gitToplevel(exec, dir) {
* which reads the index without refreshing or writing it. Reading the index
* runs a configured `core.fsmonitor` hook — an arbitrary command from the
* repository's config, and on macOS the builtin daemon's start-up — so the
* call turns it off for itself (`-c core.fsmonitor=false`): this resolver runs
* from session hooks and must stay a pure read.
* call turns it off for itself (`-c core.fsmonitor=false`, D-NO-FSMONITOR):
* this resolver runs from session hooks and must stay a pure read.
* tracked exit 0
* untracked any other answered exit (1: no such index entry; outside a
* repository git answers 128, and there is nothing to track)
Expand Down
2 changes: 1 addition & 1 deletion src/assets/scripts/verify-evidence.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -517,7 +517,7 @@ function gh(io, args, maxBuffer) {

/**
* Every git call: `-c core.fsmonitor=false` first, so no repository-configured
* hook runs, and bounded by the deadline.
* hook runs (D-NO-FSMONITOR), and bounded by the deadline.
*
* @param {Io} io
* @param {readonly string[]} args
Expand Down
77 changes: 70 additions & 7 deletions src/hud/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,72 @@ import type { GitStatus } from './types.js';
const GIT_TIMEOUT = 1000; // 1s per command
const GIT_MAXBUFFER = 16 * 1024 * 1024; // 16 MiB — covers >500k refs at ~30 B/ref

function shellExec(cmd: string, args: string[], cwd: string): Promise<string> {
/**
* How a call's stdout is cleaned. `both` trims the whole output — right for a
* single value (a ref, a count, a config answer). `trailing` keeps leading
* whitespace, for `status --porcelain`, whose first column is data: an unstaged
* edit prints ` M path`, and a full trim would shift `M` into the index column.
*/
type StdoutTrim = 'both' | 'trailing';

function shellExec(cmd: string, args: string[], cwd: string, trim: StdoutTrim = 'both'): Promise<string> {
return new Promise((resolve) => {
execFile(cmd, args, { cwd, timeout: GIT_TIMEOUT, maxBuffer: GIT_MAXBUFFER }, (err, stdout) => {
resolve(err ? '' : stdout.trim());
if (err) return resolve('');
resolve(trim === 'trailing' ? stdout.trimEnd() : stdout.trim());
});
});
}

/**
* The override that stops git running a repository's `core.fsmonitor` command.
*
* D-NO-FSMONITOR: git runs the command a repository's config names in
* `core.fsmonitor` whenever it reads the index — code chosen by whatever
* repository the status line is drawn in, on every prompt. Every HUD git call
* carries this override (`gitExec`), except the index reads' carve-out below.
*/
const FSMONITOR_OFF: readonly string[] = ['-c', 'core.fsmonitor=false'];

/** `core.fsmonitor` as git itself reads it, without touching the index. */
const FSMONITOR_CONFIG_READ: readonly string[] = ['config', '--type=bool', '--get', 'core.fsmonitor'];

/**
* Every HUD git call except the index reads: the override first, so no call
* that never needs fsmonitor can run a repository's hook.
*/
function gitExec(args: string[], cwd: string): Promise<string> {
return shellExec('git', args, cwd);
return shellExec('git', ['-c', 'core.fsmonitor=false', ...args], cwd);
}

/**
* D-NO-FSMONITOR carve-out: the override for an index read, given the
* trimmed stdout of `git config --type=bool --get core.fsmonitor`.
*
* `true` is the built-in fsmonitor daemon — git's own code, not a command from
* the repository — and it is what keeps `status` fast in huge repositories,
* where the HUD's 1s timeout would otherwise expire and draw a clean tree. Only
* that exact answer drops the override. A hook path (which `--type=bool`
* refuses), any other value, an unset key and a failed read all come back as
* something else, and keep it: the carve-out fails closed.
*
* @param fsmonitorConfig - the config read's trimmed stdout; '' when it failed
*/
export function fsmonitorOverride(fsmonitorConfig: string): readonly string[] {
return fsmonitorConfig === 'true' ? [] : FSMONITOR_OFF;
}

/**
* The HUD's index reads (`status`, `diff`): the override unless this refresh's
* `core.fsmonitor` read named the built-in daemon (`fsmonitorOverride`).
*/
function gitIndexRead(
args: string[],
cwd: string,
fsmonitorConfig: string,
trim: StdoutTrim = 'both',
): Promise<string> {
return shellExec('git', [...fsmonitorOverride(fsmonitorConfig), ...args], cwd, trim);
}

/**
Expand All @@ -26,18 +82,25 @@ export async function gatherGitStatus(cwd: string): Promise<GitStatus | null> {
const topLevel = await gitExec(['rev-parse', '--show-toplevel'], cwd);
if (!topLevel) return null;

// Branch name — 'HEAD' means detached HEAD state
const branch = await gitExec(['rev-parse', '--abbrev-ref', 'HEAD'], cwd);
// Branch name — 'HEAD' means detached HEAD state. core.fsmonitor is read once
// per refresh, never cached (the setting can change between prompts), and
// without the override, which would answer for it.
const [branch, fsmonitorConfig] = await Promise.all([
gitExec(['rev-parse', '--abbrev-ref', 'HEAD'], cwd),
shellExec('git', [...FSMONITOR_CONFIG_READ], cwd),
]);
if (!branch) return null;

// Dirty check — porcelain v1: two-char XY status prefix per path.
// --no-optional-locks is a git-level option and MUST precede the subcommand:
// `git status --no-optional-locks` is rejected as an unknown option, which makes
// shellExec return '' and silently reports every tree as clean. Keeping the flag
// (in the right position) stops the HUD from writing .git/index on every prompt.
const statusOutput = await gitExec(
const statusOutput = await gitIndexRead(
['--no-optional-locks', 'status', '--porcelain'],
cwd,
fsmonitorConfig,
'trailing',
);
let dirty = false;
let staged = false;
Expand Down Expand Up @@ -80,7 +143,7 @@ export async function gatherGitStatus(cwd: string): Promise<GitStatus | null> {
// NOTE: diff includes the working tree; ahead/behind counts commits only. This asymmetry is
// deliberate — both reference the same merge base but differ in working-tree inclusion.
if (mergeBase) {
const diffStat = await gitExec(['diff', '--shortstat', mergeBase], cwd);
const diffStat = await gitIndexRead(['diff', '--shortstat', mergeBase], cwd, fsmonitorConfig);
const filesMatch = diffStat.match(/(\d+)\s+file/);
const addMatch = diffStat.match(/(\d+)\s+insertion/);
const delMatch = diffStat.match(/(\d+)\s+deletion/);
Expand Down
41 changes: 40 additions & 1 deletion tests/decisions/ledger-ops.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { createRequire } from 'module';
import { execSync } from 'child_process';
import { execFileSync, execSync, spawnSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
Expand Down Expand Up @@ -1650,6 +1650,45 @@ describe('E4: pre-mint collision guard', () => {
expect(after.equals(before)).toBe(true);
});

it('lists tracked files without running the repository\'s core.fsmonitor hook (D-NO-FSMONITOR)', () => {
writeLedger(tmpDir, [makeLedgerRow({ anchor_id: 'ADR-001' })]);
// A gitignored file citing the same id: the fs-walk fallback would report it,
// `git ls-files` never does — so its absence proves the git listing answered.
fs.writeFileSync(path.join(tmpDir, '.gitignore'), 'ignored/\n');
fs.mkdirSync(path.join(tmpDir, 'ignored'), { recursive: true });
fs.writeFileSync(path.join(tmpDir, 'ignored', 'scratch.md'), 'ADR-002 scribble.\n');
initGitRepoWithFile(tmpDir, 'docs/design.md', 'See ADR-002 for the rationale.\n');
writeLog(tmpDir, [makeObsRow({ id: 'obs_fsmonitor', type: 'decision', status: 'ready' })]);

const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'aa-fsmonitor-hook-'));
const home = path.join(outside, 'home');
fs.mkdirSync(home);
const marker = path.join(outside, 'fsmonitor-ran');
const hook = path.join(outside, 'fsmonitor-hook.sh');
fs.writeFileSync(hook, `#!/bin/sh\necho ran >> '${marker}'\nexit 1\n`);
fs.chmodSync(hook, 0o755);
const env = { ...COLLISION_GIT_ENV, HOME: home };
execFileSync('git', ['config', 'core.fsmonitor', hook], { cwd: tmpDir, env });

try {
const run = spawnSync('node', [JSON_HELPER_BIN, 'assign-anchor', 'decision', 'obs_fsmonitor'], {
cwd: tmpDir,
env,
encoding: 'utf8',
});
expect(run.status).not.toBe(0);
expect(run.stderr).toContain('docs/design.md:1');
expect(run.stderr).not.toContain('scratch.md');
expect(fs.existsSync(marker), 'assign-anchor ran the fsmonitor hook').toBe(false);

// Known-bad probe: the same index read without the override runs the hook.
execFileSync('git', ['ls-files', '-z'], { cwd: tmpDir, env, stdio: 'ignore' });
expect(fs.existsSync(marker)).toBe(true);
} finally {
fs.rmSync(outside, { recursive: true, force: true });
}
});

it('refuses to mint when the candidate id is cited in a non-git project (fs-walk fallback)', () => {
writeLog(tmpDir, [makeObsRow({ id: 'obs_collide_nogit', type: 'decision', status: 'ready' })]);
fs.mkdirSync(path.join(tmpDir, 'docs'), { recursive: true });
Expand Down
2 changes: 1 addition & 1 deletion tests/guards/heredoc-quoting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ const SCANNED_EXTENSIONS: readonly string[] = ['.md', '.mds', '.sh', '.bash'];
*/
const KNOWN_UNQUOTED_HEREDOCS: readonly string[] = [
'src/assets/scripts/hooks/background-memory-update:313',
'src/assets/scripts/hooks/background-memory-update:422',
'src/assets/scripts/hooks/background-memory-update:424',
'src/assets/scripts/hooks/capture-question:157',
];

Expand Down
Loading
Loading