mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-26 18:35:05 +00:00
Windows: compare repo identity via normalizeRepoPath/sameRepoIdentity (8.3 short names, case, separators; inode fallback). All nine windows-latest jobs green on 52587005.
This commit is contained in:
1 parent
9e06a2792b
commit
f6eb800474
5 files changed
+343
-13
No files matched your search
@@ -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,8 @@ const {
|
||||
getSessionSearchDirs,
|
||||
getLearnedSkillsDir,
|
||||
getProjectName,
|
||||
getRepoIdentity,
|
||||
sameRepoIdentity,
|
||||
findFiles,
|
||||
ensureDir,
|
||||
readFile,
|
||||
@@ -254,6 +256,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 +264,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 +289,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 +309,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 +321,25 @@ 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)) {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
// 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 +349,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,74 @@ 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, runCmd = runCommand) {
|
||||
const target = dir || process.cwd();
|
||||
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 {
|
||||
return fs.realpathSync(commonDir);
|
||||
} catch {
|
||||
return commonDir;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
@@ -642,6 +710,9 @@ module.exports = {
|
||||
sanitizeSessionId,
|
||||
getSessionIdShort,
|
||||
getGitRepoName,
|
||||
getRepoIdentity,
|
||||
normalizeRepoPath,
|
||||
sameRepoIdentity,
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -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:');
|
||||
|
||||
|
||||
Reference in new issue
Block a user