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
2 changes: 2 additions & 0 deletions bin/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ doc
.option('--strategy <strategy>', 'Existing docs: improve, rewrite, skip (skips interactive prompt)')
.option('--domains <domains>', 'Additional domains to include (comma-separated, e.g., "backtest,advisory")')
.option('--model <model>', 'Claude model to use (e.g., sonnet, opus, haiku)')
.option('--no-hook', 'Skip post-commit hook prompt')
.option('--verbose', 'Show what Claude is reading/doing in real time')
.action(docInitCommand);

Expand All @@ -108,6 +109,7 @@ doc
.argument('[path]', 'Path to repo', '.')
.option('--commits <n>', 'Number of commits to analyze', '1')
.option('--install-hook', 'Install git post-commit hook')
.option('--remove-hook', 'Remove git post-commit hook')
.option('--dry-run', 'Preview without writing files')
.option('--timeout <seconds>', 'Claude timeout in seconds', '300')
.option('--model <model>', 'Claude model to use (e.g., sonnet, opus, haiku)')
Expand Down
19 changes: 0 additions & 19 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 19 additions & 1 deletion src/commands/doc-init.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { resolve, join } from 'path';
import { existsSync } from 'fs';
import { existsSync, readFileSync } from 'fs';
import pc from 'picocolors';
import * as p from '@clack/prompts';
import { scanRepo } from '../lib/scanner.js';
import { buildRepoGraph } from '../lib/graph-builder.js';
import { runClaude, loadPrompt, parseFileOutput } from '../lib/runner.js';
import { writeSkillFiles } from '../lib/skill-writer.js';
import { installGitHook } from './doc-sync.js';

