mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-26 04:19:33 +08:00
fix(sync): git sync hooks refresh the project they were installed for (#1965)
Git runs hooks from the top of the work tree, so a hook running a bare `codegraph sync` never reached a sub-project's index. Each hook block now names the project root it syncs, shell-quoted, one block per root, placed right after the shebang so a user hook that ends in exit cannot skip it. A hook in another language is left untouched and reported, and a block from an older install counts as the top-level project's. A project path with a line break is refused, since the root is written on a comment line in the hook. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
e3f23cc4bc
commit
b95b316860
@@ -720,4 +720,11 @@ export async function offerWatchFallback(
|
||||
'Run `codegraph sync` after changes instead.',
|
||||
);
|
||||
}
|
||||
if (result.unsupported && result.unsupported.length > 0) {
|
||||
clack.log.warn(
|
||||
`Left your ${result.unsupported.join(', ')} hook${result.unsupported.length > 1 ? 's' : ''} unchanged — ` +
|
||||
`${result.unsupported.length > 1 ? 'they are' : 'it is'} not a shell script. ` +
|
||||
`Add \`codegraph sync ${projectPath}\` to ${result.unsupported.length > 1 ? 'them' : 'it'} to refresh the index there too.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+149
-28
@@ -7,10 +7,14 @@
|
||||
* install git hooks that refresh the index after the operations that change
|
||||
* files on disk: commit, merge (covers `git pull`), and checkout.
|
||||
*
|
||||
* The hooks run `codegraph sync` in the background so they never block git,
|
||||
* and are guarded by `command -v codegraph` so they no-op cleanly when the
|
||||
* CLI isn't on PATH. Our snippet is delimited by marker comments so install
|
||||
* is idempotent and removal preserves any user-authored hook content.
|
||||
* The hooks run `codegraph sync <project root>` in the background so they
|
||||
* never block git, and are guarded by `command -v codegraph` so they no-op
|
||||
* cleanly when the CLI isn't on PATH. Git runs hooks from the top of the work
|
||||
* tree, so the block names its project root explicitly — a sub-project's
|
||||
* index (`packages/app/.codegraph`) would otherwise never be found. Each root
|
||||
* gets its own marker-delimited block, placed right after the shebang so a
|
||||
* user hook that ends in `exit` can't skip it; install is idempotent per root
|
||||
* and removal preserves any user-authored hook content.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
@@ -19,6 +23,10 @@ import { execFileSync } from 'child_process';
|
||||
|
||||
const MARKER_BEGIN = '# >>> codegraph sync hook >>>';
|
||||
const MARKER_END = '# <<< codegraph sync hook <<<';
|
||||
/** Names the project root a block syncs. Blocks written before it have none. */
|
||||
const ROOT_PREFIX = '# codegraph-root: ';
|
||||
/** Interpreters our shell snippet can be added to. */
|
||||
const SHELL_INTERPRETERS = new Set(['sh', 'bash', 'dash', 'zsh', 'ksh', 'mksh', 'ash']);
|
||||
|
||||
export type GitHookName = 'post-commit' | 'post-merge' | 'post-checkout';
|
||||
|
||||
@@ -32,6 +40,11 @@ export interface GitHookResult {
|
||||
hooksDir: string | null;
|
||||
/** Reason nothing happened (e.g. not a git repository). */
|
||||
skipped?: string;
|
||||
/**
|
||||
* Existing hooks left untouched because they aren't shell scripts (a
|
||||
* `#!/usr/bin/env python3` hook can't run our snippet).
|
||||
*/
|
||||
unsupported?: GitHookName[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,34 +86,124 @@ function gitHooksDir(projectRoot: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The key a block records for its project: the resolved root with forward
|
||||
* slashes, which Git for Windows' sh also accepts.
|
||||
*/
|
||||
function rootKey(projectRoot: string): string {
|
||||
let resolved = path.resolve(projectRoot);
|
||||
try {
|
||||
resolved = fs.realpathSync(resolved);
|
||||
} catch {
|
||||
/* keep the lexical path when it can't be resolved */
|
||||
}
|
||||
return resolved.split(path.sep).join('/');
|
||||
}
|
||||
|
||||
/** The work tree's top level (as a {@link rootKey}), or null when unknown. */
|
||||
function gitToplevelKey(projectRoot: string): string | null {
|
||||
try {
|
||||
const out = execFileSync('git', ['rev-parse', '--show-toplevel'], {
|
||||
cwd: projectRoot,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
windowsHide: true,
|
||||
timeout: 5000, // same rationale as isGitRepo
|
||||
}).trim();
|
||||
return out ? rootKey(out) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
/** The shell snippet (between markers) injected into each hook. */
|
||||
function markerBlock(): string {
|
||||
function markerBlock(root: string): string {
|
||||
return [
|
||||
MARKER_BEGIN,
|
||||
'# Keeps the CodeGraph index fresh while the live file watcher is off',
|
||||
'# (e.g. WSL2 /mnt drives). Runs in the background so it never blocks git.',
|
||||
'# Managed by codegraph; remove with `codegraph uninit` or delete this block.',
|
||||
`${ROOT_PREFIX}${root}`,
|
||||
'if command -v codegraph >/dev/null 2>&1; then',
|
||||
' ( codegraph sync >/dev/null 2>&1 & ) >/dev/null 2>&1',
|
||||
` ( codegraph sync ${shellQuote(root)} >/dev/null 2>&1 & ) >/dev/null 2>&1`,
|
||||
'fi',
|
||||
MARKER_END,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/** Remove our marker block (and the marker lines) from hook content. */
|
||||
function stripMarkerBlock(content: string): string {
|
||||
type BlockOwner = (blockRoot: string | null) => boolean;
|
||||
|
||||
/**
|
||||
* Which blocks belong to the project being installed / removed / checked: the
|
||||
* one naming its root, and — for the work tree's top-level project only — a
|
||||
* block from an older install, which named no root and so synced from the
|
||||
* directory git runs hooks in.
|
||||
*/
|
||||
function ownerFor(projectRoot: string): BlockOwner {
|
||||
const root = rootKey(projectRoot);
|
||||
let toplevel: string | null | undefined;
|
||||
return (blockRoot) => {
|
||||
if (blockRoot !== null) return blockRoot === root;
|
||||
if (toplevel === undefined) toplevel = gitToplevelKey(projectRoot);
|
||||
return toplevel === root;
|
||||
};
|
||||
}
|
||||
|
||||
/** The root each marker block in `content` names (null for an older block). */
|
||||
function blockRoots(content: string): (string | null)[] {
|
||||
const roots: (string | null)[] = [];
|
||||
let current: string | null | undefined;
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed === MARKER_BEGIN) { current = null; continue; }
|
||||
if (current === undefined) continue;
|
||||
if (trimmed.startsWith(ROOT_PREFIX)) { current = trimmed.slice(ROOT_PREFIX.length); continue; }
|
||||
if (trimmed === MARKER_END) { roots.push(current); current = undefined; }
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the marker blocks `owns` claims (and the blank line that follows
|
||||
* one), leaving every other block and all user content in place.
|
||||
*/
|
||||
function stripMarkerBlocks(content: string, owns: BlockOwner): string {
|
||||
const lines = content.split('\n');
|
||||
const kept: string[] = [];
|
||||
let inBlock = false;
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed === MARKER_BEGIN) { inBlock = true; continue; }
|
||||
if (trimmed === MARKER_END) { inBlock = false; continue; }
|
||||
if (!inBlock) kept.push(line);
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i]!.trim() !== MARKER_BEGIN) {
|
||||
kept.push(lines[i]!);
|
||||
continue;
|
||||
}
|
||||
let end = i;
|
||||
while (end < lines.length && lines[end]!.trim() !== MARKER_END) end++;
|
||||
const block = lines.slice(i, end + 1);
|
||||
const rootLine = block.find((l) => l.trim().startsWith(ROOT_PREFIX));
|
||||
const blockRoot = rootLine ? rootLine.trim().slice(ROOT_PREFIX.length) : null;
|
||||
if (owns(blockRoot)) {
|
||||
if (lines[end + 1]?.trim() === '') end++;
|
||||
} else {
|
||||
kept.push(...block);
|
||||
}
|
||||
i = end;
|
||||
}
|
||||
return kept.join('\n');
|
||||
}
|
||||
|
||||
/** Whether a hook's shebang names a shell (a hook with none runs under sh). */
|
||||
function isShellHook(content: string): boolean {
|
||||
const first = content.split('\n', 1)[0]!.trim();
|
||||
if (!first.startsWith('#!')) return true;
|
||||
const words = first.slice(2).trim().split(/\s+/);
|
||||
let program = path.posix.basename(words[0] ?? '');
|
||||
if (program === 'env') program = path.posix.basename(words.slice(1).find((w) => !w.startsWith('-')) ?? '');
|
||||
return SHELL_INTERPRETERS.has(program);
|
||||
}
|
||||
|
||||
/** Whether a hook body is just a shebang / blank lines (i.e. only ever ours). */
|
||||
function isEffectivelyEmpty(content: string): boolean {
|
||||
return content
|
||||
@@ -126,6 +229,13 @@ export function installGitSyncHook(
|
||||
projectRoot: string,
|
||||
hooks: GitHookName[] = DEFAULT_SYNC_HOOKS,
|
||||
): GitHookResult {
|
||||
// The block names its root on a comment line; a line break in the path
|
||||
// would end that comment and run the rest of the path as a command.
|
||||
const root = rootKey(projectRoot);
|
||||
if (/[\r\n]/.test(root)) {
|
||||
return { installed: [], hooksDir: null, skipped: 'the project path contains a line break' };
|
||||
}
|
||||
|
||||
const hooksDir = gitHooksDir(projectRoot);
|
||||
if (!hooksDir) {
|
||||
return { installed: [], hooksDir: null, skipped: 'not a git repository' };
|
||||
@@ -137,21 +247,30 @@ export function installGitSyncHook(
|
||||
return { installed: [], hooksDir, skipped: 'could not access the git hooks directory' };
|
||||
}
|
||||
|
||||
const block = markerBlock();
|
||||
const block = markerBlock(root);
|
||||
const owns = ownerFor(projectRoot);
|
||||
const installed: GitHookName[] = [];
|
||||
const unsupported: GitHookName[] = [];
|
||||
|
||||
for (const hook of hooks) {
|
||||
const file = path.join(hooksDir, hook);
|
||||
let content: string;
|
||||
let content = `#!/bin/sh\n${block}\n`;
|
||||
|
||||
if (fs.existsSync(file)) {
|
||||
// Strip any prior block, then re-append the current one.
|
||||
const base = stripMarkerBlock(fs.readFileSync(file, 'utf8')).replace(/\s*$/, '');
|
||||
content = base.length > 0
|
||||
? `${base}\n\n${block}\n`
|
||||
: `#!/bin/sh\n${block}\n`;
|
||||
} else {
|
||||
content = `#!/bin/sh\n${block}\n`;
|
||||
const existing = fs.readFileSync(file, 'utf8');
|
||||
if (!isShellHook(existing)) {
|
||||
unsupported.push(hook);
|
||||
continue;
|
||||
}
|
||||
// Replace this root's prior block; go in right after the shebang so a
|
||||
// user hook that ends in `exit` or `exec` can't skip it.
|
||||
const base = stripMarkerBlocks(existing, owns).replace(/\s*$/, '');
|
||||
if (!isEffectivelyEmpty(base)) {
|
||||
const lines = base.split('\n');
|
||||
const shebang = lines[0]!.startsWith('#!') ? lines.shift()! : null;
|
||||
const rest = lines.join('\n').replace(/^\s*\n/, '');
|
||||
content = `${shebang ? `${shebang}\n` : ''}${block}\n\n${rest}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(file, content);
|
||||
@@ -159,7 +278,7 @@ export function installGitSyncHook(
|
||||
installed.push(hook);
|
||||
}
|
||||
|
||||
return { installed, hooksDir };
|
||||
return unsupported.length > 0 ? { installed, hooksDir, unsupported } : { installed, hooksDir };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -176,6 +295,7 @@ export function removeGitSyncHook(
|
||||
return { installed: [], hooksDir: null, skipped: 'not a git repository' };
|
||||
}
|
||||
|
||||
const owns = ownerFor(projectRoot);
|
||||
const removed: GitHookName[] = [];
|
||||
|
||||
for (const hook of hooks) {
|
||||
@@ -183,9 +303,9 @@ export function removeGitSyncHook(
|
||||
if (!fs.existsSync(file)) continue;
|
||||
|
||||
const original = fs.readFileSync(file, 'utf8');
|
||||
if (!original.includes(MARKER_BEGIN)) continue;
|
||||
if (!blockRoots(original).some(owns)) continue;
|
||||
|
||||
const stripped = stripMarkerBlock(original);
|
||||
const stripped = stripMarkerBlocks(original, owns);
|
||||
if (isEffectivelyEmpty(stripped)) {
|
||||
fs.unlinkSync(file);
|
||||
} else {
|
||||
@@ -198,15 +318,16 @@ export function removeGitSyncHook(
|
||||
return { installed: removed, hooksDir };
|
||||
}
|
||||
|
||||
/** Whether any CodeGraph sync hook is currently installed. */
|
||||
/** Whether a CodeGraph sync hook is installed for this project root. */
|
||||
export function isSyncHookInstalled(
|
||||
projectRoot: string,
|
||||
hooks: GitHookName[] = DEFAULT_SYNC_HOOKS,
|
||||
): boolean {
|
||||
const hooksDir = gitHooksDir(projectRoot);
|
||||
if (!hooksDir) return false;
|
||||
const owns = ownerFor(projectRoot);
|
||||
return hooks.some((hook) => {
|
||||
const file = path.join(hooksDir, hook);
|
||||
return fs.existsSync(file) && fs.readFileSync(file, 'utf8').includes(MARKER_BEGIN);
|
||||
return fs.existsSync(file) && blockRoots(fs.readFileSync(file, 'utf8')).some(owns);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user