Files
claude-mem/tests/utils/project-name.test.ts
T
Ethan 16a0737dfc fix: use parent project name for worktree observation writes (#1820)
* fix: use parent project name for worktree observation writes (#1819)

Observations and sessions from git worktrees were stored under
basename(cwd) instead of the parent repo name because write paths
called getProjectName() (not worktree-aware) instead of
getProjectContext() (worktree-aware). This is the same bug as
#1081, #1317, and #1500 — it regressed because the two functions
coexist and new code reached for the simpler one.

Fix: getProjectContext() now returns parentProjectName as primary
when in a worktree, and all four write-path call sites now use
getProjectContext().primary instead of getProjectName().

Includes regression test that creates a real worktree directory
structure and asserts primary === parentProjectName.

* fix: address review nitpicks — allProjects fallback, JSDoc, write-path test

- ContextBuilder: default projects to context.allProjects for legacy
  worktree-labeled record compatibility
- ProjectContext: clarify JSDoc that primary is canonical (parent repo
  in worktrees)
- Tests: add write-path regression test mirroring session-init/SessionRoutes
  pattern; refactor worktree fixture into beforeAll/afterAll

* refactor(project-name): rename local to cwdProjectName and dedupe allProjects

Addresses final CodeRabbit nitpick: disambiguates the local variable
from the returned `primary` field, and dedupes allProjects via Set
in case parent and cwd resolve to the same name.

---------

Co-authored-by: Ethan Hurst <ethan.hurst@outlook.com.au>
2026-04-15 00:58:14 -07:00

147 lines
4.8 KiB
TypeScript

/**
* Project Name Tests
*
* Tests tilde expansion and project name extraction.
* Source: src/utils/project-name.ts
*/
import { describe, it, expect, beforeAll, afterAll } from 'bun:test';
import { homedir } from 'os';
import { getProjectName, getProjectContext } from '../../src/utils/project-name.js';
describe('getProjectName', () => {
describe('tilde expansion', () => {
it('resolves bare ~ to home directory basename', () => {
const home = homedir();
const expected = home.split('/').pop() || home.split('\\').pop() || '';
expect(getProjectName('~')).toBe(expected);
});
it('resolves ~/subpath to subpath', () => {
expect(getProjectName('~/projects/my-app')).toBe('my-app');
});
it('resolves ~/ to home directory basename', () => {
const home = homedir();
const expected = home.split('/').pop() || home.split('\\').pop() || '';
expect(getProjectName('~/')).toBe(expected);
});
});
describe('normal paths', () => {
it('extracts basename from absolute path', () => {
expect(getProjectName('/home/user/my-project')).toBe('my-project');
});
it('extracts basename from nested path', () => {
expect(getProjectName('/Users/test/work/deep/nested/project')).toBe('project');
});
it('handles trailing slash', () => {
expect(getProjectName('/home/user/my-project/')).toBe('my-project');
});
});
describe('edge cases', () => {
it('returns unknown-project for null', () => {
expect(getProjectName(null)).toBe('unknown-project');
});
it('returns unknown-project for undefined', () => {
expect(getProjectName(undefined)).toBe('unknown-project');
});
it('returns unknown-project for empty string', () => {
expect(getProjectName('')).toBe('unknown-project');
});
it('returns unknown-project for whitespace', () => {
expect(getProjectName(' ')).toBe('unknown-project');
});
});
describe('realistic scenarios from #1478', () => {
it('handles ~ the same as full home path', () => {
const home = homedir();
expect(getProjectName('~')).toBe(getProjectName(home));
});
it('handles ~/projects/app the same as /full/path/projects/app', () => {
const home = homedir();
expect(getProjectName('~/projects/app')).toBe(
getProjectName(`${home}/projects/app`)
);
});
});
});
describe('getProjectContext', () => {
it('returns primary project name for normal path', () => {
const ctx = getProjectContext('/home/user/my-project');
expect(ctx.primary).toBe('my-project');
expect(ctx.parent).toBeNull();
expect(ctx.isWorktree).toBe(false);
expect(ctx.allProjects).toEqual(['my-project']);
});
it('resolves ~ path correctly', () => {
const home = homedir();
const ctx = getProjectContext('~');
const ctxHome = getProjectContext(home);
expect(ctx.primary).toBe(ctxHome.primary);
});
it('returns unknown-project context for null', () => {
const ctx = getProjectContext(null);
expect(ctx.primary).toBe('unknown-project');
expect(ctx.parent).toBeNull();
});
describe('worktree regression (#1081, #1500, #1819)', () => {
let tmp: string;
let mainRepo: string;
let worktreeCheckout: string;
beforeAll(async () => {
const { mkdtempSync, mkdirSync, writeFileSync } = await import('fs');
const { join } = await import('path');
const { tmpdir } = await import('os');
tmp = mkdtempSync(join(tmpdir(), 'cm-wt-'));
mainRepo = join(tmp, 'main-repo');
const worktreeGitDir = join(mainRepo, '.git', 'worktrees', 'my-worktree');
worktreeCheckout = join(tmp, 'my-worktree');
mkdirSync(worktreeGitDir, { recursive: true });
mkdirSync(worktreeCheckout, { recursive: true });
writeFileSync(
join(worktreeCheckout, '.git'),
`gitdir: ${worktreeGitDir}\n`
);
});
afterAll(async () => {
const { rmSync } = await import('fs');
rmSync(tmp, { recursive: true, force: true });
});
it('uses parent project name as primary when in a worktree', () => {
const ctx = getProjectContext(worktreeCheckout);
expect(ctx.isWorktree).toBe(true);
expect(ctx.primary).toBe('main-repo');
expect(ctx.parent).toBe('main-repo');
expect(ctx.allProjects).toEqual(['main-repo', 'my-worktree']);
});
it('write-path call sites resolve to parent project in worktrees', () => {
// Mirrors the pattern used by session-init.ts and SessionRoutes.ts:
// const project = getProjectContext(cwd).primary;
// This must resolve to the parent repo, not the worktree name,
// so observations are stored under the correct project.
const project = getProjectContext(worktreeCheckout).primary;
expect(project).toBe('main-repo');
expect(project).not.toBe('my-worktree');
});
});
});