Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 53 additions & 10 deletions src/data.js
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,11 @@ function detectHomes() {
const homes = [];
const seen = new Set();
const addHome = (home) => {
const normalized = normalizeProjectPath(home);
let normalized = normalizeProjectPath(home);
if (!normalized) return;
// Resolve symlinks so the HOME-as-gitRoot guard later compares apples to
// apples — on macOS /var → /private/var, on Docker $HOME is often a symlink.
try { normalized = fs.realpathSync(normalized); } catch {}
const key = process.platform === 'win32' ? normalized.toLowerCase() : normalized;
if (seen.has(key)) return;
seen.add(key);
Expand Down Expand Up @@ -2380,7 +2383,8 @@ function scanCodexSessions() {
// from the session cwd string. Works without git for standard worktree layouts.

const _gitRootCache = {};
const GIT_ROOT_CACHE_FILE = path.join(os.tmpdir(), 'codedash-gitroot-cache.json');
// v2: collapses worktrees to main repo + ignores $HOME-as-git-root.
const GIT_ROOT_CACHE_FILE = path.join(os.tmpdir(), 'codedash-gitroot-cache-v2.json');
let _gitRootDiskCache = null;

function _loadGitRootDiskCache() {
Expand All @@ -2401,6 +2405,23 @@ function _saveGitRootDiskCache() {
} catch {}
}

// Parse first `worktree <path>` entry from `git worktree list --porcelain`.
// The first record is always the main worktree, so a session living inside any
// linked worktree collapses to the main repo's identity. Returns '' for bare
// repos (a `bare` line inside the first record) since they have no working tree.
function _parseMainWorktree(porcelain) {
if (!porcelain) return '';
let candidate = '';
for (const line of porcelain.split('\n')) {
if (candidate && line === 'bare') return '';
if (candidate && line === '') return candidate;
if (!candidate && line.startsWith('worktree ')) {
candidate = line.slice('worktree '.length).trim();
}
}
return candidate;
}

function resolveGitRoot(projectPath) {
if (!projectPath) return '';
_loadGitRootDiskCache();
Expand All @@ -2410,16 +2431,35 @@ function resolveGitRoot(projectPath) {
_gitRootCache[projectPath] = '';
return '';
}
const opts = { encoding: 'utf8', timeout: 2000, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] };
let root = '';
try {
const root = execFileSync('git', ['-C', projectPath, 'rev-parse', '--show-toplevel'], {
encoding: 'utf8', timeout: 2000, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe']
}).trim();
_gitRootCache[projectPath] = root;
return root;
} catch {
_gitRootCache[projectPath] = '';
return '';
const porcelain = execFileSync('git', ['-C', projectPath, 'worktree', 'list', '--porcelain'], opts);
root = _parseMainWorktree(porcelain);
} catch {}
if (!root) {
try {
root = execFileSync('git', ['-C', projectPath, 'rev-parse', '--show-toplevel'], opts).trim();
} catch {}
}
// Bare repos report the .git dir itself; treat them as "no working tree" so
// downstream code doesn't try to read files from a bare path.
if (root && /\.git\/?$/.test(root)) root = '';
// Normalize symlinks so /var and /private/var on macOS produce one cache key
// for the same physical directory — otherwise the same project shows twice.
if (root) {
try { root = fs.realpathSync(root); } catch {}
}
// An accidental .git at $HOME (e.g. a dotfiles repo) would otherwise leak its
// remote onto every session whose only crime was being launched from home.
// On Windows, normalize separators + case so C:\Users\foo == C:/users/FOO.
if (root) {
const norm = (p) => process.platform === 'win32' ? p.replace(/\\/g, '/').toLowerCase() : p;
const rootKey = norm(root);
if (ALL_HOMES.some(h => norm(h) === rootKey)) root = '';
}
_gitRootCache[projectPath] = root;
return root;
}

const _gitInfoCache = {};
Expand Down Expand Up @@ -5386,5 +5426,8 @@ module.exports = {
parseClaudeStructuredMessage,
parseStructuredMessage,
isFilteredClaudeStructuredMessage,
_parseMainWorktree,
resolveGitRoot,
ALL_HOMES,
},
};
97 changes: 97 additions & 0 deletions test/git-root-resolve.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFileSync } = require('child_process');

const { _parseMainWorktree, resolveGitRoot, ALL_HOMES } = require('../src/data').__test;

test('_parseMainWorktree returns first worktree path', () => {
const porcelain = [
'worktree /repos/myproj',
'HEAD abc123',
'branch refs/heads/main',
'',
'worktree /repos/myproj-feature',
'HEAD def456',
'branch refs/heads/feature',
'',
].join('\n');
assert.equal(_parseMainWorktree(porcelain), '/repos/myproj');
});

test('_parseMainWorktree handles empty input', () => {
assert.equal(_parseMainWorktree(''), '');
assert.equal(_parseMainWorktree(null), '');
});

test('_parseMainWorktree returns first worktree line regardless of preceding content', () => {
const porcelain = 'HEAD abc\nworktree /a\n';
assert.equal(_parseMainWorktree(porcelain), '/a');
});

test('_parseMainWorktree returns empty for bare main worktree', () => {
const porcelain = ['worktree /repos/origin.git', 'bare', ''].join('\n');
assert.equal(_parseMainWorktree(porcelain), '');
});

test('resolveGitRoot collapses linked worktree to main repo', () => {
// git resolves symlinks (macOS /var → /private/var), so use realpath upfront.
const tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'codbash-wt-')));
try {
const main = path.join(tmp, 'main');
fs.mkdirSync(main, { recursive: true });
const gitOpts = { cwd: main, stdio: 'ignore', timeout: 5000 };
// Plain `git init` (no -b flag) keeps compatibility with git < 2.28.
execFileSync('git', ['init', '-q'], gitOpts);
execFileSync('git', ['-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '--allow-empty', '-m', 'init'], gitOpts);
const wt = path.join(tmp, 'wt-feature');
execFileSync('git', ['worktree', 'add', '-q', wt, '-b', 'feature'], gitOpts);

// Both the main checkout and the linked worktree must resolve to the main path.
assert.equal(resolveGitRoot(main), main);
assert.equal(resolveGitRoot(wt), main);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});

test('resolveGitRoot ignores $HOME-as-git-root', (t) => {
const home = ALL_HOMES[0];
if (!home || !fs.existsSync(path.join(home, '.git'))) {
t.skip('home is not a git repo on this machine');
return;
}
assert.equal(resolveGitRoot(home), '');
});

test('resolveGitRoot returns empty for bare repos', () => {
const tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'codbash-bare-')));
try {
const bare = path.join(tmp, 'origin.git');
execFileSync('git', ['init', '-q', '--bare', bare], { stdio: 'ignore', timeout: 5000 });
assert.equal(resolveGitRoot(bare), '');
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});

test('resolveGitRoot normalizes symlinked paths to a single key', () => {
// Simulates macOS /var -> /private/var: two input paths for the same dir
// must produce the same git root, not two cache entries.
const tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'codbash-sym-')));
try {
const real = path.join(tmp, 'real');
fs.mkdirSync(real);
const link = path.join(tmp, 'link');
fs.symlinkSync(real, link);
const gitOpts = { cwd: real, stdio: 'ignore', timeout: 5000 };
execFileSync('git', ['init', '-q'], gitOpts);
execFileSync('git', ['-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '--allow-empty', '-m', 'init'], gitOpts);
assert.equal(resolveGitRoot(real), real);
assert.equal(resolveGitRoot(link), real);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
Loading