mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-24 19:30:10 +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
@@ -201,6 +201,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
- In React with TypeScript, a component that renders a generic component and passes it type arguments, like outline's `<PaginatedList<Document> items={…} />` or excalidraw's `<DropdownMenuItemContentRadio<"contain" | "overlap"> …>`, now links to the component it renders. Before, a tag followed by type arguments was skipped, so the generic component looked unused by the screens that render it, and `codegraph_explore`'s call paths, callers and impact stopped short of it. Re-index React projects after upgrading.
|
||||
- In React, a component that a file declares itself as a value is now what that file's JSX renders, whatever else in the repository shares its name. A lazily loaded page, like `const RegisterPage = Loadable(lazy(() => import('./pages/Register')))`, a `dynamic(() => import(…))` component or one loaded only in development (`IS_DEV ? lazy(…) : null`), links to the component its module exports, including the one its loader picks and one an `export default observer(Login)` wraps; a wrapped component like `const Settings = observer(function Settings() {…})`, `React.memo(Rows, areEqual)` or `forwardRef(ComboboxInner) as {…}` links to the function it wraps; and an alias like `const DialogPortal = DialogPrimitive.Portal` links to its own declaration. Before, `<RegisterPage />` was linked to any lone component of that name elsewhere, often in another app of the same repository, so `codegraph_explore`, callers and impact followed it into the wrong app while the page it really renders had no caller. React Router layouts declared this way follow the same rule, and a TSX type named like a value the file declares now stays in that file. Re-index React projects after upgrading.
|
||||
- In React, a JSX tag imported from one of your own modules now links to the component that module exports under that name, read the way a lazily loaded page's module already is. That includes going through an `index` file or barrel that passes the component on, like outline's `Sidebar/index.ts` with `import Sidebar from './App'; export default Sidebar` or its `Avatar/index.ts` with `const Avatar = AvatarWithHoverCard`, and past a wrapper such as `export default observer(AppSidebar)`, `React.memo(StaticCanvas, areEqual)` or mastodon's `const MemoizedBlurhash = memo(Blurhash)`. Before, a tag was matched by its name and the import only broke ties, so outline's `<Sidebar>` linked to an unrelated styled `Sidebar` in a sibling file, a default import under another name linked to whatever other component its module exported, and an import through a barrel could land on another app's component of the same name. A tag whose module exports an unnamed component, like `export default () => …`, now links to nothing instead of to a namesake elsewhere. A lazily loaded React Router page exported as such a value or wrapper links to the component it holds too, the one with the markup. `codegraph sync` and the file watcher redraw these links when only the file that passes the component on changes. Re-index React projects after upgrading.
|
||||
- Git sync hooks now refresh the project they were installed for, so a project inside a larger repository stays current after commit, pull and checkout; installing them no longer breaks an existing hook written in another language, and runs even when your own hook ends with `exit`.
|
||||
- In TypeScript and JavaScript, a call through a field the calling class inherits, like `this._store.add(…)` in a class whose base class declares `_store`, now links to the method of the field's class, with the base class found the way the subclass's file imports it, under its own name, an alias or a re-export. Before, such a call linked only when some other class with the calling class's name happened to declare the field, which could be another app's class: that last resort now needs a type the calling file imports or declares. A call on a field or variable whose type comes from a package outside your project, like `TelemetryReporter` from `@vscode/extension-telemetry`, no longer links to a project class that only shares the type's name, unless a file in your project declares that package (`declare module '…'`). Re-index TypeScript and JavaScript projects after upgrading.
|
||||
- In TypeScript and JavaScript, a call on a variable, parameter or field whose class the file imports, like `store.add(…)` with `store: DisposableStore`, now links to the method of the class that import names when other classes in the project share its name, such as a vendored copy, a test mock or another app's class. Before, it linked to whichever of them was indexed first, or to the one in the nearest folder: in vscode, calls on classes imported from vscode's own `vs/base` and `vs/platform` reached the copies the Copilot extension vendors or its test fixtures, in bitwarden the desktop app's `initService.init()` reached the browser extension's `InitService`, and in react-native `Keyboard` and `WebSocket` reached the Jest mock of `NativeEventEmitter`. A call whose first match was a test file's class, which then linked to nothing, now reaches the imported class too. The import is followed through re-exports, `import type`, namespace imports like `new async.Delayer()` and `require(…)`. Re-index TypeScript and JavaScript projects after upgrading.
|
||||
- In TypeScript and JavaScript, a call on a value another file exports, like `store.add(…)` after `import { store } from './state'` with `export const store = new Store()` there, now links to the method of the class that file imports or declares when other classes in the project share its name, in Vue, Svelte and Astro components too. Before, it linked to whichever of them was indexed first: in vscode, calls on `FileAccess`, `extUri`, `errorHandler` and `MenuRegistry` reached the copies the Copilot extension vendors or one of its test fixtures, and in folo the mobile app's `settingSyncQueue.init()` reached the desktop app's `SettingSyncQueue`. Re-index TypeScript and JavaScript projects after upgrading.
|
||||
|
||||
@@ -127,3 +127,146 @@ describe('git sync hooks', () => {
|
||||
expect(isSyncHookInstalled(repo)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Run a hook the way git does, with a fake `codegraph` first on PATH that
|
||||
* records its argv and cwd. The hook backgrounds the call, so poll for the log.
|
||||
*/
|
||||
function runHookWithFakeCodegraph(hookFile: string, cwd: string): string[] {
|
||||
const bin = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-fakebin-'));
|
||||
const log = path.join(bin, 'calls.log');
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
path.join(bin, 'codegraph'),
|
||||
`#!/bin/sh\necho "cwd=$(pwd) args=$*" >> '${log}'\n`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
try {
|
||||
execFileSync(hookFile, [], {
|
||||
cwd,
|
||||
stdio: 'ignore',
|
||||
env: { ...process.env, PATH: `${bin}${path.delimiter}${process.env.PATH ?? ''}` },
|
||||
});
|
||||
} catch {
|
||||
/* a hook that exits non-zero still ran; the log says what it did */
|
||||
}
|
||||
const deadline = Date.now() + 3000;
|
||||
while (!fs.existsSync(log) && Date.now() < deadline) {
|
||||
execFileSync('sleep', ['0.05']);
|
||||
}
|
||||
return fs.existsSync(log) ? fs.readFileSync(log, 'utf8').trim().split('\n') : [];
|
||||
} finally {
|
||||
fs.rmSync(bin, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe('git sync hooks — project roots and existing hooks', () => {
|
||||
let repo: string;
|
||||
|
||||
beforeEach(() => {
|
||||
repo = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-githooks-')));
|
||||
gitInit(repo);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (fs.existsSync(repo)) fs.rmSync(repo, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const hookFile = (): string => path.join(repo, '.git', 'hooks', 'post-commit');
|
||||
|
||||
it.runIf(process.platform !== 'win32')('syncs the sub-project it was installed for, not the repo top level', () => {
|
||||
const sub = path.join(repo, 'packages', 'app');
|
||||
fs.mkdirSync(sub, { recursive: true });
|
||||
|
||||
installGitSyncHook(sub, ['post-commit']);
|
||||
|
||||
// git runs hooks from the top of the work tree
|
||||
const calls = runHookWithFakeCodegraph(hookFile(), repo);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]).toContain(`args=sync ${sub}`);
|
||||
});
|
||||
|
||||
it('keeps one block per project root, and removes only its own', () => {
|
||||
const a = path.join(repo, 'a');
|
||||
const b = path.join(repo, 'b');
|
||||
fs.mkdirSync(a);
|
||||
fs.mkdirSync(b);
|
||||
|
||||
installGitSyncHook(a, ['post-commit']);
|
||||
installGitSyncHook(b, ['post-commit']);
|
||||
installGitSyncHook(a, ['post-commit']); // re-install stays idempotent per root
|
||||
|
||||
const body = fs.readFileSync(hookFile(), 'utf8');
|
||||
expect(body.split('# >>> codegraph sync hook >>>').length - 1).toBe(2);
|
||||
expect(isSyncHookInstalled(a, ['post-commit'])).toBe(true);
|
||||
expect(isSyncHookInstalled(b, ['post-commit'])).toBe(true);
|
||||
|
||||
removeGitSyncHook(a, ['post-commit']);
|
||||
expect(isSyncHookInstalled(a, ['post-commit'])).toBe(false);
|
||||
expect(isSyncHookInstalled(b, ['post-commit'])).toBe(true);
|
||||
});
|
||||
|
||||
it('does not report a hook installed for another project root', () => {
|
||||
const a = path.join(repo, 'a');
|
||||
const b = path.join(repo, 'b');
|
||||
fs.mkdirSync(a);
|
||||
fs.mkdirSync(b);
|
||||
|
||||
installGitSyncHook(a, ['post-commit']);
|
||||
expect(isSyncHookInstalled(b, ['post-commit'])).toBe(false);
|
||||
});
|
||||
|
||||
it('treats a block from an older install as the top-level project\'s', () => {
|
||||
const legacy = [
|
||||
'#!/bin/sh',
|
||||
'# >>> codegraph sync hook >>>',
|
||||
'if command -v codegraph >/dev/null 2>&1; then',
|
||||
' ( codegraph sync >/dev/null 2>&1 & ) >/dev/null 2>&1',
|
||||
'fi',
|
||||
'# <<< codegraph sync hook <<<',
|
||||
'',
|
||||
].join('\n');
|
||||
fs.writeFileSync(hookFile(), legacy, { mode: 0o755 });
|
||||
const sub = path.join(repo, 'sub');
|
||||
fs.mkdirSync(sub);
|
||||
|
||||
expect(isSyncHookInstalled(repo, ['post-commit'])).toBe(true);
|
||||
expect(isSyncHookInstalled(sub, ['post-commit'])).toBe(false);
|
||||
|
||||
// Re-installing for the top level replaces the old block rather than adding one.
|
||||
installGitSyncHook(repo, ['post-commit']);
|
||||
const body = fs.readFileSync(hookFile(), 'utf8');
|
||||
expect(body.split('# >>> codegraph sync hook >>>').length - 1).toBe(1);
|
||||
});
|
||||
|
||||
it('leaves a hook written in another language untouched', () => {
|
||||
const python = '#!/usr/bin/env python3\nprint("my hook")\n';
|
||||
fs.writeFileSync(hookFile(), python, { mode: 0o755 });
|
||||
|
||||
const result = installGitSyncHook(repo, ['post-commit']);
|
||||
|
||||
expect(fs.readFileSync(hookFile(), 'utf8')).toBe(python);
|
||||
expect(result.installed).toEqual([]);
|
||||
expect(result.unsupported).toEqual(['post-commit']);
|
||||
expect(isSyncHookInstalled(repo, ['post-commit'])).toBe(false);
|
||||
});
|
||||
|
||||
it('installs nothing for a project path with a line break, which would end the root comment', () => {
|
||||
const result = installGitSyncHook(path.join(repo, 'app\ntouch pwned'), ['post-commit']);
|
||||
|
||||
expect(result.installed).toEqual([]);
|
||||
expect(result.skipped).toBe('the project path contains a line break');
|
||||
expect(fs.existsSync(hookFile())).toBe(false);
|
||||
});
|
||||
|
||||
it.runIf(process.platform !== 'win32')('still syncs when the existing shell hook ends with exit', () => {
|
||||
fs.writeFileSync(hookFile(), '#!/bin/sh\necho "my hook"\nexit 0\n', { mode: 0o755 });
|
||||
|
||||
installGitSyncHook(repo, ['post-commit']);
|
||||
|
||||
const body = fs.readFileSync(hookFile(), 'utf8');
|
||||
expect(body).toContain('echo "my hook"');
|
||||
expect(body.startsWith('#!/bin/sh\n')).toBe(true);
|
||||
expect(runHookWithFakeCodegraph(hookFile(), repo)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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