diff --git a/scripts/hooks/session-end.js b/scripts/hooks/session-end.js index 9709aa95a..5c31a8e0b 100644 --- a/scripts/hooks/session-end.js +++ b/scripts/hooks/session-end.js @@ -11,7 +11,7 @@ const path = require('path'); const fs = require('fs'); -const { getSessionsDir, getDateString, getTimeString, getSessionIdShort, sanitizeSessionId, getProjectName, ensureDir, readFile, writeFile, runCommand, stripAnsi, log } = require('../lib/utils'); +const { getSessionsDir, getDateString, getTimeString, getSessionIdShort, sanitizeSessionId, getProjectName, getRepoIdentity, ensureDir, readFile, writeFile, runCommand, stripAnsi, log } = require('../lib/utils'); const { generateSessionSummary, getContextRemainingPct, getContextThreshold } = require('../lib/llm-summary'); const SUMMARY_START_MARKER = ''; @@ -130,7 +130,8 @@ function getSessionMetadata() { return { project: getProjectName() || 'unknown', branch: branchResult.success ? branchResult.output : 'unknown', - worktree: process.cwd() + worktree: process.cwd(), + repo: getRepoIdentity() }; } @@ -145,16 +146,20 @@ function buildSessionHeader(today, currentTime, metadata, existingContent = '') const date = extractHeaderField(existingContent, 'Date') || today; const started = extractHeaderField(existingContent, 'Started') || currentTime; - return [ + const lines = [ heading, `**Date:** ${date}`, `**Started:** ${started}`, `**Last Updated:** ${currentTime}`, `**Project:** ${metadata.project}`, `**Branch:** ${metadata.branch}`, - `**Worktree:** ${metadata.worktree}`, - '' - ].join('\n'); + `**Worktree:** ${metadata.worktree}` + ]; + if (metadata.repo) { + lines.push(`**Repo:** ${metadata.repo}`); + } + lines.push(''); + return lines.join('\n'); } function mergeSessionHeader(content, today, currentTime, metadata) { diff --git a/scripts/hooks/session-start.js b/scripts/hooks/session-start.js index 63854aff1..7150f7ef5 100644 --- a/scripts/hooks/session-start.js +++ b/scripts/hooks/session-start.js @@ -14,6 +14,7 @@ const { getSessionSearchDirs, getLearnedSkillsDir, getProjectName, + getRepoIdentity, findFiles, ensureDir, readFile, @@ -254,6 +255,7 @@ function pruneExpiredSessions(searchDirs, retentionDays) { * Session files written by session-end.js contain header fields like: * **Project:** my-project * **Worktree:** /path/to/project + * **Repo:** /path/to/main-worktree/.git * * This function reads each session file once, caching its content, and * returns both the selected session object and its already-read content @@ -261,11 +263,18 @@ function pruneExpiredSessions(searchDirs, retentionDays) { * * Priority (highest to lowest): * 1. Exact worktree (cwd) match — most recent - * 2. Same project name match for legacy sessions without Worktree metadata - * 3. No injection when sessions belong to a different worktree/project + * 2. Repository identity match: the session was recorded in another + * worktree or subdirectory of the same repository. Identity is the + * main worktree's common git dir (issue #3160), taken from the + * recorded **Repo:** field or resolved from the recorded **Worktree:** + * path for older session files. Unrelated repositories never match. + * 3. Same project name match for legacy sessions without Worktree/Repo + * metadata + * 4. No injection when sessions belong to a different repository * * Sessions are already sorted newest-first, so the first match in each - * category wins. + * category wins; the scan continues past repository and project matches so + * an exact worktree match always takes precedence. * * @param {Array} sessions - Deduplicated session list, sorted newest-first. * @param {string} cwd - Current working directory (process.cwd()). @@ -279,7 +288,17 @@ function selectMatchingSession(sessions, cwd, currentProject) { // Normalize cwd once outside the loop to avoid repeated syscalls const normalizedCwd = normalizePath(cwd); + const currentRepoId = getRepoIdentity(cwd); + const repoIdByWorktree = new Map(); + const repoIdOfRecordedWorktree = (recordedWorktree) => { + if (!repoIdByWorktree.has(recordedWorktree)) { + repoIdByWorktree.set(recordedWorktree, getRepoIdentity(recordedWorktree)); + } + return repoIdByWorktree.get(recordedWorktree); + }; + let repoMatch = null; + let repoMatchContent = null; let projectMatch = null; let projectMatchContent = null; let readableSessions = 0; @@ -289,9 +308,11 @@ function selectMatchingSession(sessions, cwd, currentProject) { if (!content) continue; readableSessions++; - // Extract **Worktree:** field + // Extract **Worktree:** and **Repo:** fields const worktreeMatch = content.match(/\*\*Worktree:\*\*\s*(.+)$/m); const sessionWorktree = worktreeMatch ? worktreeMatch[1].trim() : ''; + const repoFieldMatch = content.match(/\*\*Repo:\*\*\s*(.+)$/m); + const sessionRepo = repoFieldMatch ? repoFieldMatch[1].trim() : ''; // Exact worktree match — best possible, return immediately // Normalize both paths to handle symlinks and case-insensitive filesystems @@ -299,9 +320,24 @@ function selectMatchingSession(sessions, cwd, currentProject) { return { session, content, matchReason: 'worktree' }; } + // Repository identity match (#3160): the summary lookup is scoped to the + // repository, not the cwd path, so a session recorded in worktree A is + // eligible in worktree B only when both resolve to the same common git + // dir. Unrelated repositories never share. + if (!repoMatch && currentRepoId && (sessionRepo || sessionWorktree)) { + const sessionRepoId = sessionRepo + ? normalizePath(sessionRepo) + : repoIdOfRecordedWorktree(sessionWorktree); + if (sessionRepoId && sessionRepoId === currentRepoId) { + repoMatch = session; + repoMatchContent = content; + } + } + // Project name match is only safe for legacy session files written before - // Worktree metadata existed. A different explicit Worktree is not a match. - if (!projectMatch && currentProject && !sessionWorktree) { + // Worktree/Repo metadata existed. A different explicit Worktree or Repo + // is not a match. + if (!projectMatch && currentProject && !sessionWorktree && !sessionRepo) { const projectFieldMatch = content.match(/\*\*Project:\*\*\s*(.+)$/m); const sessionProject = projectFieldMatch ? projectFieldMatch[1].trim() : ''; if (sessionProject && sessionProject === currentProject) { @@ -311,6 +347,10 @@ function selectMatchingSession(sessions, cwd, currentProject) { } } + if (repoMatch) { + return { session: repoMatch, content: repoMatchContent, matchReason: 'repo' }; + } + if (projectMatch) { return { session: projectMatch, content: projectMatchContent, matchReason: 'project' }; } diff --git a/scripts/lib/utils.js b/scripts/lib/utils.js index 5e29868eb..a32f1c200 100644 --- a/scripts/lib/utils.js +++ b/scripts/lib/utils.js @@ -135,6 +135,28 @@ function getGitRepoName() { return path.basename(result.output); } +/** + * Get the repository identity for a directory: the canonical (real) path of + * the repository's common git dir, which is the main worktree's .git + * directory. Every linked worktree of one repository resolves to the same + * identity, while unrelated repositories never share one. + * + * @param {string} [dir] - Directory to resolve from (defaults to process.cwd()). + * @returns {string|null} The canonical common git dir, or null when dir is + * not inside a git repository or does not exist. + */ +function getRepoIdentity(dir) { + const target = dir || process.cwd(); + const result = runCommand('git rev-parse --git-common-dir', { cwd: target }); + if (!result.success || !result.output) return null; + const commonDir = path.resolve(target, result.output); + try { + return fs.realpathSync(commonDir); + } catch { + return commonDir; + } +} + /** * Get project name from git repo or current directory */ @@ -642,6 +664,7 @@ module.exports = { sanitizeSessionId, getSessionIdShort, getGitRepoName, + getRepoIdentity, getProjectName, // File operations diff --git a/tests/hooks/hooks.test.js b/tests/hooks/hooks.test.js index 635566b27..374473d2b 100644 --- a/tests/hooks/hooks.test.js +++ b/tests/hooks/hooks.test.js @@ -103,6 +103,9 @@ const CLI_RESUME_SESSION_SENTINEL = 'CLI_RESUME_CONTEXT_SHOULD_NOT_BE_INJECTED'; const CLI_CLEAR_SESSION_SENTINEL = 'CLI_CLEAR_CONTEXT_SHOULD_NOT_BE_INJECTED'; const DESKTOP_CLEAR_SESSION_SENTINEL = 'DESKTOP_CLEAR_CONTEXT_SHOULD_NOT_BE_INJECTED'; const PROJECT_ONLY_SESSION_SENTINEL = 'PROJECT_ONLY_CONTEXT_SHOULD_BE_INJECTED'; +const SAME_REPO_WORKTREE_SENTINEL = 'SAME_REPO_WORKTREE_CONTEXT_SHOULD_BE_INJECTED'; +const REPO_FIELD_SESSION_SENTINEL = 'REPO_FIELD_CONTEXT_SHOULD_BE_INJECTED'; +const UNRELATED_REPO_SESSION_SENTINEL = 'UNRELATED_REPO_CONTEXT_SHOULD_NOT_BE_INJECTED'; function buildSessionStartFixture(content, options = {}) { const title = options.title ?? '# Session'; @@ -113,11 +116,41 @@ function buildSessionStartFixture(content, options = {}) { if (worktree) { lines.push(`**Worktree:** ${worktree}`); } + if (options.repo) { + lines.push(`**Repo:** ${options.repo}`); + } lines.push('', content, ''); return lines.join('\n'); } +function initGitRepoWithWorktrees(baseDir, worktreeNames) { + const mainrepo = path.join(baseDir, 'mainrepo'); + execFileSync('git', ['init', '-q', mainrepo]); + execFileSync('git', ['config', 'user.email', 't@t.local'], { cwd: mainrepo }); + execFileSync('git', ['config', 'user.name', 't'], { cwd: mainrepo }); + fs.writeFileSync(path.join(mainrepo, 'README.md'), 'seed\n'); + execFileSync('git', ['add', '-A'], { cwd: mainrepo }); + execFileSync('git', ['commit', '-q', '-m', 'seed'], { cwd: mainrepo }); + const worktrees = {}; + for (const name of worktreeNames) { + const target = path.join(baseDir, name); + execFileSync('git', ['worktree', 'add', '-q', '-b', name, target, 'HEAD'], { cwd: mainrepo }); + worktrees[name] = target; + } + return { mainrepo, worktrees }; +} + +function gitCommonDirRealpath(dir) { + const out = execFileSync('git', ['rev-parse', '--git-common-dir'], { cwd: dir, encoding: 'utf8' }).trim(); + const resolved = path.resolve(dir, out); + try { + return fs.realpathSync(resolved); + } catch { + return resolved; + } +} + // Test helper function test(name, fn) { try { @@ -145,9 +178,10 @@ async function asyncTest(name, fn) { } // Run a script and capture output -function runScript(scriptPath, input = '', env = {}) { +function runScript(scriptPath, input = '', env = {}, cwd = process.cwd()) { return new Promise((resolve, reject) => { const proc = spawn('node', [scriptPath], { + cwd, env: { ...process.env, ...env }, stdio: ['pipe', 'pipe', 'pipe'] }); @@ -952,6 +986,119 @@ async function runTests() { passed++; else failed++; + if ( + await asyncTest('injects a same-repository session recorded in a different worktree (#3160)', async () => { + const isoHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-3160-samerepo-home-')); + const sessionsDir = getCanonicalSessionsDir(isoHome); + fs.mkdirSync(sessionsDir, { recursive: true }); + fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true }); + const repoBase = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-3160-samerepo-')); + const { worktrees } = initGitRepoWithWorktrees(repoBase, ['wt-a', 'wt-b']); + + const sessionFile = path.join(sessionsDir, '2026-02-11-samerepo-session.tmp'); + fs.writeFileSync( + sessionFile, + buildSessionStartFixture(SAME_REPO_WORKTREE_SENTINEL, { + project: 'wt-a', + worktree: worktrees['wt-a'] + }) + ); + + try { + const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', { + HOME: isoHome, + USERPROFILE: isoHome + }, worktrees['wt-b']); + assert.strictEqual(result.code, 0); + const additionalContext = getSessionStartAdditionalContext(result.stdout); + assert.ok(additionalContext.includes(SAME_REPO_WORKTREE_SENTINEL), 'Should inject a session recorded in another worktree of the same repository'); + assert.ok(result.stderr.includes('(match: repo)'), `Should report repository identity match, stderr: ${result.stderr}`); + } finally { + fs.rmSync(isoHome, { recursive: true, force: true }); + fs.rmSync(repoBase, { recursive: true, force: true }); + } + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('scopes sessions by recorded repository identity when the worktree path is gone (#3160)', async () => { + const isoHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-3160-repofield-home-')); + const sessionsDir = getCanonicalSessionsDir(isoHome); + fs.mkdirSync(sessionsDir, { recursive: true }); + fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true }); + const repoBase = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-3160-repofield-')); + const { mainrepo, worktrees } = initGitRepoWithWorktrees(repoBase, ['wt-b']); + + const sessionFile = path.join(sessionsDir, '2026-02-11-repofield-session.tmp'); + fs.writeFileSync( + sessionFile, + buildSessionStartFixture(REPO_FIELD_SESSION_SENTINEL, { + project: 'wt-removed', + worktree: path.join(repoBase, 'wt-removed'), + repo: gitCommonDirRealpath(mainrepo) + }) + ); + + try { + const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', { + HOME: isoHome, + USERPROFILE: isoHome + }, worktrees['wt-b']); + assert.strictEqual(result.code, 0); + const additionalContext = getSessionStartAdditionalContext(result.stdout); + assert.ok(additionalContext.includes(REPO_FIELD_SESSION_SENTINEL), 'Should match on the recorded common git dir when the recorded worktree path no longer resolves'); + assert.ok(result.stderr.includes('(match: repo)'), `Should report repository identity match, stderr: ${result.stderr}`); + } finally { + fs.rmSync(isoHome, { recursive: true, force: true }); + fs.rmSync(repoBase, { recursive: true, force: true }); + } + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('never injects a session from an unrelated repository (#3160)', async () => { + const isoHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-3160-unrelated-home-')); + const sessionsDir = getCanonicalSessionsDir(isoHome); + fs.mkdirSync(sessionsDir, { recursive: true }); + fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true }); + const repoBaseX = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-3160-repox-')); + const repoX = initGitRepoWithWorktrees(repoBaseX, ['wt-x']); + const repoBaseY = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-3160-repoy-')); + const repoY = initGitRepoWithWorktrees(repoBaseY, ['wt-y']); + + const sessionFile = path.join(sessionsDir, '2026-02-11-unrelated-session.tmp'); + fs.writeFileSync( + sessionFile, + buildSessionStartFixture(UNRELATED_REPO_SESSION_SENTINEL, { + project: 'wt-x', + worktree: repoX.worktrees['wt-x'], + repo: gitCommonDirRealpath(repoX.mainrepo) + }) + ); + + try { + const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', { + HOME: isoHome, + USERPROFILE: isoHome + }, repoY.worktrees['wt-y']); + assert.strictEqual(result.code, 0); + const additionalContext = getSessionStartAdditionalContext(result.stdout); + assert.ok(!additionalContext.includes(UNRELATED_REPO_SESSION_SENTINEL), 'Should never inject a session from an unrelated repository'); + assert.ok(result.stderr.includes('No worktree/project session match found'), `Should log no-match reason, stderr: ${result.stderr}`); + } finally { + fs.rmSync(isoHome, { recursive: true, force: true }); + fs.rmSync(repoBaseX, { recursive: true, force: true }); + fs.rmSync(repoBaseY, { recursive: true, force: true }); + } + }) + ) + passed++; + else failed++; + if ( await asyncTest('reports learned skills count', async () => { const isoHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-skills-start-')); @@ -1259,6 +1406,7 @@ async function runTests() { assert.ok(content.includes(`**Project:** ${project}`), 'Should persist project metadata'); assert.ok(content.includes(`**Branch:** ${branch}`), 'Should persist branch metadata'); assert.ok(content.includes(`**Worktree:** ${process.cwd()}`), 'Should persist worktree metadata'); + assert.ok(content.includes(`**Repo:** ${gitCommonDirRealpath(process.cwd())}`), 'Should persist repository identity metadata'); } finally { fs.rmSync(isoHome, { recursive: true, force: true }); }