Skip to content
Open
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
36 changes: 36 additions & 0 deletions src/utils/git-root.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
19 changes: 14 additions & 5 deletions src/utils/project-name.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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);

Expand Down Expand Up @@ -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 {
Expand All @@ -80,6 +90,5 @@ export function getProjectContext(cwd: string | null | undefined): ProjectContex
allProjects: [worktreeInfo.parentProjectName, primary]
};
}

return { primary, parent: null, isWorktree: false, allProjects: [primary] };
}
68 changes: 68 additions & 0 deletions tests/utils/git-root.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
});
});
});
58 changes: 58 additions & 0 deletions tests/utils/project-name.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
});
});
});