diff --git a/src/utils/git-root.ts b/src/utils/git-root.ts new file mode 100644 index 000000000..98de59ca1 --- /dev/null +++ b/src/utils/git-root.ts @@ -0,0 +1,36 @@ +/** + * Git Root Detection Utility + * + * Walks up the directory tree from a given path to find the nearest + * git repository root (directory containing a .git entry). + */ +import { statSync } from 'fs'; +import path from 'path'; + +/** + * Find the git repository root by walking up the directory tree. + * + * @param cwd - Starting directory (absolute path) + * @returns Absolute path to the git root, or null if not in a git repo + */ +export function findGitRoot(cwd: string | null | undefined): string | null { + if (!cwd || cwd.trim() === '') return null; + + let current = path.resolve(cwd); + + while (true) { + const gitPath = path.join(current, '.git'); + try { + statSync(gitPath); + // .git exists (file or directory) - this is the repo root + return current; + } catch { + // .git not found here, walk up + } + + const parent = path.dirname(current); + // Reached filesystem root with no .git found + if (parent === current) return null; + current = parent; + } +} diff --git a/src/utils/project-name.ts b/src/utils/project-name.ts index 287f42725..8cdee90ed 100644 --- a/src/utils/project-name.ts +++ b/src/utils/project-name.ts @@ -1,9 +1,12 @@ import path from 'path'; import { logger } from './logger.js'; import { detectWorktree } from './worktree.js'; +import { findGitRoot } from './git-root.js'; /** - * Extract project name from working directory path + * Extract project name from working directory path. + * Prefers the git repository root name over the raw cwd basename, + * so that running from any subdirectory yields a consistent project name. * Handles edge cases: null/undefined cwd, drive roots, trailing slashes * * @param cwd - Current working directory (absolute path) @@ -15,6 +18,16 @@ export function getProjectName(cwd: string | null | undefined): string { return 'unknown-project'; } + // Prefer git root name for consistent project identification across subdirectories + const gitRoot = findGitRoot(cwd); + if (gitRoot) { + const gitRootName = path.basename(gitRoot); + if (gitRootName !== '') { + logger.debug('PROJECT_NAME', 'Using git root as project name', { cwd, gitRoot, gitRootName }); + return gitRootName; + } + } + // Extract basename (handles trailing slashes automatically) const basename = path.basename(cwd); @@ -64,13 +77,10 @@ export interface ProjectContext { */ export function getProjectContext(cwd: string | null | undefined): ProjectContext { const primary = getProjectName(cwd); - if (!cwd) { return { primary, parent: null, isWorktree: false, allProjects: [primary] }; } - const worktreeInfo = detectWorktree(cwd); - if (worktreeInfo.isWorktree && worktreeInfo.parentProjectName) { // In a worktree: include parent first for chronological ordering return { @@ -80,6 +90,5 @@ export function getProjectContext(cwd: string | null | undefined): ProjectContex allProjects: [worktreeInfo.parentProjectName, primary] }; } - return { primary, parent: null, isWorktree: false, allProjects: [primary] }; } diff --git a/tests/utils/git-root.test.ts b/tests/utils/git-root.test.ts new file mode 100644 index 000000000..2625b42eb --- /dev/null +++ b/tests/utils/git-root.test.ts @@ -0,0 +1,68 @@ +/** + * Git Root Detection Tests + * + * Tests walking up the directory tree to find the nearest .git root. + * Source: src/utils/git-root.ts + */ +import { describe, it, expect } from 'bun:test'; +import { findGitRoot } from '../../src/utils/git-root.js'; +import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from 'fs'; +import path from 'path'; +import os from 'os'; + +describe('findGitRoot', () => { + describe('with no git repo', () => { + it('returns null for a directory with no .git', () => { + const tmp = mkdtempSync(path.join(os.tmpdir(), 'claude-mem-test-')); + try { + expect(findGitRoot(tmp)).toBeNull(); + } finally { + rmSync(tmp, { recursive: true }); + } + }); + + it('returns null for null input', () => { + expect(findGitRoot(null)).toBeNull(); + }); + + it('returns null for empty string', () => { + expect(findGitRoot('')).toBeNull(); + }); + }); + + describe('with a git repo', () => { + it('finds .git in the current directory', () => { + const tmp = mkdtempSync(path.join(os.tmpdir(), 'claude-mem-test-')); + try { + mkdirSync(path.join(tmp, '.git')); + expect(findGitRoot(tmp)).toBe(tmp); + } finally { + rmSync(tmp, { recursive: true }); + } + }); + + it('finds .git by walking up from a subdirectory', () => { + const tmp = mkdtempSync(path.join(os.tmpdir(), 'claude-mem-test-')); + try { + mkdirSync(path.join(tmp, '.git')); + const subdir = path.join(tmp, 'src', 'utils'); + mkdirSync(subdir, { recursive: true }); + expect(findGitRoot(subdir)).toBe(tmp); + } finally { + rmSync(tmp, { recursive: true }); + } + }); + + it('finds .git as a file (worktree)', () => { + const tmp = mkdtempSync(path.join(os.tmpdir(), 'claude-mem-test-')); + try { + writeFileSync(path.join(tmp, '.git'), 'gitdir: /some/other/path'); + const subdir = path.join(tmp, 'src'); + mkdirSync(subdir, { recursive: true }); + expect(findGitRoot(subdir)).toBe(tmp); + } finally { + rmSync(tmp, { recursive: true }); + } + }); + }); +}); diff --git a/tests/utils/project-name.test.ts b/tests/utils/project-name.test.ts new file mode 100644 index 000000000..cd5453dea --- /dev/null +++ b/tests/utils/project-name.test.ts @@ -0,0 +1,58 @@ +/** + * Project Name Tests + * + * Tests project name extraction with git root detection. + * Source: src/utils/project-name.ts + */ +import { describe, it, expect } from 'bun:test'; +import { getProjectName } from '../../src/utils/project-name.js'; +import { mkdirSync, mkdtempSync, rmSync } from 'fs'; +import path from 'path'; +import os from 'os'; + +describe('getProjectName', () => { + describe('with null/empty input', () => { + it('returns unknown-project for null', () => { + expect(getProjectName(null)).toBe('unknown-project'); + }); + + it('returns unknown-project for empty string', () => { + expect(getProjectName('')).toBe('unknown-project'); + }); + }); + + describe('with git repo', () => { + it('uses git root name when in repo root', () => { + const tmp = mkdtempSync(path.join(os.tmpdir(), 'my-project-')); + try { + mkdirSync(path.join(tmp, '.git')); + expect(getProjectName(tmp)).toBe(path.basename(tmp)); + } finally { + rmSync(tmp, { recursive: true }); + } + }); + + it('uses git root name when in subdirectory', () => { + const tmp = mkdtempSync(path.join(os.tmpdir(), 'my-project-')); + try { + mkdirSync(path.join(tmp, '.git')); + const subdir = path.join(tmp, 'src', 'utils'); + mkdirSync(subdir, { recursive: true }); + expect(getProjectName(subdir)).toBe(path.basename(tmp)); + } finally { + rmSync(tmp, { recursive: true }); + } + }); + }); + + describe('without git repo', () => { + it('falls back to basename of cwd', () => { + const tmp = mkdtempSync(path.join(os.tmpdir(), 'claude-mem-test-')); + try { + expect(getProjectName(tmp)).toBe(path.basename(tmp)); + } finally { + rmSync(tmp, { recursive: true }); + } + }); + }); +});