diff --git a/bin/cli.js b/bin/cli.js index 60be4c3..eb9b751 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -99,6 +99,7 @@ doc .option('--strategy ', 'Existing docs: improve, rewrite, skip (skips interactive prompt)') .option('--domains ', 'Additional domains to include (comma-separated, e.g., "backtest,advisory")') .option('--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); @@ -108,6 +109,7 @@ doc .argument('[path]', 'Path to repo', '.') .option('--commits ', '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 ', 'Claude timeout in seconds', '300') .option('--model ', 'Claude model to use (e.g., sonnet, opus, haiku)') diff --git a/package-lock.json b/package-lock.json index 57e196d..6b9de7b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,6 @@ "license": "MIT", "dependencies": { "@clack/prompts": "^0.8.0", - "aspens": "^0.2.0", "commander": "^12.0.0", "es-module-lexer": "^2.0.0", "picocolors": "^1.1.0" @@ -542,24 +541,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/aspens": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/aspens/-/aspens-0.2.0.tgz", - "integrity": "sha512-Q2KIsyHtzMS7aAElWcKgQ3WdwNA8EAJZnG9UfsVQxtPx92vvM68VZjmiHVmlUMiqDjE1RppKriGKYWEqIpevPw==", - "license": "MIT", - "dependencies": { - "@clack/prompts": "^0.8.0", - "commander": "^12.0.0", - "es-module-lexer": "^2.0.0", - "picocolors": "^1.1.0" - }, - "bin": { - "aspens": "bin/cli.js" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", diff --git a/src/commands/doc-init.js b/src/commands/doc-init.js index 99b766f..45535f9 100644 --- a/src/commands/doc-init.js +++ b/src/commands/doc-init.js @@ -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']; @@ -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`)}` + diff --git a/src/commands/doc-sync.js b/src/commands/doc-sync.js index 25c6544..654e0ee 100644 --- a/src/commands/doc-sync.js +++ b/src/commands/doc-sync.js @@ -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'; @@ -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')); @@ -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'); @@ -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 --- diff --git a/tests/git-hook.test.js b/tests/git-hook.test.js new file mode 100644 index 0000000..2ec8519 --- /dev/null +++ b/tests/git-hook.test.js @@ -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); + }); +});