From cbf4b9dea44f953da572e6d74dfb4e078d9dd1c4 Mon Sep 17 00:00:00 2001 From: James Ross Date: Tue, 24 Feb 2026 15:08:56 -0800 Subject: [PATCH 1/2] ci: add gitmind-suggest workflow for PR review dogfooding (#293) Wires up the composite action (action.yml) to run on every PR against main, using Claude CLI as the suggestion agent. Includes guards for draft PRs, bot authors, and missing ANTHROPIC_API_KEY. Non-blocking via continue-on-error so suggest failures never prevent merge. --- .github/workflows/gitmind-suggest.yml | 64 +++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 .github/workflows/gitmind-suggest.yml diff --git a/.github/workflows/gitmind-suggest.yml b/.github/workflows/gitmind-suggest.yml new file mode 100644 index 00000000..ea32b74a --- /dev/null +++ b/.github/workflows/gitmind-suggest.yml @@ -0,0 +1,64 @@ +name: git-mind Suggest + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + branches: [main] + +concurrency: + group: gitmind-suggest-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + +jobs: + suggest: + if: > + !github.event.pull_request.draft && + github.actor != 'dependabot[bot]' + runs-on: ubuntu-latest + continue-on-error: true + + steps: + - name: Check for API key + id: check-key + env: + HAS_KEY: ${{ secrets.ANTHROPIC_API_KEY != '' }} + run: | + if [ "$HAS_KEY" != "true" ]; then + echo "::notice::ANTHROPIC_API_KEY not configured — skipping suggest" + echo "skip=true" >> "$GITHUB_OUTPUT" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + + - name: Checkout + if: steps.check-key.outputs.skip != 'true' + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + if: steps.check-key.outputs.skip != 'true' + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + + - name: Install dependencies + if: steps.check-key.outputs.skip != 'true' + run: npm ci + + - name: Install Claude CLI + if: steps.check-key.outputs.skip != 'true' + run: npm install -g @anthropic-ai/claude-code + + - name: Run git-mind suggest + if: steps.check-key.outputs.skip != 'true' + uses: ./ + with: + agent: 'claude -p --output-format json' + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} From 561c33bfbaf1b8e8ca6b7e33058630ca50a136f4 Mon Sep 17 00:00:00 2001 From: James Ross Date: Tue, 24 Feb 2026 21:34:03 -0800 Subject: [PATCH 2/2] feat: replace post-commit hook with pre-push hook for directives + suggest (#293) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-commit fires on every rebase replay, creating orphan edges. Pre-push runs once before commits leave the machine — processes directives via git rev-list and runs suggest when GITMIND_AGENT is set. Always exits 0. --- bin/git-mind.js | 2 +- src/cli/commands.js | 52 ++++++++++++++++++++++------------ test/hooks.test.js | 68 ++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 102 insertions(+), 20 deletions(-) diff --git a/bin/git-mind.js b/bin/git-mind.js index f76b6ce1..0b32353b 100755 --- a/bin/git-mind.js +++ b/bin/git-mind.js @@ -74,7 +74,7 @@ Commands: --repo-name Override detected repo identifier --dry-run Preview without writing --json Output as JSON - install-hooks Install post-commit Git hook + install-hooks Install pre-push Git hook (directives + suggest) doctor Run graph integrity checks --fix Auto-fix dangling edges --at Time-travel: check graph as-of a git ref diff --git a/src/cli/commands.js b/src/cli/commands.js index 0fbdccd8..0df37f3b 100644 --- a/src/cli/commands.js +++ b/src/cli/commands.js @@ -246,28 +246,44 @@ export async function remove(cwd, source, target, opts = {}) { } } -/** - * Install a post-commit Git hook that processes directives. - * @param {string} cwd - */ -export async function installHooks(cwd) { - const hookPath = join(cwd, '.git', 'hooks', 'post-commit'); +const PRE_PUSH_SCRIPT = `#!/bin/sh +# git-mind pre-push hook +# Processes commit directives and runs suggest on commits about to be pushed - const hookScript = `#!/bin/sh -# git-mind post-commit hook -# Parses commit directives and creates edges automatically +command -v npx >/dev/null 2>&1 || exit 0 -SHA=$(git rev-parse HEAD) -MSG=$(git log -1 --format=%B "$SHA") +ZERO="0000000000000000000000000000000000000000" -# Only run if git-mind is available -command -v npx >/dev/null 2>&1 || exit 0 +while read local_ref local_sha remote_ref remote_sha; do + [ "$local_sha" = "$ZERO" ] && continue # deleting ref + if [ "$remote_sha" = "$ZERO" ]; then + RANGE="HEAD~10..HEAD" # new branch — use last 10 + else + RANGE="\${remote_sha}..\${local_sha}" + fi + + # Process commit directives for each commit in the range + for SHA in $(git rev-list "$RANGE" 2>/dev/null); do + npx git-mind process-commit "$SHA" 2>/dev/null || true + done -npx git-mind process-commit "$SHA" 2>/dev/null || true + # Run suggest if an agent is configured + if [ -n "$GITMIND_AGENT" ]; then + npx git-mind suggest --context "$RANGE" 2>/dev/null || true + fi +done + +exit 0 `; +/** + * Install pre-push Git hook (directives + suggest). + * @param {string} cwd + */ +export async function installHooks(cwd) { + const hookPath = join(cwd, '.git', 'hooks', 'pre-push'); + try { - // Check if a hook already exists let exists = false; try { await access(hookPath, constants.F_OK); @@ -275,15 +291,15 @@ npx git-mind process-commit "$SHA" 2>/dev/null || true } catch { /* doesn't exist */ } if (exists) { - console.error(error(`Post-commit hook already exists at ${hookPath}`)); + console.error(error(`pre-push hook already exists at ${hookPath}`)); console.error(info('Remove it manually or append git-mind to the existing hook')); process.exitCode = 1; return; } - await writeFile(hookPath, hookScript); + await writeFile(hookPath, PRE_PUSH_SCRIPT); await chmod(hookPath, 0o755); - console.log(success('Installed post-commit hook')); + console.log(success('Installed pre-push hook')); } catch (err) { console.error(error(`Failed to install hook: ${err.message}`)); process.exitCode = 1; diff --git a/test/hooks.test.js b/test/hooks.test.js index 9e3a9239..f9b31892 100644 --- a/test/hooks.test.js +++ b/test/hooks.test.js @@ -1,11 +1,12 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdtemp, rm } from 'node:fs/promises'; +import { mkdtemp, rm, readFile, stat, writeFile, mkdir } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { execSync } from 'node:child_process'; import { initGraph } from '../src/graph.js'; import { queryEdges } from '../src/edges.js'; import { parseDirectives, processCommit } from '../src/hooks.js'; +import { installHooks } from '../src/cli/commands.js'; describe('hooks', () => { describe('parseDirectives', () => { @@ -97,4 +98,69 @@ RELATES-TO: module:session`; expect(edges.length).toBe(0); }); }); + + describe('installHooks', () => { + let tempDir; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'gitmind-test-')); + execSync('git init', { cwd: tempDir, stdio: 'ignore' }); + await mkdir(join(tempDir, '.git', 'hooks'), { recursive: true }); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it('creates pre-push hook', async () => { + await installHooks(tempDir); + + const prePush = await readFile(join(tempDir, '.git', 'hooks', 'pre-push'), 'utf-8'); + expect(prePush).toContain('git-mind suggest'); + expect(prePush).toContain('process-commit'); + }); + + it('makes hook executable', async () => { + await installHooks(tempDir); + + const prePushStat = await stat(join(tempDir, '.git', 'hooks', 'pre-push')); + expect(prePushStat.mode & 0o111).toBeTruthy(); + }); + + it('does not overwrite existing hook', async () => { + const existingContent = '#!/bin/sh\necho "existing hook"'; + await writeFile(join(tempDir, '.git', 'hooks', 'pre-push'), existingContent); + + await installHooks(tempDir); + + const prePush = await readFile(join(tempDir, '.git', 'hooks', 'pre-push'), 'utf-8'); + expect(prePush).toBe(existingContent); + }); + + it('hook runs suggest only when GITMIND_AGENT is set', async () => { + await installHooks(tempDir); + + const prePush = await readFile(join(tempDir, '.git', 'hooks', 'pre-push'), 'utf-8'); + expect(prePush).toContain('GITMIND_AGENT'); + expect(prePush).toMatch(/if \[ -n "\$GITMIND_AGENT" \]/); + }); + + it('hook always exits 0', async () => { + await installHooks(tempDir); + + const prePush = await readFile(join(tempDir, '.git', 'hooks', 'pre-push'), 'utf-8'); + expect(prePush).toContain('exit 0'); + expect(prePush).toContain('|| true'); + }); + + it('hook processes directives unconditionally', async () => { + await installHooks(tempDir); + + const prePush = await readFile(join(tempDir, '.git', 'hooks', 'pre-push'), 'utf-8'); + // process-commit runs outside the GITMIND_AGENT guard + const agentGuardIndex = prePush.indexOf('if [ -n "$GITMIND_AGENT" ]'); + const processCommitIndex = prePush.indexOf('process-commit'); + expect(processCommitIndex).toBeLessThan(agentGuardIndex); + }); + }); });