mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-20 16:47:59 +02:00
fix(session-start): scope summary lookup to repository identity (#3160)
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user