// Read-only tools — Claude explores the repo itself
const READ_ONLY_TOOLS = ['Read', 'Glob', 'Grep'];
Expand Down Expand Up @@ -363,6 +364,23 @@ export async function docInitCommand(path, options) {

showTokenSummary(startTime);

// Offer auto-sync hook
if (options.hook !== false && !options.dryRun && existsSync(join(repoPath, '.git'))) {
const hookPath = join(repoPath, '.git', 'hooks', 'post-commit');
const hookInstalled = existsSync(hookPath) &&
readFileSync(hookPath, 'utf8').includes('aspens doc');
if (!hookInstalled) {
console.log();
const wantHook = await p.confirm({
message: 'Install post-commit hook to keep docs in sync automatically?',
initialValue: true,
});
if (!p.isCancel(wantHook) && wantHook) {
installGitHook(repoPath);
}
}
}

console.log();
p.outro(
`${pc.green(`${created} created`)}` +
Expand Down
118 changes: 90 additions & 28 deletions src/commands/doc-sync.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { resolve, join, relative } from 'path';
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync } from 'fs';
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'fs';
import { execSync } from 'child_process';
import pc from 'picocolors';
import * as p from '@clack/prompts';
Expand All @@ -15,10 +15,13 @@ export async function docSyncCommand(path, options) {
const verbose = !!options.verbose;
const commits = parseInt(options.commits) || 1;

// Install hook mode
// Install/remove hook mode
if (options.installHook) {
return installGitHook(repoPath);
}
if (options.removeHook) {
return removeGitHook(repoPath);
}

p.intro(pc.cyan('aspens doc sync'));

Expand Down Expand Up @@ -318,7 +321,18 @@ function mapChangesToSkills(changedFiles, existingSkills, scan) {

// --- Git hook ---

function installGitHook(repoPath) {
function resolveAspensPath() {
try {
const resolved = execSync('which aspens', {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
}).trim();
if (resolved && existsSync(resolved)) return resolved;
} catch { /* not in PATH */ }
return 'npx aspens';
}

export function installGitHook(repoPath) {
const hookDir = join(repoPath, '.git', 'hooks');
const hookPath = join(hookDir, 'post-commit');

Expand All @@ -329,48 +343,96 @@ function installGitHook(repoPath) {

mkdirSync(hookDir, { recursive: true });

const hookCommand = `
# aspens doc sync — auto-update skills after commit
# Installed by: aspens doc sync --install-hook

# Cooldown: skip if last sync was less than 5 minutes ago
ASPENS_LOCK="/tmp/aspens-sync-\$(git rev-parse --show-toplevel | shasum | cut -c1-8).lock"
if [ -f "\$ASPENS_LOCK" ]; then
LAST_RUN=\$(cat "\$ASPENS_LOCK" 2>/dev/null || echo 0)
NOW=\$(date +%s)
if [ \$((NOW - LAST_RUN)) -lt 300 ]; then
exit 0 # Too soon since last sync
const aspensCmd = resolveAspensPath();

const hookBlock = `
# >>> aspens doc-sync hook (do not edit) >>>
__aspens_doc_sync() {
REPO_ROOT="\$(git rev-parse --show-toplevel 2>/dev/null)" || return 0
REPO_HASH="\$(echo "\$REPO_ROOT" | shasum | cut -c1-8)"
ASPENS_LOCK="/tmp/aspens-sync-\${REPO_HASH}.lock"
ASPENS_LOG="/tmp/aspens-sync-\${REPO_HASH}.log"

# Cooldown: skip if last sync was less than 5 minutes ago
if [ -f "\$ASPENS_LOCK" ]; then
LAST_RUN=\$(cat "\$ASPENS_LOCK" 2>/dev/null || echo 0)
NOW=\$(date +%s)
if [ \$((NOW - LAST_RUN)) -lt 300 ]; then
return 0
fi
fi
fi
echo \$(date +%s) > "\$ASPENS_LOCK"
echo \$(date +%s) > "\$ASPENS_LOCK"

# Clean up stale lock files older than 1 hour
find /tmp -maxdepth 1 -name "aspens-sync-*.lock" -mmin +60 -delete 2>/dev/null
# Clean up stale lock files older than 1 hour
find /tmp -maxdepth 1 -name "aspens-sync-*.lock" -mmin +60 -delete 2>/dev/null

# Run in background so commit isn't blocked
npx aspens doc sync --commits 1 "\$(git rev-parse --show-toplevel)" &
`;
# Truncate log if over 200 lines
if [ -f "\$ASPENS_LOG" ] && [ "\$(wc -l < "\$ASPENS_LOG" 2>/dev/null || echo 0)" -gt 200 ]; then
tail -100 "\$ASPENS_LOG" > "\$ASPENS_LOG.tmp" && mv "\$ASPENS_LOG.tmp" "\$ASPENS_LOG"
fi

const hookFull = `#!/bin/sh${hookCommand}`;
# Run in background with logging
(echo "[sync] \$(date '+%Y-%m-%d %H:%M:%S') started" >> "\$ASPENS_LOG" && ${aspensCmd} doc sync --commits 1 "\$REPO_ROOT" >> "\$ASPENS_LOG" 2>&1; echo "[sync] \$(date '+%Y-%m-%d %H:%M:%S') finished (exit \$?)" >> "\$ASPENS_LOG") &
}
__aspens_doc_sync
# <<< aspens doc-sync hook <<<
`;

// Check for existing hook
if (existsSync(hookPath)) {
const existing = readFileSync(hookPath, 'utf8');
if (existing.includes('aspens doc sync')) {
if (existing.includes('aspens doc-sync hook') || existing.includes('aspens doc sync')) {
console.log(pc.yellow('\n Hook already installed.\n'));
return;
}
// Append command to existing hook (without shebang)
writeFileSync(hookPath, existing + '\n' + hookCommand, 'utf8');
console.log(pc.green('\n Appended aspens doc sync to existing post-commit hook.\n'));
// Append to existing hook (outside shebang)
writeFileSync(hookPath, existing + '\n' + hookBlock, 'utf8');
console.log(pc.green('\n Appended aspens doc-sync to existing post-commit hook.\n'));
} else {
writeFileSync(hookPath, hookFull, 'utf8');
writeFileSync(hookPath, '#!/bin/sh\n' + hookBlock, 'utf8');
execSync(`chmod +x "${hookPath}"`);
console.log(pc.green('\n Installed post-commit hook.\n'));
}

console.log(pc.dim(' Skills will auto-update after every commit.'));
console.log(pc.dim(' Remove with: rm .git/hooks/post-commit\n'));
console.log(pc.dim(' Log: /tmp/aspens-sync-*.log'));
console.log(pc.dim(' Remove with: aspens doc sync --remove-hook\n'));
}

export function removeGitHook(repoPath) {
const hookPath = join(repoPath, '.git', 'hooks', 'post-commit');

if (!existsSync(hookPath)) {
console.log(pc.yellow('\n No post-commit hook found.\n'));
return;
}

const content = readFileSync(hookPath, 'utf8');
const hasMarkers = content.includes('# >>> aspens doc-sync hook');
const hasLegacy = !hasMarkers && content.includes('aspens doc sync');

if (!hasMarkers && !hasLegacy) {
console.log(pc.yellow('\n Post-commit hook does not contain aspens.\n'));
return;
}

if (hasMarkers) {
const cleaned = content
.replace(/\n?# >>> aspens doc-sync hook \(do not edit\) >>>[\s\S]*?# <<< aspens doc-sync hook <<<\n?/, '')
.trim();

if (!cleaned || cleaned === '#!/bin/sh') {
unlinkSync(hookPath);
console.log(pc.green('\n Removed post-commit hook.\n'));
} else {
writeFileSync(hookPath, cleaned + '\n', 'utf8');
console.log(pc.green('\n Removed aspens doc-sync from post-commit hook.\n'));
}
} else {
console.log(pc.yellow('\n Legacy aspens hook detected (no removal markers).'));
console.log(pc.dim(' Re-install first: aspens doc sync --install-hook'));
console.log(pc.dim(' Or edit manually: .git/hooks/post-commit\n'));
}
}

// --- Helpers ---
Expand Down
105 changes: 105 additions & 0 deletions tests/git-hook.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { describe, it, expect, beforeEach, afterAll } from 'vitest';
import { existsSync, readFileSync, rmSync, mkdirSync, writeFileSync, statSync } from 'fs';
import { join } from 'path';
import { installGitHook, removeGitHook } from '../src/commands/doc-sync.js';

const TEST_DIR = join(import.meta.dirname, 'tmp-hook');
const HOOKS_DIR = join(TEST_DIR, '.git', 'hooks');
const HOOK_PATH = join(HOOKS_DIR, 'post-commit');

beforeEach(() => {
if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true });
mkdirSync(HOOKS_DIR, { recursive: true });
});

afterAll(() => {
if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true });
});

describe('installGitHook', () => {
it('creates post-commit hook with shebang and markers', () => {
installGitHook(TEST_DIR);
const content = readFileSync(HOOK_PATH, 'utf8');
expect(content).toContain('#!/bin/sh');
expect(content).toContain('# >>> aspens doc-sync hook (do not edit) >>>');
expect(content).toContain('__aspens_doc_sync()');
expect(content).toContain('# <<< aspens doc-sync hook <<<');
});

it('makes hook executable', () => {
installGitHook(TEST_DIR);
const mode = statSync(HOOK_PATH).mode;
expect(mode & 0o111).toBeGreaterThan(0);
});

it('uses return 0 instead of exit 0 in cooldown', () => {
installGitHook(TEST_DIR);
const content = readFileSync(HOOK_PATH, 'utf8');
expect(content).toContain('return 0');
expect(content).not.toContain('exit 0');
});

it('includes logging to a file', () => {
installGitHook(TEST_DIR);
const content = readFileSync(HOOK_PATH, 'utf8');
expect(content).toContain('ASPENS_LOG=');
expect(content).toContain('>> "$ASPENS_LOG"');
});

it('is idempotent — skips if already installed', () => {
installGitHook(TEST_DIR);
const first = readFileSync(HOOK_PATH, 'utf8');
installGitHook(TEST_DIR);
const second = readFileSync(HOOK_PATH, 'utf8');
expect(second).toBe(first);
});

it('appends to existing hook without replacing shebang', () => {
writeFileSync(HOOK_PATH, '#!/bin/sh\necho "other hook"\n', 'utf8');
installGitHook(TEST_DIR);
const content = readFileSync(HOOK_PATH, 'utf8');
expect(content).toContain('echo "other hook"');
expect(content).toContain('# >>> aspens doc-sync hook');
// Only one shebang
expect(content.match(/^#!\/bin\/sh/gm)).toHaveLength(1);
});
});

describe('removeGitHook', () => {
it('removes hook file when it only contains aspens block', () => {
installGitHook(TEST_DIR);
expect(existsSync(HOOK_PATH)).toBe(true);
removeGitHook(TEST_DIR);
expect(existsSync(HOOK_PATH)).toBe(false);
});

it('preserves other hook content when removing aspens block', () => {
writeFileSync(HOOK_PATH, '#!/bin/sh\necho "other hook"\n', 'utf8');
installGitHook(TEST_DIR);
removeGitHook(TEST_DIR);
expect(existsSync(HOOK_PATH)).toBe(true);
const content = readFileSync(HOOK_PATH, 'utf8');
expect(content).toContain('echo "other hook"');
expect(content).not.toContain('aspens doc-sync hook');
});

it('handles missing hook file gracefully', () => {
// Should not throw
removeGitHook(TEST_DIR);
});

it('handles hook without aspens content gracefully', () => {
writeFileSync(HOOK_PATH, '#!/bin/sh\necho "unrelated"\n', 'utf8');
// Should not throw or modify
removeGitHook(TEST_DIR);
const content = readFileSync(HOOK_PATH, 'utf8');
expect(content).toContain('echo "unrelated"');
});

it('detects legacy hooks without markers', () => {
writeFileSync(HOOK_PATH, '#!/bin/sh\nnpx aspens doc sync --commits 1\n', 'utf8');
// Should not delete — warns about legacy format
removeGitHook(TEST_DIR);
expect(existsSync(HOOK_PATH)).toBe(true);
});
});
Loading