diff --git a/src/data.js b/src/data.js index b4127fa..4c42f6c 100644 --- a/src/data.js +++ b/src/data.js @@ -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); @@ -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() { @@ -2401,6 +2405,23 @@ function _saveGitRootDiskCache() { } catch {} } +// Parse first `worktree ` 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(); @@ -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 = {}; @@ -5386,5 +5426,8 @@ module.exports = { parseClaudeStructuredMessage, parseStructuredMessage, isFilteredClaudeStructuredMessage, + _parseMainWorktree, + resolveGitRoot, + ALL_HOMES, }, }; diff --git a/test/git-root-resolve.test.js b/test/git-root-resolve.test.js new file mode 100644 index 0000000..62964f4 --- /dev/null +++ b/test/git-root-resolve.test.js @@ -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 }); + } +});