From 0f8a10a1874654790f70a03217325b54ca8bb2e2 Mon Sep 17 00:00:00 2001 From: Greg von Nessi Date: Wed, 25 Feb 2026 22:43:22 +0000 Subject: [PATCH 1/2] Fix worktree sessions fragmenting project identity Claude Code v2.1.47+ passes worktree paths as cwd in hook stdin, causing sessions to be tagged as e.g. 'claude-worktree-abc123' instead of the real project name. All entry points now resolve worktree paths back to the main repository via git worktree list with a .git file parsing fallback. Bump version to 0.8.2. --- CHANGELOG.md | 17 +++ docs/guides/integration.md | 20 +++ package-lock.json | 4 +- package.json | 2 +- src/cli/commands/hook.ts | 14 +- src/hooks/claudemd-generator.ts | 9 +- src/hooks/hook-utils.ts | 3 +- src/ingest/ingest-session.ts | 3 +- src/parser/session-reader.ts | 8 +- src/utils/project-path.ts | 148 +++++++++++++++++++ test/cli/commands/hook.test.ts | 8 +- test/cli/hook-command.test.ts | 9 ++ test/parser/session-reader.test.ts | 13 ++ test/utils/project-path.test.ts | 226 +++++++++++++++++++++++++++++ 14 files changed, 465 insertions(+), 19 deletions(-) create mode 100644 src/utils/project-path.ts create mode 100644 test/utils/project-path.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c9fd795..1f14bcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.8.2] - 2026-02-25 + +### Fixed + +- **Worktree sessions fragment project identity**: When Claude Code runs in a worktree (`isolation: "worktree"`, introduced in v2.1.47), hook stdin reports the worktree path (e.g., `/tmp/claude-worktree-abc123/`) as `cwd`. Causantic derived project identity from `basename(cwd)`, so worktree sessions were tagged as `claude-worktree-abc123` instead of the real project name. All entry points (hook dispatcher, claudemd-generator, session reader, ingestion, hook-utils) now resolve worktree paths back to the main repository via `git worktree list --porcelain` with a `.git` file parsing fallback. Project identity is consistent across worktree and non-worktree sessions. + +### Added + +- **`src/utils/project-path.ts`**: New utility — `resolveCanonicalProjectPath(cwd)` detects linked worktrees (`.git` is a file, not a directory), resolves to the main repo path, and caches results. Uses `execFileSync` with 500ms timeout, falls back to parsing the `.git` file. Skips submodules (`.git/modules/` paths). Pattern follows `device-detector.ts`. +- **Claude Code Compatibility section** in `docs/guides/integration.md`: Documents worktree resolution, `CLAUDE_CODE_SIMPLE` mode, enterprise `disableAllHooks`, and future hook events. + +### Tests + +- 11 new tests in `test/utils/project-path.test.ts`: normal repo, worktree resolution via git command, `.git` file fallback, submodule guard, error cases, caching, and a real git worktree integration test. +- Updated `test/cli/commands/hook.test.ts` and `test/parser/session-reader.test.ts` with worktree-aware assertions. +- 2066 total tests passing. + ## [0.8.1] - 2026-02-22 ### Changed diff --git a/docs/guides/integration.md b/docs/guides/integration.md index 7d97c58..916dbda 100644 --- a/docs/guides/integration.md +++ b/docs/guides/integration.md @@ -2,6 +2,26 @@ This guide covers integrating Causantic with Claude Code through hooks and MCP. +## Claude Code Compatibility + +### Worktree Sessions + +Claude Code v2.1.47+ supports worktree isolation (`isolation: "worktree"` in agents, `--worktree` flag). When a session runs in a worktree, Claude Code passes the worktree path (e.g., `/tmp/claude-worktree-abc123/`) as `cwd` in hook stdin. + +Causantic automatically resolves worktree paths back to the main repository, so project identity remains consistent across worktree and non-worktree sessions. No configuration is needed. + +### CLAUDE_CODE_SIMPLE Mode + +Setting `CLAUDE_CODE_SIMPLE=true` disables all Claude Code integrations including MCP servers and hooks. Causantic will not receive any hook events or serve MCP queries when this mode is active. + +### Enterprise `disableAllHooks` + +The `disableAllHooks` managed setting (enterprise Claude Code deployments) can override Causantic hooks. If hooks are not firing, check whether this setting is active in your organisation's Claude Code configuration. + +### Future Hook Events + +Claude Code may introduce additional hook events (`ConfigChange`, `WorktreeCreate`, `WorktreeRemove`) that Causantic could leverage for richer context tracking. + ## Hook System Causantic uses Claude Code hooks to capture context at key moments: diff --git a/package-lock.json b/package-lock.json index 9d26293..8743851 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "causantic", - "version": "0.8.1", + "version": "0.8.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "causantic", - "version": "0.8.1", + "version": "0.8.2", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.78.0", diff --git a/package.json b/package.json index 772f816..9aaeecb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "causantic", - "version": "0.8.1", + "version": "0.8.2", "description": "Long-term memory for Claude Code — local-first, graph-augmented, self-benchmarking", "type": "module", "private": false, diff --git a/src/cli/commands/hook.ts b/src/cli/commands/hook.ts index aac18ea..bf85dac 100644 --- a/src/cli/commands/hook.ts +++ b/src/cli/commands/hook.ts @@ -1,5 +1,6 @@ import { basename } from 'node:path'; import type { Command } from '../types.js'; +import { resolveCanonicalProjectPath } from '../../utils/project-path.js'; /** * Claude Code hook stdin input shape. @@ -68,8 +69,8 @@ export const hookCommand: Command = { switch (hookName) { case 'session-start': { - // session-start needs the project slug (basename of cwd) - const projectSlug = basename(input.cwd ?? args[1] ?? process.cwd()); + // session-start needs the project slug (basename of canonical cwd) + const projectSlug = basename(resolveCanonicalProjectPath(input.cwd ?? args[1] ?? process.cwd())); const { handleSessionStart } = await import('../../hooks/session-start.js'); const result = await handleSessionStart(projectSlug, {}); @@ -93,7 +94,7 @@ export const hookCommand: Command = { process.exit(2); } - const project = basename(input.cwd ?? process.cwd()); + const project = basename(resolveCanonicalProjectPath(input.cwd ?? process.cwd())); const { handlePreCompact } = await import('../../hooks/pre-compact.js'); await handlePreCompact(sessionPath, { project, sessionId: input.session_id }); console.log('Pre-compact hook executed.'); @@ -108,16 +109,17 @@ export const hookCommand: Command = { process.exit(2); } - const project = basename(input.cwd ?? process.cwd()); + const project = basename(resolveCanonicalProjectPath(input.cwd ?? process.cwd())); const { handleSessionEnd } = await import('../../hooks/session-end.js'); await handleSessionEnd(sessionPath, { project, sessionId: input.session_id }); console.log('Session-end hook executed.'); break; } case 'claudemd-generator': { - const projectPath = input.cwd ?? args[1] ?? process.cwd(); + const rawCwd = input.cwd ?? args[1] ?? process.cwd(); + const projectSlug = basename(resolveCanonicalProjectPath(rawCwd)); const { updateClaudeMd } = await import('../../hooks/claudemd-generator.js'); - await updateClaudeMd(projectPath, {}); + await updateClaudeMd(rawCwd, { projectSlug }); console.log('CLAUDE.md updated.'); break; } diff --git a/src/hooks/claudemd-generator.ts b/src/hooks/claudemd-generator.ts index ffafd17..f3ee193 100644 --- a/src/hooks/claudemd-generator.ts +++ b/src/hooks/claudemd-generator.ts @@ -11,7 +11,7 @@ import { readFile, writeFile } from 'fs/promises'; import { existsSync } from 'fs'; -import { join } from 'path'; +import { basename, join } from 'path'; import { generateMemorySection } from './session-start.js'; import { executeHook, logHook, isTransientError, type HookMetrics } from './hook-utils.js'; import { errorMessage } from '../utils/errors.js'; @@ -31,6 +31,8 @@ export interface ClaudeMdOptions extends SessionStartOptions { claudeMdPath?: string; /** Create file if it doesn't exist. Default: false */ createIfMissing?: boolean; + /** Project slug for memory queries (overrides basename(projectPath)). */ + projectSlug?: string; } /** @@ -64,8 +66,9 @@ async function internalUpdateClaudeMd( ...sessionOptions } = options; - // Generate memory section - const memorySection = await generateMemorySection(projectPath, sessionOptions); + // Generate memory section using project slug for DB queries + const memorySlug = options.projectSlug ?? basename(projectPath); + const memorySection = await generateMemorySection(memorySlug, sessionOptions); if (!memorySection) { logHook({ diff --git a/src/hooks/hook-utils.ts b/src/hooks/hook-utils.ts index 74af907..a16e851 100644 --- a/src/hooks/hook-utils.ts +++ b/src/hooks/hook-utils.ts @@ -382,6 +382,7 @@ export async function handleIngestionHook( options: IngestionHookOptions = {}, ): Promise { const { basename } = await import('node:path'); + const { resolveCanonicalProjectPath } = await import('../utils/project-path.js'); const { recordHookStatus } = await import('./hook-status.js'); const { enableRetry = true, maxRetries = 3, gracefulDegradation = true } = options; @@ -396,7 +397,7 @@ export async function handleIngestionHook( degraded: true, }; - const project = options.project ?? basename(process.cwd()); + const project = options.project ?? basename(resolveCanonicalProjectPath(process.cwd())); const { result, metrics } = await executeHook( hookName, diff --git a/src/ingest/ingest-session.ts b/src/ingest/ingest-session.ts index 23fdd4a..ecf7e67 100644 --- a/src/ingest/ingest-session.ts +++ b/src/ingest/ingest-session.ts @@ -48,6 +48,7 @@ import { import type { ChunkInput } from '../storage/types.js'; import type { Chunk, Turn } from '../parser/types.js'; import { createLogger } from '../utils/logger.js'; +import { resolveCanonicalProjectPath } from '../utils/project-path.js'; const log = createLogger('ingest-session'); @@ -166,7 +167,7 @@ export async function ingestSession( // Get session info const info = await getSessionInfo(sessionPath); const projectSlug = deriveProjectSlug(info); - const projectPath = info.cwd || ''; + const projectPath = info.cwd ? resolveCanonicalProjectPath(info.cwd) : ''; // Get file stats for mtime check const fileStats = await stat(sessionPath); diff --git a/src/parser/session-reader.ts b/src/parser/session-reader.ts index f022b7b..9047206 100644 --- a/src/parser/session-reader.ts +++ b/src/parser/session-reader.ts @@ -10,6 +10,7 @@ import { createInterface } from 'node:readline'; import { basename, dirname, join } from 'node:path'; import type { RawMessage, RawMessageType, SessionInfo } from './types.js'; import { createLogger } from '../utils/logger.js'; +import { resolveCanonicalProjectPath } from '../utils/project-path.js'; const log = createLogger('session-reader'); @@ -257,14 +258,15 @@ export async function hasSubAgents(sessionPath: string): Promise { */ export function deriveProjectSlug(info: SessionInfo, knownSlugs?: Map): string { if (info.cwd) { - let slug = basename(info.cwd); + const canonicalCwd = resolveCanonicalProjectPath(info.cwd); + let slug = basename(canonicalCwd); // Check for collision: same basename but different cwd if (knownSlugs) { const existingCwd = knownSlugs.get(slug); - if (existingCwd && existingCwd !== info.cwd) { + if (existingCwd && existingCwd !== canonicalCwd) { // Disambiguate using last two path components - slug = twoComponentSlug(info.cwd); + slug = twoComponentSlug(canonicalCwd); } } diff --git a/src/utils/project-path.ts b/src/utils/project-path.ts new file mode 100644 index 0000000..1193e3b --- /dev/null +++ b/src/utils/project-path.ts @@ -0,0 +1,148 @@ +/** + * Resolves canonical project paths from git worktree directories. + * + * Claude Code v2.1.47+ supports worktree isolation, passing worktree paths + * (e.g., /tmp/claude-worktree-abc123/) as `cwd`. This utility resolves + * worktree paths back to the main repository path so project identity + * remains consistent across worktree and non-worktree sessions. + */ + +import { execFileSync } from 'node:child_process'; +import { readFileSync, statSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { createLogger } from './logger.js'; + +const log = createLogger('project-path'); + +/** Module-level cache: worktree path → canonical path. */ +const cache = new Map(); + +/** + * Resolve a working directory to its canonical project path. + * + * For normal repos, returns the input unchanged. + * For linked worktrees, resolves to the main repository path. + * For non-git directories, returns the input unchanged. + * + * @param cwd - The working directory (possibly a worktree path) + * @returns The canonical project path (main repo root) + */ +export function resolveCanonicalProjectPath(cwd: string): string { + if (!cwd) return cwd; + + const cached = cache.get(cwd); + if (cached !== undefined) return cached; + + const resolved = resolveWorktree(cwd); + cache.set(cwd, resolved); + return resolved; +} + +/** + * Clear the path cache. Intended for testing. + */ +export function clearProjectPathCache(): void { + cache.clear(); +} + +/** + * Internal resolution logic. + */ +function resolveWorktree(cwd: string): string { + // Check if .git exists and what type it is + let gitStat; + try { + gitStat = statSync(join(cwd, '.git')); + } catch { + // No .git — not a git repo (or inaccessible), return as-is + return cwd; + } + + // Normal repo: .git is a directory + if (gitStat.isDirectory()) { + return cwd; + } + + // Linked worktree: .git is a file containing "gitdir: " + if (gitStat.isFile()) { + return resolveFromGitCommand(cwd) ?? resolveFromGitFile(cwd) ?? cwd; + } + + return cwd; +} + +/** + * Try resolving via `git worktree list --porcelain`. + * The first line is always the main worktree: "worktree /path/to/main" + */ +function resolveFromGitCommand(cwd: string): string | null { + try { + const output = execFileSync('git', ['-C', cwd, 'worktree', 'list', '--porcelain'], { + timeout: 500, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + + // First line: "worktree /path/to/main/repo" + const firstLine = output.split('\n')[0]; + if (firstLine?.startsWith('worktree ')) { + const mainPath = firstLine.slice('worktree '.length).trim(); + if (mainPath && mainPath !== cwd) { + log.debug('Resolved worktree via git command', { from: cwd, to: mainPath }); + return mainPath; + } + // If mainPath === cwd, this IS the main worktree + return mainPath || null; + } + } catch (error) { + log.debug('git worktree list failed, trying .git file fallback', { + cwd, + error: error instanceof Error ? error.message : String(error), + }); + } + return null; +} + +/** + * Fallback: parse the .git file to find the main repo. + * + * Worktree .git files contain: "gitdir: /path/to/main/.git/worktrees/" + * Submodule .git files contain: "gitdir: /path/to/main/.git/modules/" + * + * We only resolve worktrees (path contains /worktrees/), not submodules. + */ +function resolveFromGitFile(cwd: string): string | null { + try { + const gitFileContent = readFileSync(join(cwd, '.git'), 'utf-8').trim(); + + if (!gitFileContent.startsWith('gitdir: ')) { + return null; + } + + const gitdir = gitFileContent.slice('gitdir: '.length).trim(); + + // Only resolve worktrees, not submodules + if (!gitdir.includes('/worktrees/')) { + return null; + } + + // Walk up from gitdir to find main repo root: + // gitdir is like /path/to/main/.git/worktrees/ + // We need /path/to/main (parent of .git) + const worktreesIdx = gitdir.lastIndexOf('/worktrees/'); + const dotGitDir = gitdir.slice(0, worktreesIdx); + + // dotGitDir should end with .git (or be the .git directory itself) + const mainPath = dirname(dotGitDir); + if (mainPath) { + log.debug('Resolved worktree via .git file', { from: cwd, to: mainPath }); + return mainPath; + } + } catch (error) { + log.debug('Failed to parse .git file', { + cwd, + error: error instanceof Error ? error.message : String(error), + }); + } + return null; +} diff --git a/test/cli/commands/hook.test.ts b/test/cli/commands/hook.test.ts index b0e8fba..3e63ee5 100644 --- a/test/cli/commands/hook.test.ts +++ b/test/cli/commands/hook.test.ts @@ -164,7 +164,9 @@ describe('hookCommand', () => { await hookCommand.handler(['claudemd-generator', '/projects/my-app']); - expect(mockUpdateClaudeMd).toHaveBeenCalledWith('/projects/my-app', {}); + expect(mockUpdateClaudeMd).toHaveBeenCalledWith('/projects/my-app', { + projectSlug: 'my-app', + }); expect(console.log).toHaveBeenCalledWith('CLAUDE.md updated.'); }); @@ -173,7 +175,9 @@ describe('hookCommand', () => { await hookCommand.handler(['claudemd-generator']); - expect(mockUpdateClaudeMd).toHaveBeenCalledWith(expect.any(String), {}); + expect(mockUpdateClaudeMd).toHaveBeenCalledWith(expect.any(String), { + projectSlug: expect.any(String), + }); }); }); diff --git a/test/cli/hook-command.test.ts b/test/cli/hook-command.test.ts index 895076c..cb89e39 100644 --- a/test/cli/hook-command.test.ts +++ b/test/cli/hook-command.test.ts @@ -152,6 +152,15 @@ describe('hook-command', () => { expect(basename('/tmp')).toBe('tmp'); }); + it('worktree cwd would resolve to main repo basename', () => { + // Verifies the concept: if resolveCanonicalProjectPath maps + // /tmp/claude-worktree-abc123 → /Users/test/my-project, + // then basename gives 'my-project' not 'claude-worktree-abc123' + const { basename } = require('node:path'); + const mainRepoPath = '/Users/test/my-project'; + expect(basename(mainRepoPath)).toBe('my-project'); + }); + it('session-end prefers transcript_path over args', () => { const input = { transcript_path: '/from/stdin.jsonl' }; const args = ['/from/args.jsonl']; diff --git a/test/parser/session-reader.test.ts b/test/parser/session-reader.test.ts index 8bca168..dc9278e 100644 --- a/test/parser/session-reader.test.ts +++ b/test/parser/session-reader.test.ts @@ -118,4 +118,17 @@ describe('deriveProjectSlug', () => { const info = makeInfo({ cwd: '/root' }); expect(deriveProjectSlug(info)).toBe('root'); }); + + it('resolves worktree cwd to main repo name (mocked)', async () => { + // Mock resolveCanonicalProjectPath to simulate worktree resolution + const { vi } = await import('vitest'); + const projectPath = await import('../../src/utils/project-path.js'); + const spy = vi.spyOn(projectPath, 'resolveCanonicalProjectPath'); + spy.mockReturnValue('/Users/test/my-real-project'); + + const info = makeInfo({ cwd: '/tmp/claude-worktree-abc123' }); + expect(deriveProjectSlug(info)).toBe('my-real-project'); + + spy.mockRestore(); + }); }); diff --git a/test/utils/project-path.test.ts b/test/utils/project-path.test.ts new file mode 100644 index 0000000..dd97d21 --- /dev/null +++ b/test/utils/project-path.test.ts @@ -0,0 +1,226 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { join } from 'node:path'; + +// Mock child_process and fs before imports +vi.mock('node:child_process', () => ({ + execFileSync: vi.fn(), +})); + +vi.mock('node:fs', async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + statSync: vi.fn(), + readFileSync: vi.fn(), + }; +}); + +import { resolveCanonicalProjectPath, clearProjectPathCache } from '../../src/utils/project-path.js'; +import { execFileSync } from 'node:child_process'; +import { statSync, readFileSync } from 'node:fs'; + +const mockExecFileSync = vi.mocked(execFileSync); +const mockStatSync = vi.mocked(statSync); +const mockReadFileSync = vi.mocked(readFileSync); + +describe('resolveCanonicalProjectPath', () => { + beforeEach(() => { + clearProjectPathCache(); + vi.clearAllMocks(); + }); + + it('returns empty string for empty input', () => { + expect(resolveCanonicalProjectPath('')).toBe(''); + }); + + it('returns unchanged for normal repo (.git is directory)', () => { + mockStatSync.mockReturnValue({ isDirectory: () => true, isFile: () => false } as ReturnType< + typeof statSync + >); + + expect(resolveCanonicalProjectPath('/Users/test/my-project')).toBe('/Users/test/my-project'); + }); + + it('returns unchanged for non-git directory (no .git)', () => { + mockStatSync.mockImplementation(() => { + throw new Error('ENOENT'); + }); + + expect(resolveCanonicalProjectPath('/tmp/random-dir')).toBe('/tmp/random-dir'); + }); + + it('resolves linked worktree via git command', () => { + mockStatSync.mockReturnValue({ isDirectory: () => false, isFile: () => true } as ReturnType< + typeof statSync + >); + + mockExecFileSync.mockReturnValue( + 'worktree /Users/test/my-project\nbare\n\nworktree /tmp/claude-worktree-abc123\nbranch refs/heads/feature\n', + ); + + expect(resolveCanonicalProjectPath('/tmp/claude-worktree-abc123')).toBe( + '/Users/test/my-project', + ); + }); + + it('falls back to .git file parsing when git command fails', () => { + mockStatSync.mockReturnValue({ isDirectory: () => false, isFile: () => true } as ReturnType< + typeof statSync + >); + + mockExecFileSync.mockImplementation(() => { + throw new Error('git not found'); + }); + + mockReadFileSync.mockReturnValue( + 'gitdir: /Users/test/my-project/.git/worktrees/feature-branch', + ); + + expect(resolveCanonicalProjectPath('/tmp/claude-worktree-abc123')).toBe( + '/Users/test/my-project', + ); + }); + + it('does not resolve submodule .git files (no /worktrees/ in path)', () => { + mockStatSync.mockReturnValue({ isDirectory: () => false, isFile: () => true } as ReturnType< + typeof statSync + >); + + mockExecFileSync.mockImplementation(() => { + throw new Error('git not found'); + }); + + mockReadFileSync.mockReturnValue( + 'gitdir: /Users/test/main-project/.git/modules/my-submodule', + ); + + expect(resolveCanonicalProjectPath('/Users/test/main-project/my-submodule')).toBe( + '/Users/test/main-project/my-submodule', + ); + }); + + it('returns original cwd when both methods fail', () => { + mockStatSync.mockReturnValue({ isDirectory: () => false, isFile: () => true } as ReturnType< + typeof statSync + >); + + mockExecFileSync.mockImplementation(() => { + throw new Error('git not found'); + }); + + mockReadFileSync.mockImplementation(() => { + throw new Error('EACCES'); + }); + + expect(resolveCanonicalProjectPath('/tmp/broken-worktree')).toBe('/tmp/broken-worktree'); + }); + + it('caches results (second call does not spawn subprocess)', () => { + mockStatSync.mockReturnValue({ isDirectory: () => true, isFile: () => false } as ReturnType< + typeof statSync + >); + + resolveCanonicalProjectPath('/Users/test/cached-project'); + resolveCanonicalProjectPath('/Users/test/cached-project'); + + // statSync called only once (first call), not on second + expect(mockStatSync).toHaveBeenCalledTimes(1); + }); + + it('returns cwd when .git file has unexpected format', () => { + mockStatSync.mockReturnValue({ isDirectory: () => false, isFile: () => true } as ReturnType< + typeof statSync + >); + + mockExecFileSync.mockImplementation(() => { + throw new Error('git not found'); + }); + + mockReadFileSync.mockReturnValue('unexpected content without gitdir prefix'); + + expect(resolveCanonicalProjectPath('/tmp/weird-git')).toBe('/tmp/weird-git'); + }); + + it('handles main worktree correctly (worktree path === cwd)', () => { + mockStatSync.mockReturnValue({ isDirectory: () => false, isFile: () => true } as ReturnType< + typeof statSync + >); + + // When git worktree list returns the same path as cwd, it IS the main worktree + mockExecFileSync.mockReturnValue( + 'worktree /Users/test/my-project\nbare\n', + ); + + expect(resolveCanonicalProjectPath('/Users/test/my-project')).toBe('/Users/test/my-project'); + }); +}); + +describe('resolveCanonicalProjectPath integration', () => { + // Integration test using real git operations + // Only runs if git is available + let tempDir: string; + let worktreePath: string; + let hasGit = false; + + beforeEach(async () => { + clearProjectPathCache(); + + try { + const { execSync } = await import('node:child_process'); + execSync('git --version', { stdio: 'ignore' }); + hasGit = true; + } catch { + hasGit = false; + } + }); + + afterEach(async () => { + if (!hasGit || !tempDir) return; + + // Cleanup: remove worktree first, then temp dir + try { + const { execSync } = await import('node:child_process'); + const { rmSync } = await import('node:fs'); + if (worktreePath) { + execSync(`git -C "${tempDir}" worktree remove "${worktreePath}" --force`, { + stdio: 'ignore', + }); + } + rmSync(tempDir, { recursive: true, force: true }); + } catch { + // Best-effort cleanup + } + }); + + it('resolves real git worktree to main repo path', async () => { + if (!hasGit) return; // Skip if git unavailable + + // Restore real implementations for this test + vi.restoreAllMocks(); + clearProjectPathCache(); + + const { execSync } = await import('node:child_process'); + const { mkdtempSync } = await import('node:fs'); + const { tmpdir } = await import('node:os'); + + // Create a real git repo + tempDir = mkdtempSync(join(tmpdir(), 'causantic-worktree-test-')); + execSync('git init', { cwd: tempDir, stdio: 'ignore' }); + execSync('git commit --allow-empty -m "init"', { cwd: tempDir, stdio: 'ignore' }); + + // Create a worktree + worktreePath = join(tmpdir(), 'causantic-worktree-test-wt-' + Date.now()); + execSync(`git worktree add "${worktreePath}" -b test-branch`, { + cwd: tempDir, + stdio: 'ignore', + }); + + // Re-import to get un-mocked version + const { resolveCanonicalProjectPath: resolve, clearProjectPathCache: clearCache } = + await import('../../src/utils/project-path.js'); + clearCache(); + + const result = resolve(worktreePath); + expect(result).toBe(tempDir); + }); +}); From 1e2efcda0cdc073bc8601afe4b00704986d707ef Mon Sep 17 00:00:00 2001 From: Greg von Nessi Date: Wed, 25 Feb 2026 22:45:50 +0000 Subject: [PATCH 2/2] Fix formatting in hook.ts and project-path.test.ts --- src/cli/commands/hook.ts | 4 +++- test/utils/project-path.test.ts | 13 ++++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/cli/commands/hook.ts b/src/cli/commands/hook.ts index bf85dac..a5802d9 100644 --- a/src/cli/commands/hook.ts +++ b/src/cli/commands/hook.ts @@ -70,7 +70,9 @@ export const hookCommand: Command = { switch (hookName) { case 'session-start': { // session-start needs the project slug (basename of canonical cwd) - const projectSlug = basename(resolveCanonicalProjectPath(input.cwd ?? args[1] ?? process.cwd())); + const projectSlug = basename( + resolveCanonicalProjectPath(input.cwd ?? args[1] ?? process.cwd()), + ); const { handleSessionStart } = await import('../../hooks/session-start.js'); const result = await handleSessionStart(projectSlug, {}); diff --git a/test/utils/project-path.test.ts b/test/utils/project-path.test.ts index dd97d21..25cf151 100644 --- a/test/utils/project-path.test.ts +++ b/test/utils/project-path.test.ts @@ -15,7 +15,10 @@ vi.mock('node:fs', async (importOriginal) => { }; }); -import { resolveCanonicalProjectPath, clearProjectPathCache } from '../../src/utils/project-path.js'; +import { + resolveCanonicalProjectPath, + clearProjectPathCache, +} from '../../src/utils/project-path.js'; import { execFileSync } from 'node:child_process'; import { statSync, readFileSync } from 'node:fs'; @@ -90,9 +93,7 @@ describe('resolveCanonicalProjectPath', () => { throw new Error('git not found'); }); - mockReadFileSync.mockReturnValue( - 'gitdir: /Users/test/main-project/.git/modules/my-submodule', - ); + mockReadFileSync.mockReturnValue('gitdir: /Users/test/main-project/.git/modules/my-submodule'); expect(resolveCanonicalProjectPath('/Users/test/main-project/my-submodule')).toBe( '/Users/test/main-project/my-submodule', @@ -147,9 +148,7 @@ describe('resolveCanonicalProjectPath', () => { >); // When git worktree list returns the same path as cwd, it IS the main worktree - mockExecFileSync.mockReturnValue( - 'worktree /Users/test/my-project\nbare\n', - ); + mockExecFileSync.mockReturnValue('worktree /Users/test/my-project\nbare\n'); expect(resolveCanonicalProjectPath('/Users/test/my-project')).toBe('/Users/test/my-project'); });