diff --git a/scripts/hooks/session-start.js b/scripts/hooks/session-start.js index 7150f7ef5..9a859565a 100644 --- a/scripts/hooks/session-start.js +++ b/scripts/hooks/session-start.js @@ -15,6 +15,7 @@ const { getLearnedSkillsDir, getProjectName, getRepoIdentity, + sameRepoIdentity, findFiles, ensureDir, readFile, @@ -325,10 +326,11 @@ function selectMatchingSession(sessions, cwd, currentProject) { // 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) { + // The recorded Repo field may carry a different path form than the + // live lookup (8.3 short names on Windows runners, case, separators), + // so compare with filesystem-identity fallback rather than ===. + const sessionRepoId = sessionRepo || repoIdOfRecordedWorktree(sessionWorktree); + if (sessionRepoId && sameRepoIdentity(sessionRepoId, currentRepoId)) { repoMatch = session; repoMatchContent = content; } diff --git a/scripts/lib/utils.js b/scripts/lib/utils.js index a32f1c200..9766fcd0c 100644 --- a/scripts/lib/utils.js +++ b/scripts/lib/utils.js @@ -145,9 +145,9 @@ function getGitRepoName() { * @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) { +function getRepoIdentity(dir, runCmd = runCommand) { const target = dir || process.cwd(); - const result = runCommand('git rev-parse --git-common-dir', { cwd: target }); + const result = runCmd('git rev-parse --git-common-dir', { cwd: target }); if (!result.success || !result.output) return null; const commonDir = path.resolve(target, result.output); try { @@ -157,6 +157,52 @@ function getRepoIdentity(dir) { } } +/** + * Normalize a repository identity path for comparison: canonical (real) form + * when it exists, forward slashes, no trailing slash, and lowercase on + * Windows where the filesystem is case-insensitive. The platform argument + * exists so Windows-shaped git output can be tested on any OS. + * + * @param {string} p - Path to normalize. + * @param {string} [platform] - Platform override (defaults to process.platform). + * @returns {string} The normalized path, or '' for empty input. + */ +function normalizeRepoPath(p, platform = process.platform) { + if (!p) return ''; + let resolved; + try { + resolved = fs.realpathSync(p); + } catch { + resolved = path.resolve(p); + } + const slashed = resolved.replace(/\\/g, '/').replace(/\/+$/, ''); + return platform === 'win32' ? slashed.toLowerCase() : slashed; +} + +/** + * Compare two repository identity paths. String normalization alone is not + * enough on Windows CI runners, where TEMP commonly uses an 8.3 short name + * (RUNNER~1): Node's realpath keeps the short form while git reports the + * long form for the same directory. When the strings differ, fall back to + * filesystem identity (device + inode), which is immune to 8.3 names, case + * and separators. Fails closed when either path cannot be statted. + * + * @param {string} a - First identity path. + * @param {string} b - Second identity path. + * @returns {boolean} True when both paths name the same directory. + */ +function sameRepoIdentity(a, b) { + if (!a || !b) return false; + if (normalizeRepoPath(a) === normalizeRepoPath(b)) return true; + try { + const sa = fs.statSync(a); + const sb = fs.statSync(b); + return sa.ino !== 0 && sa.dev === sb.dev && sa.ino === sb.ino; + } catch { + return false; + } +} + /** * Get project name from git repo or current directory */ @@ -665,6 +711,8 @@ module.exports = { getSessionIdShort, getGitRepoName, getRepoIdentity, + normalizeRepoPath, + sameRepoIdentity, getProjectName, // File operations diff --git a/tests/lib/utils.test.js b/tests/lib/utils.test.js index 158db6c73..f9921a503 100644 --- a/tests/lib/utils.test.js +++ b/tests/lib/utils.test.js @@ -7,6 +7,7 @@ const assert = require('assert'); const path = require('path'); const fs = require('fs'); +const os = require('os'); const { spawnSync } = require('child_process'); // Import the module @@ -243,6 +244,69 @@ function runTests() { assert.ok(name && name.length > 0); })) passed++; else failed++; + // Repository identity tests (#3160 Windows path forms) + console.log('\nRepository Identity:'); + + if (test('getRepoIdentity resolves a mocked relative git output against dir', () => { + const fakeGit = () => ({ success: true, output: '.git' }); + const id = utils.getRepoIdentity('/definitely/missing/repo', fakeGit); + assert.strictEqual(id, path.resolve('/definitely/missing/repo', '.git')); + })) passed++; else failed++; + + if (test('getRepoIdentity returns null when git fails', () => { + const fakeGit = () => ({ success: false, output: 'not a git repository' }); + assert.strictEqual(utils.getRepoIdentity('/definitely/missing/repo', fakeGit), null); + })) passed++; else failed++; + + if (test('normalizeRepoPath treats Windows-shaped paths equal across case and separators', () => { + // Windows-shaped git output: 8.3 short name, backslashes, mixed case. + // Runs on any OS; the platform argument selects the case-insensitive rule. + const a = 'C:\\Users\\RUNNER~1\\AppData\\Local\\Temp\\repo\\.git'; + const b = 'c:/users/runner~1/appdata/local/temp/repo/.git'; + assert.strictEqual( + utils.normalizeRepoPath(a, 'win32'), + utils.normalizeRepoPath(b, 'win32') + ); + })) passed++; else failed++; + + if (test('normalizeRepoPath strips trailing slashes and keeps case off win32', () => { + const a = utils.normalizeRepoPath('X:/Repo/Main/.git/', 'linux'); + const b = utils.normalizeRepoPath('X:/Repo/Main/.git', 'linux'); + assert.strictEqual(a, b); + assert.ok(!/\.git\/$/.test(a)); + assert.ok(a.includes('Repo'), 'linux normalization must not lowercase'); + })) passed++; else failed++; + + if (test('sameRepoIdentity matches a hard link by filesystem identity', () => { + // dev+ino fallback: different path strings, same file. This is what + // rescues 8.3 short-name versus long-name mismatches on Windows. + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-repoid-')); + try { + const orig = path.join(dir, 'a'); + const link = path.join(dir, 'b'); + fs.writeFileSync(orig, 'x'); + fs.linkSync(orig, link); + assert.ok(utils.sameRepoIdentity(orig, link)); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + })) passed++; else failed++; + + if (test('sameRepoIdentity rejects different files and missing paths', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-repoid-')); + try { + const a = path.join(dir, 'a'); + const b = path.join(dir, 'b'); + fs.writeFileSync(a, 'x'); + fs.writeFileSync(b, 'y'); + assert.ok(!utils.sameRepoIdentity(a, b)); + assert.ok(!utils.sameRepoIdentity(a, path.join(dir, 'missing'))); + assert.ok(!utils.sameRepoIdentity('', b)); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + })) passed++; else failed++; + // sanitizeSessionId tests console.log('\nsanitizeSessionId:');