Merge pull request #3557 from gsd-build/feat/3347-auto-update-knowledge-graph-after-main-h
feat(3347): opt-in auto-update of knowledge graph after main HEAD advances
This commit is contained in:
@@ -78,14 +78,16 @@ const INTERNAL_COMPONENT_SLUGS = new Set([
|
||||
'tools',
|
||||
|
||||
// Hook scripts — internal runtime hooks, not user-invocable slash commands.
|
||||
// hooks/gsd-statusline.js — session statusline hook
|
||||
// hooks/gsd-context-monitor.js — context-window monitor hook
|
||||
// hooks/gsd-update-banner.js — update-available banner hook
|
||||
// hooks/gsd-statusline.js — session statusline hook
|
||||
// hooks/gsd-context-monitor.js — context-window monitor hook
|
||||
// hooks/gsd-update-banner.js — update-available banner hook
|
||||
// hooks/gsd-graphify-update.sh — knowledge-graph auto-update PostToolUse hook (#3347)
|
||||
// These appear in docs as file-path references (e.g. "gsd-statusline.js reads
|
||||
// the cache"), not as command invocations.
|
||||
'statusline',
|
||||
'context-monitor',
|
||||
'update-banner',
|
||||
'graphify-update',
|
||||
|
||||
// gsd-update-check.json — background update-check CACHE FILE, not a slash command.
|
||||
// ARCHITECTURE.md references "~/.cache/gsd/gsd-update-check.json" as a path;
|
||||
|
||||
238
tests/feat-3347-graphify-auto-update-config.test.cjs
Normal file
238
tests/feat-3347-graphify-auto-update-config.test.cjs
Normal file
@@ -0,0 +1,238 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Regression tests for #3347 — opt-in auto-update of the knowledge graph
|
||||
* after main HEAD advances.
|
||||
*
|
||||
* This file covers the config-key surface: the new `graphify.auto_update`
|
||||
* key must be a valid config key, default to false, persist via config-set,
|
||||
* and round-trip via config-get. The runtime hook behavior is covered in
|
||||
* tests/feat-3347-graphify-auto-update-hook.test.cjs.
|
||||
*
|
||||
* Default-off discipline (issue #3347 acceptance criteria):
|
||||
* - `graphify.auto_update` defaults to `false` so existing users see no
|
||||
* behavior change after upgrade.
|
||||
* - Opt-in via /gsd:settings or `gsd-tools config-set graphify.auto_update true`.
|
||||
*/
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs');
|
||||
|
||||
const {
|
||||
VALID_CONFIG_KEYS,
|
||||
isValidConfigKey,
|
||||
} = require('../get-shit-done/bin/lib/config-schema.cjs');
|
||||
|
||||
const {
|
||||
CONFIG_DEFAULTS: CANONICAL_CONFIG_DEFAULTS,
|
||||
} = require('../get-shit-done/bin/lib/configuration.generated.cjs');
|
||||
|
||||
const fsx = require('node:fs');
|
||||
const pathx = require('node:path');
|
||||
const cpx = require('node:child_process');
|
||||
const osx = require('node:os');
|
||||
const { graphifyStatus } = require('../get-shit-done/bin/lib/graphify.cjs');
|
||||
|
||||
function makeStatusProject(autoUpdate) {
|
||||
const tmpDir = fsx.mkdtempSync(pathx.join(osx.tmpdir(), 'gsd-3347-status-'));
|
||||
cpx.execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: tmpDir });
|
||||
cpx.execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: tmpDir });
|
||||
cpx.execFileSync('git', ['config', 'user.name', 'Test'], { cwd: tmpDir });
|
||||
fsx.writeFileSync(pathx.join(tmpDir, 'README.md'), '# t\n');
|
||||
cpx.execFileSync('git', ['add', '.'], { cwd: tmpDir });
|
||||
cpx.execFileSync('git', ['commit', '-qm', 'init'], { cwd: tmpDir });
|
||||
fsx.mkdirSync(pathx.join(tmpDir, '.planning/graphs'), { recursive: true });
|
||||
fsx.writeFileSync(
|
||||
pathx.join(tmpDir, '.planning/config.json'),
|
||||
JSON.stringify({ graphify: { enabled: true } }),
|
||||
);
|
||||
// Write a fresh (current-mtime) graph so age-based stale is false; only the
|
||||
// auto-update status field can set stale: true.
|
||||
fsx.writeFileSync(
|
||||
pathx.join(tmpDir, '.planning/graphs/graph.json'),
|
||||
JSON.stringify({ nodes: [], edges: [] }),
|
||||
);
|
||||
if (autoUpdate !== null) {
|
||||
fsx.writeFileSync(
|
||||
pathx.join(tmpDir, '.planning/graphs/.last-build-status.json'),
|
||||
JSON.stringify(autoUpdate),
|
||||
);
|
||||
}
|
||||
return tmpDir;
|
||||
}
|
||||
|
||||
describe('#3347 — graphify.auto_update is a registered config key', () => {
|
||||
test('VALID_CONFIG_KEYS contains graphify.auto_update', () => {
|
||||
assert.ok(
|
||||
VALID_CONFIG_KEYS.has('graphify.auto_update'),
|
||||
'graphify.auto_update must be in VALID_CONFIG_KEYS so config-set accepts it',
|
||||
);
|
||||
});
|
||||
|
||||
test('isValidConfigKey accepts graphify.auto_update', () => {
|
||||
assert.ok(
|
||||
isValidConfigKey('graphify.auto_update'),
|
||||
'isValidConfigKey must return true for graphify.auto_update',
|
||||
);
|
||||
});
|
||||
|
||||
test('isValidConfigKey still accepts the pre-existing graphify.enabled key', () => {
|
||||
assert.ok(
|
||||
isValidConfigKey('graphify.enabled'),
|
||||
'regression guard: graphify.enabled must remain a valid key',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#3347 — graphify.auto_update defaults to false', () => {
|
||||
test('CANONICAL_CONFIG_DEFAULTS.graphify.auto_update is false', () => {
|
||||
assert.ok(
|
||||
CANONICAL_CONFIG_DEFAULTS.graphify !== undefined,
|
||||
'CANONICAL_CONFIG_DEFAULTS must expose a graphify section',
|
||||
);
|
||||
assert.strictEqual(
|
||||
CANONICAL_CONFIG_DEFAULTS.graphify.auto_update,
|
||||
false,
|
||||
'graphify.auto_update default must be false (opt-in per issue #3347 AC)',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#3347 — config-set graphify.auto_update round-trips', () => {
|
||||
test('config-set graphify.auto_update true succeeds', (t) => {
|
||||
const tmpDir = createTempProject();
|
||||
t.after(() => cleanup(tmpDir));
|
||||
|
||||
const result = runGsdTools(
|
||||
['config-set', 'graphify.auto_update', 'true'],
|
||||
tmpDir,
|
||||
);
|
||||
assert.ok(
|
||||
result.success,
|
||||
[
|
||||
'config-set graphify.auto_update true should succeed,',
|
||||
'got:',
|
||||
'stdout: ' + result.output,
|
||||
'stderr: ' + result.error,
|
||||
].join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
test('config-set graphify.auto_update true writes to config.json', (t) => {
|
||||
const tmpDir = createTempProject();
|
||||
t.after(() => cleanup(tmpDir));
|
||||
|
||||
runGsdTools(['config-set', 'graphify.auto_update', 'true'], tmpDir);
|
||||
|
||||
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
||||
assert.ok(
|
||||
fs.existsSync(configPath),
|
||||
'.planning/config.json must exist after config-set',
|
||||
);
|
||||
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
assert.strictEqual(
|
||||
config.graphify?.auto_update,
|
||||
true,
|
||||
[
|
||||
'Expected graphify.auto_update: true in config.json,',
|
||||
'got: ' + JSON.stringify(config.graphify),
|
||||
].join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
test('config-set graphify.auto_update false persists too', (t) => {
|
||||
const tmpDir = createTempProject();
|
||||
t.after(() => cleanup(tmpDir));
|
||||
|
||||
runGsdTools(['config-set', 'graphify.auto_update', 'true'], tmpDir);
|
||||
runGsdTools(['config-set', 'graphify.auto_update', 'false'], tmpDir);
|
||||
|
||||
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
assert.strictEqual(
|
||||
config.graphify?.auto_update,
|
||||
false,
|
||||
'config-set must round-trip true → false',
|
||||
);
|
||||
});
|
||||
|
||||
test('graphifyStatus folds auto-update status=failed into stale=true', (t) => {
|
||||
const tmpDir = makeStatusProject({
|
||||
ts: '2026-05-15T12:00:00Z',
|
||||
status: 'failed',
|
||||
exit_code: 1,
|
||||
duration_ms: 1234,
|
||||
head_at_build: 'abcdef0',
|
||||
graphify_version: null,
|
||||
});
|
||||
t.after(() => fsx.rmSync(tmpDir, { recursive: true, force: true }));
|
||||
const s = graphifyStatus(tmpDir);
|
||||
assert.strictEqual(s.stale, true, 'auto-build failure must set stale=true');
|
||||
assert.ok(s.last_build_auto_update, 'last_build_auto_update must be exposed');
|
||||
assert.strictEqual(s.last_build_auto_update.status, 'failed');
|
||||
assert.strictEqual(s.last_build_auto_update.exit_code, 1);
|
||||
});
|
||||
|
||||
test('graphifyStatus folds auto-update status=running into stale=true', (t) => {
|
||||
const tmpDir = makeStatusProject({
|
||||
ts: '2026-05-15T12:00:00Z',
|
||||
status: 'running',
|
||||
exit_code: null,
|
||||
duration_ms: null,
|
||||
head_at_build: 'abcdef0',
|
||||
graphify_version: null,
|
||||
});
|
||||
t.after(() => fsx.rmSync(tmpDir, { recursive: true, force: true }));
|
||||
const s = graphifyStatus(tmpDir);
|
||||
assert.strictEqual(s.stale, true, 'auto-build in-flight must set stale=true');
|
||||
assert.strictEqual(s.last_build_auto_update.status, 'running');
|
||||
});
|
||||
|
||||
test('graphifyStatus leaves stale alone when auto-update status=ok and graph is fresh', (t) => {
|
||||
const tmpDir = makeStatusProject({
|
||||
ts: '2026-05-15T12:00:00Z',
|
||||
status: 'ok',
|
||||
exit_code: 0,
|
||||
duration_ms: 1234,
|
||||
head_at_build: 'abcdef0',
|
||||
graphify_version: null,
|
||||
});
|
||||
t.after(() => fsx.rmSync(tmpDir, { recursive: true, force: true }));
|
||||
const s = graphifyStatus(tmpDir);
|
||||
assert.strictEqual(s.stale, false, 'fresh graph + ok auto-build => not stale');
|
||||
assert.strictEqual(s.last_build_auto_update.status, 'ok');
|
||||
});
|
||||
|
||||
test('graphifyStatus exposes last_build_auto_update: null when status file absent', (t) => {
|
||||
const tmpDir = makeStatusProject(null);
|
||||
t.after(() => fsx.rmSync(tmpDir, { recursive: true, force: true }));
|
||||
const s = graphifyStatus(tmpDir);
|
||||
assert.strictEqual(s.last_build_auto_update, null);
|
||||
assert.strictEqual(s.stale, false, 'no status file => stale follows mtime only');
|
||||
});
|
||||
|
||||
test('config-set graphify.auto_update does not perturb sibling graphify.enabled', (t) => {
|
||||
const tmpDir = createTempProject();
|
||||
t.after(() => cleanup(tmpDir));
|
||||
|
||||
runGsdTools(['config-set', 'graphify.enabled', 'true'], tmpDir);
|
||||
runGsdTools(['config-set', 'graphify.auto_update', 'true'], tmpDir);
|
||||
|
||||
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
assert.strictEqual(
|
||||
config.graphify?.enabled,
|
||||
true,
|
||||
'graphify.enabled must be preserved when setting graphify.auto_update',
|
||||
);
|
||||
assert.strictEqual(
|
||||
config.graphify?.auto_update,
|
||||
true,
|
||||
'graphify.auto_update must coexist with graphify.enabled',
|
||||
);
|
||||
});
|
||||
});
|
||||
394
tests/feat-3347-graphify-auto-update-hook.test.cjs
Normal file
394
tests/feat-3347-graphify-auto-update-hook.test.cjs
Normal file
@@ -0,0 +1,394 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Regression tests for #3347 — hooks/gsd-graphify-update.sh behavior.
|
||||
*
|
||||
* The hook is a PostToolUse handler that fires after every Bash tool call.
|
||||
* It is a no-op except when ALL of these are true:
|
||||
* - Tool name is Bash
|
||||
* - tool_input.command matches a HEAD-advancing git operation
|
||||
* - Current branch == default branch (main/master/trunk; configurable)
|
||||
* - .planning/config.json has graphify.enabled === true
|
||||
* - .planning/config.json has graphify.auto_update === true
|
||||
* - $CI environment variable is unset / empty
|
||||
* - graphify binary is on PATH
|
||||
* - No live rebuild already in progress (PID lock check)
|
||||
*
|
||||
* When all gates pass, the hook:
|
||||
* 1. Writes .planning/graphs/.last-build-status.json with status="running"
|
||||
* and the current HEAD sha (sync, before detach).
|
||||
* 2. Detaches a background `graphify update .` invocation that copies
|
||||
* outputs into .planning/graphs/ and updates the status file to
|
||||
* status="ok" or status="failed" on completion.
|
||||
* 3. Returns exit 0 in <100ms regardless.
|
||||
*
|
||||
* On a hook return path failure (bail), no status file is written and no
|
||||
* lock is acquired — the commit completes with no side effect.
|
||||
*/
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const cp = require('node:child_process');
|
||||
const os = require('node:os');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const HOOK = path.join(ROOT, 'hooks', 'gsd-graphify-update.sh');
|
||||
|
||||
function createTempGitRepo(opts = {}) {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3347-'));
|
||||
cp.execFileSync('git', ['init', '-b', opts.defaultBranch || 'main'], {
|
||||
cwd: tmpDir,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
cp.execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: tmpDir });
|
||||
cp.execFileSync('git', ['config', 'user.name', 'Test'], { cwd: tmpDir });
|
||||
fs.writeFileSync(path.join(tmpDir, 'README.md'), '# test\n');
|
||||
cp.execFileSync('git', ['add', 'README.md'], { cwd: tmpDir });
|
||||
cp.execFileSync('git', ['commit', '-m', 'init'], { cwd: tmpDir, stdio: 'ignore' });
|
||||
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
|
||||
if (opts.config !== undefined) {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify(opts.config, null, 2),
|
||||
);
|
||||
}
|
||||
return tmpDir;
|
||||
}
|
||||
|
||||
function makeMockGraphifyBin(tmpDir, { exitCode = 0, sleepMs = 0 } = {}) {
|
||||
const binDir = path.join(tmpDir, '.mock-bin');
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
const script = path.join(binDir, 'graphify');
|
||||
// Mock: sleep optionally to allow lock observation, then write
|
||||
// graphify-out/graph.json and exit with the requested code.
|
||||
const body = [
|
||||
'#!/usr/bin/env bash',
|
||||
'set -u',
|
||||
sleepMs ? `sleep ${(sleepMs / 1000).toFixed(3)}` : '',
|
||||
'mkdir -p graphify-out',
|
||||
'echo \'{"nodes":[],"edges":[]}\' > graphify-out/graph.json',
|
||||
'echo "mock report" > graphify-out/GRAPH_REPORT.md',
|
||||
'echo "<html></html>" > graphify-out/graph.html',
|
||||
`exit ${exitCode}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
fs.writeFileSync(script, body + '\n', { mode: 0o755 });
|
||||
return binDir;
|
||||
}
|
||||
|
||||
function runHook(tmpDir, toolPayload, { env = {}, pathPrepend = '' } = {}) {
|
||||
const PATH = pathPrepend
|
||||
? `${pathPrepend}${path.delimiter}${process.env.PATH || ''}`
|
||||
: process.env.PATH || '';
|
||||
return cp.spawnSync('bash', [HOOK], {
|
||||
cwd: tmpDir,
|
||||
input: JSON.stringify(toolPayload),
|
||||
env: {
|
||||
...process.env,
|
||||
PATH,
|
||||
CI: '',
|
||||
...env,
|
||||
},
|
||||
encoding: 'utf8',
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
function cleanup(tmpDir) {
|
||||
// The hook detaches a graphify-rebuild subprocess that may still be writing
|
||||
// into tmpDir when the test body returns. Wait briefly for its lock file to
|
||||
// disappear (rebuild process exit trap removes it), then retry rmSync to
|
||||
// absorb any remaining transient ENOTEMPTY race.
|
||||
const lockPath = path.join(tmpDir, '.planning/graphs/.rebuild.lock');
|
||||
const lockDeadline = Date.now() + 4000;
|
||||
while (Date.now() < lockDeadline) {
|
||||
if (!fs.existsSync(lockPath)) break;
|
||||
try {
|
||||
const pid = parseInt(fs.readFileSync(lockPath, 'utf8'), 10);
|
||||
if (!Number.isFinite(pid) || pid <= 0) break;
|
||||
cp.execFileSync('kill', ['-0', String(pid)], { stdio: 'ignore' });
|
||||
} catch {
|
||||
break; // PID dead → safe to clean up
|
||||
}
|
||||
cp.execFileSync('sleep', ['0.05']);
|
||||
}
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 8, retryDelay: 100 });
|
||||
}
|
||||
|
||||
describe('#3347 hook — bail paths (no side effects)', () => {
|
||||
test('non-Bash tool call exits 0 with no status file', (t) => {
|
||||
const tmpDir = createTempGitRepo({
|
||||
config: { graphify: { enabled: true, auto_update: true } },
|
||||
});
|
||||
t.after(() => cleanup(tmpDir));
|
||||
const r = runHook(tmpDir, { tool_name: 'Edit', tool_input: { file_path: 'x' } });
|
||||
assert.strictEqual(r.status, 0, 'hook must exit 0 on non-Bash tool');
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(tmpDir, '.planning/graphs/.last-build-status.json')),
|
||||
'no status file should be created when bailing',
|
||||
);
|
||||
});
|
||||
|
||||
test('Bash but non-HEAD-advancing command exits 0 with no status file', (t) => {
|
||||
const tmpDir = createTempGitRepo({
|
||||
config: { graphify: { enabled: true, auto_update: true } },
|
||||
});
|
||||
t.after(() => cleanup(tmpDir));
|
||||
const r = runHook(tmpDir, { tool_name: 'Bash', tool_input: { command: 'ls -la' } });
|
||||
assert.strictEqual(r.status, 0);
|
||||
assert.ok(!fs.existsSync(path.join(tmpDir, '.planning/graphs/.last-build-status.json')));
|
||||
});
|
||||
|
||||
test('git commit but graphify.enabled=false → no dispatch', (t) => {
|
||||
const tmpDir = createTempGitRepo({
|
||||
config: { graphify: { enabled: false, auto_update: true } },
|
||||
});
|
||||
t.after(() => cleanup(tmpDir));
|
||||
const r = runHook(tmpDir, { tool_name: 'Bash', tool_input: { command: 'git commit -m x' } });
|
||||
assert.strictEqual(r.status, 0);
|
||||
assert.ok(!fs.existsSync(path.join(tmpDir, '.planning/graphs/.last-build-status.json')));
|
||||
});
|
||||
|
||||
test('git commit but graphify.auto_update=false → no dispatch (opt-in)', (t) => {
|
||||
const tmpDir = createTempGitRepo({
|
||||
config: { graphify: { enabled: true, auto_update: false } },
|
||||
});
|
||||
t.after(() => cleanup(tmpDir));
|
||||
const r = runHook(tmpDir, { tool_name: 'Bash', tool_input: { command: 'git commit -m x' } });
|
||||
assert.strictEqual(r.status, 0);
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(tmpDir, '.planning/graphs/.last-build-status.json')),
|
||||
'opt-in default-off: auto_update=false must suppress dispatch',
|
||||
);
|
||||
});
|
||||
|
||||
test('CI=true → no dispatch even with both gates true', (t) => {
|
||||
const tmpDir = createTempGitRepo({
|
||||
config: { graphify: { enabled: true, auto_update: true } },
|
||||
});
|
||||
t.after(() => cleanup(tmpDir));
|
||||
const mockBin = makeMockGraphifyBin(tmpDir);
|
||||
const r = runHook(
|
||||
tmpDir,
|
||||
{ tool_name: 'Bash', tool_input: { command: 'git commit -m x' } },
|
||||
{ env: { CI: 'true' }, pathPrepend: mockBin },
|
||||
);
|
||||
assert.strictEqual(r.status, 0);
|
||||
assert.ok(!fs.existsSync(path.join(tmpDir, '.planning/graphs/.last-build-status.json')));
|
||||
});
|
||||
|
||||
test('on non-default branch → no dispatch', (t) => {
|
||||
const tmpDir = createTempGitRepo({
|
||||
config: { graphify: { enabled: true, auto_update: true } },
|
||||
});
|
||||
t.after(() => cleanup(tmpDir));
|
||||
cp.execFileSync('git', ['checkout', '-b', 'worktree-agent-abc'], {
|
||||
cwd: tmpDir,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
const mockBin = makeMockGraphifyBin(tmpDir);
|
||||
const r = runHook(
|
||||
tmpDir,
|
||||
{ tool_name: 'Bash', tool_input: { command: 'git commit -m x' } },
|
||||
{ pathPrepend: mockBin },
|
||||
);
|
||||
assert.strictEqual(r.status, 0);
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(tmpDir, '.planning/graphs/.last-build-status.json')),
|
||||
'branch check must filter worktree-agent-* (non-default-branch) commits',
|
||||
);
|
||||
});
|
||||
|
||||
test('graphify binary not on PATH → silent exit 0', (t) => {
|
||||
const tmpDir = createTempGitRepo({
|
||||
config: { graphify: { enabled: true, auto_update: true } },
|
||||
});
|
||||
t.after(() => cleanup(tmpDir));
|
||||
// Note: do NOT prepend mock bin; rely on real PATH not having graphify
|
||||
const r = runHook(
|
||||
tmpDir,
|
||||
{ tool_name: 'Bash', tool_input: { command: 'git commit -m x' } },
|
||||
{ env: { PATH: '/usr/bin:/bin' } },
|
||||
);
|
||||
assert.strictEqual(r.status, 0, 'must not break commits when graphify missing');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#3347 hook — dispatch path (all gates pass)', () => {
|
||||
test('writes status file with status=running synchronously before returning', (t) => {
|
||||
const tmpDir = createTempGitRepo({
|
||||
config: { graphify: { enabled: true, auto_update: true } },
|
||||
});
|
||||
t.after(() => cleanup(tmpDir));
|
||||
// Sleep 2s in mock so we can observe the running state before completion
|
||||
const mockBin = makeMockGraphifyBin(tmpDir, { sleepMs: 2000 });
|
||||
|
||||
const r = runHook(
|
||||
tmpDir,
|
||||
{ tool_name: 'Bash', tool_input: { command: 'git commit -m x' } },
|
||||
{ pathPrepend: mockBin },
|
||||
);
|
||||
assert.strictEqual(r.status, 0, 'hook must return 0');
|
||||
|
||||
const statusPath = path.join(tmpDir, '.planning/graphs/.last-build-status.json');
|
||||
assert.ok(fs.existsSync(statusPath), 'status file must be written synchronously');
|
||||
const status = JSON.parse(fs.readFileSync(statusPath, 'utf8'));
|
||||
assert.strictEqual(status.status, 'running', 'initial status must be "running"');
|
||||
assert.ok(/^[0-9a-f]{7,40}$/.test(status.head_at_build), 'head_at_build must be a commit sha');
|
||||
});
|
||||
|
||||
test('completes to status=ok after detached graphify run succeeds', (t) => {
|
||||
const tmpDir = createTempGitRepo({
|
||||
config: { graphify: { enabled: true, auto_update: true } },
|
||||
});
|
||||
t.after(() => cleanup(tmpDir));
|
||||
const mockBin = makeMockGraphifyBin(tmpDir, { exitCode: 0, sleepMs: 200 });
|
||||
|
||||
runHook(
|
||||
tmpDir,
|
||||
{ tool_name: 'Bash', tool_input: { command: 'git commit -m x' } },
|
||||
{ pathPrepend: mockBin },
|
||||
);
|
||||
|
||||
// Wait up to 5s for the detached process to finish updating the status
|
||||
const statusPath = path.join(tmpDir, '.planning/graphs/.last-build-status.json');
|
||||
const deadline = Date.now() + 15000;
|
||||
let status;
|
||||
while (Date.now() < deadline) {
|
||||
if (fs.existsSync(statusPath)) {
|
||||
status = JSON.parse(fs.readFileSync(statusPath, 'utf8'));
|
||||
if (status.status === 'ok') break;
|
||||
}
|
||||
cp.execFileSync('sleep', ['0.1']);
|
||||
}
|
||||
assert.ok(status, 'status file must exist after dispatch');
|
||||
assert.strictEqual(status.status, 'ok', 'mock graphify exit=0 → status ok');
|
||||
assert.strictEqual(status.exit_code, 0);
|
||||
assert.ok(typeof status.duration_ms === 'number' && status.duration_ms >= 0);
|
||||
});
|
||||
|
||||
test('completes to status=failed when graphify exits non-zero', (t) => {
|
||||
const tmpDir = createTempGitRepo({
|
||||
config: { graphify: { enabled: true, auto_update: true } },
|
||||
});
|
||||
t.after(() => cleanup(tmpDir));
|
||||
const mockBin = makeMockGraphifyBin(tmpDir, { exitCode: 1, sleepMs: 100 });
|
||||
|
||||
runHook(
|
||||
tmpDir,
|
||||
{ tool_name: 'Bash', tool_input: { command: 'git commit -m x' } },
|
||||
{ pathPrepend: mockBin },
|
||||
);
|
||||
|
||||
const statusPath = path.join(tmpDir, '.planning/graphs/.last-build-status.json');
|
||||
const deadline = Date.now() + 15000;
|
||||
let status;
|
||||
while (Date.now() < deadline) {
|
||||
if (fs.existsSync(statusPath)) {
|
||||
status = JSON.parse(fs.readFileSync(statusPath, 'utf8'));
|
||||
if (status.status === 'failed') break;
|
||||
}
|
||||
cp.execFileSync('sleep', ['0.1']);
|
||||
}
|
||||
assert.ok(status, 'status file must exist after dispatch');
|
||||
assert.strictEqual(status.status, 'failed', 'mock graphify exit=1 → status failed');
|
||||
assert.strictEqual(status.exit_code, 1);
|
||||
});
|
||||
|
||||
test('lock file with a live PID prevents concurrent dispatch', (t) => {
|
||||
const tmpDir = createTempGitRepo({
|
||||
config: { graphify: { enabled: true, auto_update: true } },
|
||||
});
|
||||
t.after(() => cleanup(tmpDir));
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning/graphs'), { recursive: true });
|
||||
// Seed a live-PID lock pointing at our own process — kill -0 will succeed
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning/graphs/.rebuild.lock'), String(process.pid));
|
||||
|
||||
const mockBin = makeMockGraphifyBin(tmpDir);
|
||||
const r = runHook(
|
||||
tmpDir,
|
||||
{ tool_name: 'Bash', tool_input: { command: 'git commit -m x' } },
|
||||
{ pathPrepend: mockBin },
|
||||
);
|
||||
assert.strictEqual(r.status, 0);
|
||||
// Status file should NOT be written because a rebuild is in flight
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(tmpDir, '.planning/graphs/.last-build-status.json')),
|
||||
'live PID lock must suppress dispatch',
|
||||
);
|
||||
});
|
||||
|
||||
test('stale lock file (dead PID) is treated as absent', (t) => {
|
||||
const tmpDir = createTempGitRepo({
|
||||
config: { graphify: { enabled: true, auto_update: true } },
|
||||
});
|
||||
t.after(() => cleanup(tmpDir));
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning/graphs'), { recursive: true });
|
||||
// PID 1 is init; kill -0 1 succeeds for root but fails for non-root.
|
||||
// Use a very large PID number unlikely to exist (max pid = 4194304 on linux).
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning/graphs/.rebuild.lock'), '4194303');
|
||||
|
||||
const mockBin = makeMockGraphifyBin(tmpDir, { sleepMs: 500 });
|
||||
const r = runHook(
|
||||
tmpDir,
|
||||
{ tool_name: 'Bash', tool_input: { command: 'git commit -m x' } },
|
||||
{ pathPrepend: mockBin },
|
||||
);
|
||||
assert.strictEqual(r.status, 0);
|
||||
const statusPath = path.join(tmpDir, '.planning/graphs/.last-build-status.json');
|
||||
assert.ok(fs.existsSync(statusPath), 'stale lock must not block dispatch');
|
||||
});
|
||||
|
||||
test('respects git.base_branch config override (default branch != main)', (t) => {
|
||||
const tmpDir = createTempGitRepo({
|
||||
defaultBranch: 'trunk',
|
||||
config: {
|
||||
graphify: { enabled: true, auto_update: true },
|
||||
git: { base_branch: 'trunk' },
|
||||
},
|
||||
});
|
||||
t.after(() => cleanup(tmpDir));
|
||||
const mockBin = makeMockGraphifyBin(tmpDir, { sleepMs: 100 });
|
||||
const r = runHook(
|
||||
tmpDir,
|
||||
{ tool_name: 'Bash', tool_input: { command: 'git commit -m x' } },
|
||||
{ pathPrepend: mockBin },
|
||||
);
|
||||
assert.strictEqual(r.status, 0);
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(tmpDir, '.planning/graphs/.last-build-status.json')),
|
||||
'hook must honor git.base_branch when default branch is not main',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#3347 hook — HEAD-advancing command matchers', () => {
|
||||
for (const cmd of [
|
||||
'git commit -m fix',
|
||||
'git merge feature',
|
||||
'git pull --ff-only',
|
||||
'git rebase --continue',
|
||||
'git cherry-pick abc123',
|
||||
]) {
|
||||
test(`dispatches on: ${cmd}`, (t) => {
|
||||
const tmpDir = createTempGitRepo({
|
||||
config: { graphify: { enabled: true, auto_update: true } },
|
||||
});
|
||||
t.after(() => cleanup(tmpDir));
|
||||
const mockBin = makeMockGraphifyBin(tmpDir, { sleepMs: 100 });
|
||||
runHook(
|
||||
tmpDir,
|
||||
{ tool_name: 'Bash', tool_input: { command: cmd } },
|
||||
{ pathPrepend: mockBin },
|
||||
);
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(tmpDir, '.planning/graphs/.last-build-status.json')),
|
||||
`must dispatch for HEAD-advancing op: ${cmd}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user