fix(session-start): scope summary lookup to repository identity (#3160)

This commit is contained in:
Affaan Mustafa
2026-09-18 18:59:41 -04:00
parent b15f7d8171
commit f5f1f6b45b
4 changed files with 229 additions and 13 deletions
+11 -6
View File
@@ -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 = '<!-- ECC:SUMMARY:START -->';
@@ -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) {
+46 -6
View File
@@ -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<Object>} 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' };
}
+23
View File
@@ -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
+149 -1
View File
@@ -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 });
}