diff --git a/src/utils/project-name.ts b/src/utils/project-name.ts index 287f4272..6e8e88ff 100644 --- a/src/utils/project-name.ts +++ b/src/utils/project-name.ts @@ -1,12 +1,24 @@ +import { homedir } from 'os' import path from 'path'; import { logger } from './logger.js'; import { detectWorktree } from './worktree.js'; +/** + * Expand leading ~ to the user's home directory. + * Handles "~", "~/", and "~/subpath" but not "~user/" (which is rare in cwd). + */ +function expandTilde(p: string): string { + if (p === '~' || p.startsWith('~/')) { + return p.replace(/^~/, homedir()) + } + return p +} + /** * Extract project name from working directory path - * Handles edge cases: null/undefined cwd, drive roots, trailing slashes + * Handles edge cases: null/undefined cwd, drive roots, trailing slashes, unexpanded ~ * - * @param cwd - Current working directory (absolute path) + * @param cwd - Current working directory (absolute path, or ~-prefixed path) * @returns Project name or "unknown-project" if extraction fails */ export function getProjectName(cwd: string | null | undefined): string { @@ -15,8 +27,11 @@ export function getProjectName(cwd: string | null | undefined): string { return 'unknown-project'; } + // Expand leading ~ before path operations + const expanded = expandTilde(cwd) + // Extract basename (handles trailing slashes automatically) - const basename = path.basename(cwd); + const basename = path.basename(expanded); // Edge case: Drive roots on Windows (C:\, J:\) or Unix root (/) // path.basename('C:\') returns '' (empty string) @@ -69,7 +84,8 @@ export function getProjectContext(cwd: string | null | undefined): ProjectContex return { primary, parent: null, isWorktree: false, allProjects: [primary] }; } - const worktreeInfo = detectWorktree(cwd); + const expandedCwd = expandTilde(cwd); + const worktreeInfo = detectWorktree(expandedCwd); if (worktreeInfo.isWorktree && worktreeInfo.parentProjectName) { // In a worktree: include parent first for chronological ordering diff --git a/tests/utils/project-name.test.ts b/tests/utils/project-name.test.ts new file mode 100644 index 00000000..2354cbcd --- /dev/null +++ b/tests/utils/project-name.test.ts @@ -0,0 +1,99 @@ +/** + * Project Name Tests + * + * Tests tilde expansion and project name extraction. + * Source: src/utils/project-name.ts + */ + +import { describe, it, expect } 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(); + }); +});