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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ fabric.properties
node_modules/
dist/
*.tgz
releases/

# Environment
.env
Expand Down
3 changes: 1 addition & 2 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,12 +106,11 @@ The MCP server (`git-mem-mcp`) exposes git-mem to AI tools over stdio:

```bash
git mem init
git mem init -y --commit-count 50
git mem init -y
```

Options:
- `-y, --yes` — Accept defaults without prompting
- `--commit-count <n>` — Number of commits to extract (default: 100)
- `--hooks` — Install prepare-commit-msg git hook
- `--uninstall-hooks` — Remove the prepare-commit-msg git hook

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
"pre-commit": "npm run type-check && npm run lint",
"test": "node -e \"const {globSync}=require('glob');const {spawnSync}=require('child_process');const files=globSync('tests/**/*.test.ts');if(!files.length){console.log('No test files found');process.exit(0);}const r=spawnSync('node',['--import','tsx','--test',...files],{stdio:'inherit'});process.exit(r.status===0||r.status===2?0:r.status)\"",
"test:unit": "node -e \"const {globSync}=require('glob');const {spawnSync}=require('child_process');const files=globSync('tests/unit/**/*.test.ts');if(!files.length){console.log('No test files found');process.exit(0);}const r=spawnSync('node',['--import','tsx','--test',...files],{stdio:'inherit'});process.exit(r.status===0||r.status===2?0:r.status)\"",
"test:integration": "node -e \"const {globSync}=require('glob');const {spawnSync}=require('child_process');const files=globSync('tests/integration/**/*.test.ts');if(!files.length){console.log('No test files found');process.exit(0);}const r=spawnSync('node',['--import','tsx','--test',...files],{stdio:'inherit'});process.exit(r.status===0||r.status===2?0:r.status)\""
"test:integration": "node -e \"const {globSync}=require('glob');const {spawnSync}=require('child_process');const files=globSync('tests/integration/**/*.test.ts');if(!files.length){console.log('No test files found');process.exit(0);}const r=spawnSync('node',['--import','tsx','--test',...files],{stdio:'inherit'});process.exit(r.status===0||r.status===2?0:r.status)\"",
"package": "node -e \"const {rmSync,mkdirSync,renameSync}=require('fs');const {globSync}=require('glob');globSync('*.tgz').forEach(f=>rmSync(f));globSync('releases/*.tgz').forEach(f=>rmSync(f));mkdirSync('releases',{recursive:true})\" && npm run build && npm pack && node -e \"const {renameSync}=require('fs');const {globSync}=require('glob');globSync('*.tgz').forEach(f=>renameSync(f,'releases/'+f))\""
},
"keywords": [
"git",
Expand Down
59 changes: 47 additions & 12 deletions scripts/reinstall-global.sh
Original file line number Diff line number Diff line change
@@ -1,19 +1,54 @@
#!/usr/bin/env bash
set -euo pipefail
#!/bin/bash
# Reinstall git-mem globally from local build
# Usage: ./scripts/reinstall-global.sh
#
# This script:
# 1. Builds the project and creates a .tgz package
# 2. Uninstalls existing global git-mem
# 3. Installs the new package globally
# 4. Verifies installation

cd "$(dirname "$0")/.."
set -e

echo "Building..."
npm run build
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
RELEASES_DIR="$PROJECT_ROOT/releases"

echo "Uninstalling git-mem globally..."
echo "=== git-mem Local Reinstall ==="
echo ""

# Step 1: Package (builds + creates tgz)
echo "1. Building and packaging..."
cd "$PROJECT_ROOT"
npm run package

# Find the latest .tgz file
PACKAGE_FILE=$(ls -t "$RELEASES_DIR"/*.tgz 2>/dev/null | head -1)
if [ -z "$PACKAGE_FILE" ]; then
echo "Error: No .tgz file found in $RELEASES_DIR"
exit 1
fi
Comment on lines +26 to +30
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Minor: ls parsing caveat acknowledged.

The static analysis tool (SC2012) suggests using find instead of ls for safer filename handling. However, since npm pack produces predictable filenames (git-mem-X.Y.Z.tgz), the ls -t approach is acceptable here. The error handling for missing packages is good.

If you want stricter compliance, consider:

♻️ Optional alternative using find
-PACKAGE_FILE=$(ls -t "$RELEASES_DIR"/*.tgz 2>/dev/null | head -1)
+PACKAGE_FILE=$(find "$RELEASES_DIR" -maxdepth 1 -name '*.tgz' -printf '%T@ %p\n' 2>/dev/null | sort -rn | head -1 | cut -d' ' -f2-)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
PACKAGE_FILE=$(ls -t "$RELEASES_DIR"/*.tgz 2>/dev/null | head -1)
if [ -z "$PACKAGE_FILE" ]; then
echo "Error: No .tgz file found in $RELEASES_DIR"
exit 1
fi
PACKAGE_FILE=$(find "$RELEASES_DIR" -maxdepth 1 -name '*.tgz' -printf '%T@ %p\n' 2>/dev/null | sort -rn | head -1 | cut -d' ' -f2-)
if [ -z "$PACKAGE_FILE" ]; then
echo "Error: No .tgz file found in $RELEASES_DIR"
exit 1
fi
🧰 Tools
🪛 Shellcheck (0.11.0)

[info] 26-26: Use find instead of ls to better handle non-alphanumeric filenames.

(SC2012)

🤖 Prompt for AI Agents
In `@scripts/reinstall-global.sh` around lines 26 - 30, Replace the fragile
ls-based assignment for PACKAGE_FILE with a safe find-based pipeline: use find
to list .tgz files in RELEASES_DIR (restrict to files, maxdepth 1), feed results
through a null-safe xargs/ls or use find's mtime output sorted to pick the
newest file, then assign that path to PACKAGE_FILE; update the logic around
PACKAGE_FILE and keep the existing error check intact (references: PACKAGE_FILE,
RELEASES_DIR, the current ls -t usage and the npm pack produced .tgz files).

echo " Package: $PACKAGE_FILE"

# Step 2: Uninstall existing
echo ""
echo "2. Uninstalling existing global git-mem..."
npm uninstall -g git-mem 2>/dev/null || true

echo "Installing git-mem globally from $(pwd)..."
npm install -g .
# Step 3: Install new package
echo ""
echo "3. Installing new package globally..."
npm install -g "$PACKAGE_FILE"

# Step 4: Verify installation
echo ""
echo "4. Verifying installation..."
INSTALLED_VERSION=$(git-mem --version 2>/dev/null || echo "unknown")
echo " Installed version: $INSTALLED_VERSION"
echo " git-mem: $(which git-mem)"
echo " git-mem-mcp: $(which git-mem-mcp)"

echo ""
echo "Installed:"
git-mem --version
which git-mem
which git-mem-mcp
echo "=== Done ==="
echo ""
echo "git-mem $INSTALLED_VERSION installed globally from local build."
9 changes: 5 additions & 4 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,12 @@ program

program
.command('init')
.description('Set up git-mem: hooks, MCP config, .gitignore, and extract from history')
.description('Set up git-mem: hooks, MCP config, .gitignore')
.option('-y, --yes', 'Accept defaults without prompting')
.option('--commit-count <n>', 'Number of commits to extract', '100')
.option('--hooks', 'Install prepare-commit-msg git hook for AI-Agent trailers')
.option('--uninstall-hooks', 'Remove the prepare-commit-msg git hook')
.option('--extract', 'Extract knowledge from commit history (use with --yes)')
.option('--commit-count <n>', 'Number of commits to extract (default: 10)', parseInt)
.action((options) => initCommand(options, logger));

program
Expand All @@ -39,8 +40,8 @@ program
.option('--confidence <level>', 'Confidence: verified, high, medium, low', 'high')
.option('--lifecycle <tier>', 'Lifecycle: permanent, project, session', 'project')
.option('--tags <tags>', 'Comma-separated tags')
.option('--agent <name>', 'AI agent name (default: auto-detect from $GIT_MEM_AGENT / $CLAUDE_CODE)')
.option('--model <name>', 'AI model identifier (default: $GIT_MEM_MODEL)')
.option('--agent <name>', 'AI agent name (default: auto-detect from $GIT_MEM_AGENT / $CLAUDECODE)')
.option('--model <name>', 'AI model identifier (default: $GIT_MEM_MODEL / $ANTHROPIC_MODEL)')
.option('--no-trailers', 'Skip writing AI-* trailers to the commit message')
.action((text, options) => rememberCommand(text, options, logger));

Expand Down
78 changes: 50 additions & 28 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,10 @@ import { createStderrProgressHandler } from './progress';

interface IInitCommandOptions {
yes?: boolean;
commitCount?: string;
hooks?: boolean;
uninstallHooks?: boolean;
extract?: boolean;
commitCount?: number;
}

// ── Pure helpers (exported for testing) ──────────────────────────────
Expand Down Expand Up @@ -110,12 +111,12 @@ export function ensureEnvPlaceholder(cwd: string): void {

// ── Main command ─────────────────────────────────────────────────────

/** Run unified project setup: hooks, MCP config, .gitignore, .env, and optional extract. */
/** Run unified project setup: hooks, MCP config, .gitignore, and .env. */
export async function initCommand(options: IInitCommandOptions, logger?: ILogger): Promise<void> {
const log = logger?.child({ command: 'init' });
const cwd = process.cwd();

log?.info('Command invoked', { yes: options.yes, commitCount: options.commitCount, hooks: options.hooks, uninstallHooks: options.uninstallHooks });
log?.info('Command invoked', { yes: options.yes, hooks: options.hooks, uninstallHooks: options.uninstallHooks });

// ── Git hook uninstall (early exit) ─────────────────────────────
if (options.uninstallHooks) {
Expand All @@ -129,36 +130,50 @@ export async function initCommand(options: IInitCommandOptions, logger?: ILogger
}

// ── Prompts (skipped with --yes) ───────────────────────────────
let commitCount = options.commitCount ? parseInt(options.commitCount, 10) : 30;
if (!Number.isFinite(commitCount) || commitCount <= 0) {
console.log(`Invalid --commit-count value: "${options.commitCount}". Using default (30).`);
commitCount = 30;
}
let claudeIntegration = true;
let runExtract = options.extract ?? false;
let commitCount = options.commitCount ?? 10;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Validate commitCount (parseInt returns NaN for invalid input)
if (Number.isNaN(commitCount) || commitCount < 1) {
commitCount = 10;
}

if (!options.yes) {
const response = await prompts([
{
type: 'number',
name: 'commitCount',
message: 'How many commits to free?',
initial: commitCount,
},
{
type: 'confirm',
name: 'claudeIntegration',
message: 'Integrate with Claude Code?',
initial: true,
},
{
type: 'confirm',
name: 'runExtract',
message: 'Extract knowledge from commit history?',
initial: false,
},
{
type: (prev) => prev ? 'select' : null,
name: 'commitCount',
message: 'How many commits to extract?',
choices: [
{ title: '10', value: 10 },
{ title: '30', value: 30 },
{ title: '50', value: 50 },
],
initial: 0,
},
], {
onCancel: () => {
console.log('\nSetup cancelled.');
process.exit(0);
},
});

commitCount = response.commitCount ?? commitCount;
claudeIntegration = response.claudeIntegration ?? true;
runExtract = response.runExtract ?? false;
commitCount = response.commitCount ?? 10;
}

// ── Claude Code hooks ──────────────────────────────────────────
Expand Down Expand Up @@ -209,21 +224,28 @@ export async function initCommand(options: IInitCommandOptions, logger?: ILogger
ensureGitignoreEntries(cwd, ['.env', '.git-mem.json']);
console.log('✓ Updated .gitignore');

// ── API key check & extract ────────────────────────────────────
console.log('\nChecking for ANTHROPIC_API_KEY in .env...\n');
// ── .env placeholder ──────────────────────────────────────────
ensureEnvPlaceholder(cwd);
console.log('✓ Ensured ANTHROPIC_API_KEY placeholder in .env');

const apiKey = readEnvApiKey(cwd);
// ── Extract from history ─────────────────────────────────────
if (runExtract) {
const apiKey = readEnvApiKey(cwd);
const enrich = !!apiKey;

if (apiKey) {
process.env.ANTHROPIC_API_KEY = apiKey;
console.log(`Extracting knowledge from ${commitCount} commits with LLM enrichment...`);
if (enrich) {
process.env.ANTHROPIC_API_KEY = apiKey;
console.log(`\nExtracting knowledge from ${commitCount} commits with LLM enrichment...`);
} else {
console.log(`\nExtracting knowledge from ${commitCount} commits (heuristic only)...`);
}

Comment on lines +231 to 242
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider checking process.env.ANTHROPIC_API_KEY before reading from .env file.

The current implementation only reads from .env file (via readEnvApiKey), but users might have ANTHROPIC_API_KEY set in their shell environment. This could cause confusion if the environment variable is set but enrichment doesn't activate because .env is empty.

♻️ Proposed fix to check environment first
   // ── Extract from history ─────────────────────────────────────
   if (runExtract) {
-    const apiKey = readEnvApiKey(cwd);
+    const apiKey = process.env.ANTHROPIC_API_KEY || readEnvApiKey(cwd);
     const enrich = !!apiKey;

     if (enrich) {
-      process.env.ANTHROPIC_API_KEY = apiKey;
+      if (!process.env.ANTHROPIC_API_KEY) {
+        process.env.ANTHROPIC_API_KEY = apiKey;
+      }
       console.log(`\nExtracting knowledge from ${commitCount} commits with LLM enrichment...`);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// ── Extract from history ─────────────────────────────────────
if (runExtract) {
const apiKey = readEnvApiKey(cwd);
const enrich = !!apiKey;
if (apiKey) {
process.env.ANTHROPIC_API_KEY = apiKey;
console.log(`Extracting knowledge from ${commitCount} commits with LLM enrichment...`);
if (enrich) {
process.env.ANTHROPIC_API_KEY = apiKey;
console.log(`\nExtracting knowledge from ${commitCount} commits with LLM enrichment...`);
} else {
console.log(`\nExtracting knowledge from ${commitCount} commits (heuristic only)...`);
}
// ── Extract from history ─────────────────────────────────────
if (runExtract) {
const apiKey = process.env.ANTHROPIC_API_KEY || readEnvApiKey(cwd);
const enrich = !!apiKey;
if (enrich) {
if (!process.env.ANTHROPIC_API_KEY) {
process.env.ANTHROPIC_API_KEY = apiKey;
}
console.log(`\nExtracting knowledge from ${commitCount} commits with LLM enrichment...`);
} else {
console.log(`\nExtracting knowledge from ${commitCount} commits (heuristic only)...`);
}
🤖 Prompt for AI Agents
In `@src/commands/init.ts` around lines 231 - 242, The code only calls
readEnvApiKey(cwd) to get ANTHROPIC_API_KEY which ignores a key already set in
the user's shell; update the logic around runExtract to first check
process.env.ANTHROPIC_API_KEY and use that if present, otherwise call
readEnvApiKey(cwd) as a fallback, then set the enrich flag based on the
resulting apiKey and assign process.env.ANTHROPIC_API_KEY = apiKey when
enrichment is enabled; adjust references in the block using enrich and apiKey
(the runExtract branch, readEnvApiKey call, enrich variable and assignment to
process.env.ANTHROPIC_API_KEY) accordingly.

const container = createContainer({ logger, scope: 'init', enrich: true });
const container = createContainer({ logger, scope: 'init', enrich });
const { extractService } = container.cradle;

const result = await extractService.extract({
maxCommits: commitCount,
enrich: true,
enrich,
onProgress: createStderrProgressHandler(),
});

Expand All @@ -233,10 +255,10 @@ export async function initCommand(options: IInitCommandOptions, logger?: ILogger
`Facts: ${result.factsExtracted} | ` +
`Duration: ${result.durationMs}ms`,
);
} else {
ensureEnvPlaceholder(cwd);
console.log('✓ Added ANTHROPIC_API_KEY= to .env');
console.log('→ Add your key to .env, then run:');
console.log(` git-mem extract --enrich --commit-count ${commitCount}`);

if (!enrich) {
console.log('\nFor a deeper AI summary of each commit, add your ANTHROPIC_API_KEY to .env and run:');
console.log(' git-mem extract --enrich');
}
}
}
10 changes: 3 additions & 7 deletions src/commands/remember.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/

import { createContainer } from '../infrastructure/di';
import { resolveAgent, resolveModel } from '../infrastructure/detect-agent';
import type { MemoryType } from '../domain/entities/IMemoryEntity';
import type { ConfidenceLevel } from '../domain/types/IMemoryQuality';
import type { MemoryLifecycle } from '../domain/types/IMemoryLifecycle';
Expand All @@ -24,13 +25,8 @@ export async function rememberCommand(text: string, options: IRememberOptions, l
const { memoryService, logger: log } = container.cradle;
log.info('Command invoked', { type: options.type || 'fact' });

// Resolve agent: explicit flag > $GIT_MEM_AGENT > $CLAUDE_CODE heuristic
const agent = options.agent
|| process.env.GIT_MEM_AGENT
|| (process.env.CLAUDE_CODE ? 'Claude-Code' : undefined);

// Resolve model: explicit flag > $GIT_MEM_MODEL
const model = options.model || process.env.GIT_MEM_MODEL || undefined;
const agent = resolveAgent(options.agent);
const model = resolveModel(options.model);

const memory = memoryService.remember(text, {
sha: options.commit,
Expand Down
17 changes: 15 additions & 2 deletions src/hooks/prepare-commit-msg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
*
* Detection heuristics (checked in order):
* - $GIT_MEM_AGENT env var (explicit, user-defined agent string)
* - $CLAUDE_CODE env var (Claude Code session)
* - $CLAUDECODE env var (Claude Code session — includes version)
* - $CLAUDE_CODE env var (legacy fallback)
* - $GIT_MEM_MODEL env var (explicit, user-defined model string)
* - $ANTHROPIC_MODEL env var (set by Claude Code)
*
* The hook uses `git interpret-trailers` for proper formatting.
*/
Expand All @@ -20,7 +22,7 @@ import { execFileSync } from 'child_process';
const HOOK_FINGERPRINT_PREFIX = '# git-mem:prepare-commit-msg';

/** Full fingerprint with version — used for upgrade detection. */
const HOOK_FINGERPRINT = `${HOOK_FINGERPRINT_PREFIX} v2`;
const HOOK_FINGERPRINT = `${HOOK_FINGERPRINT_PREFIX} v3`;

/**
* The shell hook script.
Expand All @@ -42,6 +44,15 @@ esac
AGENT=""
if [ -n "$GIT_MEM_AGENT" ]; then
AGENT="$GIT_MEM_AGENT"
elif [ -n "$CLAUDECODE" ]; then
# Get Claude Code version for the agent string
CC_VERSION=""
CC_VERSION=$(CLAUDECODE= claude --version 2>/dev/null | head -1 | awk '{print $1}') || true
if [ -n "$CC_VERSION" ]; then
AGENT="Claude-Code/$CC_VERSION"
else
AGENT="Claude-Code"
fi
elif [ -n "$CLAUDE_CODE" ]; then
AGENT="Claude-Code"
fi
Expand All @@ -50,6 +61,8 @@ fi
MODEL=""
if [ -n "$GIT_MEM_MODEL" ]; then
MODEL="$GIT_MEM_MODEL"
elif [ -n "$ANTHROPIC_MODEL" ]; then
MODEL="$ANTHROPIC_MODEL"
fi

# No agent detected — exit silently
Expand Down
49 changes: 49 additions & 0 deletions src/infrastructure/detect-agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* Detect AI agent and model from environment variables.
*
* Agent detection chain: $GIT_MEM_AGENT > $CLAUDECODE (with version) > $CLAUDE_CODE
* Model detection chain: $GIT_MEM_MODEL > $ANTHROPIC_MODEL
*/

import { execFileSync } from 'child_process';

/**
* Detect Claude Code agent string including version.
* Returns e.g. "Claude-Code/2.1.41" or "Claude-Code" if version unavailable.
*/
export function detectClaudeAgent(): string {
try {
// Unset CLAUDECODE so `claude --version` doesn't refuse to run inside a session
const version = execFileSync('claude', ['--version'], {
encoding: 'utf8',
env: { ...process.env, CLAUDECODE: '' },
timeout: 3000,
stdio: ['pipe', 'pipe', 'pipe'],
}).trim().split(/\s+/)[0];
if (version) return `Claude-Code/${version}`;
} catch {
// claude binary not found or timed out — fall back
}
return 'Claude-Code';
}

/**
* Resolve the AI agent string from env vars.
* @param explicit Optional explicit value (from CLI flag or MCP param)
*/
export function resolveAgent(explicit?: string): string | undefined {
if (explicit) return explicit;
if (process.env.GIT_MEM_AGENT) return process.env.GIT_MEM_AGENT;
if (process.env.CLAUDECODE) return detectClaudeAgent();
if (process.env.CLAUDE_CODE) return 'Claude-Code';
return undefined;
}

/**
* Resolve the AI model string from env vars.
* @param explicit Optional explicit value (from CLI flag or MCP param)
*/
export function resolveModel(explicit?: string): string | undefined {
if (explicit) return explicit;
return process.env.GIT_MEM_MODEL || process.env.ANTHROPIC_MODEL || undefined;
}
Comment on lines +1 to +49
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new detect-agent.ts module introduces critical agent and model resolution logic used across CLI and MCP tools, but has no accompanying unit tests. Given that other infrastructure utilities in this codebase have comprehensive test coverage (as seen in tests/unit/infrastructure/), tests should be added for:

  1. resolveAgent() - test the priority chain: explicit > GIT_MEM_AGENT > CLAUDECODE > CLAUDE_CODE
  2. resolveModel() - test the priority chain: explicit > GIT_MEM_MODEL > ANTHROPIC_MODEL
  3. detectClaudeAgent() - test both successful version detection and fallback behavior when the claude binary is unavailable or times out

The integration tests in prepare-commit-msg.test.ts partially cover this functionality through the hook, but direct unit tests would provide better isolation and faster feedback for this shared utility.

Copilot uses AI. Check for mistakes.
Loading