fix(session-start): tolerate Windows path forms in repo identity match (#3160)

The recorded Repo field and the live lookup can name the same common git
dir in different forms on Windows runners (8.3 short names from TEMP,
case, separators), so === never matched and sessions were skipped. Add
normalizeRepoPath plus sameRepoIdentity (device and inode fallback) in
utils.js and use them for the repository identity comparison. Unit tests
mock git output with Windows-shaped paths and cover the inode fallback
via hard links on any OS.
This commit is contained in:
Affaan Mustafa
2026-09-19 01:35:33 -04:00
parent f5f1f6b45b
commit 52587005d4
3 changed files with 120 additions and 6 deletions
+6 -4
View File
@@ -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;
}
+50 -2
View File
@@ -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
+64
View File
@@ -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:');