fix(project-name): expand ~ to home directory before project resolution
Fixes #1478 When a terminal reports cwd as '~' or '~/subpath' instead of the full path, getProjectName() fell through to the 'unknown-project' fallback because path.basename('~') returns '~' as-is. Added expandTilde() helper that resolves leading ~ to os.homedir(), called in both getProjectName() and getProjectContext() before path operations and worktree detection.
This commit is contained in:
@@ -1,12 +1,24 @@
|
|||||||
|
import { homedir } from 'os'
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import { detectWorktree } from './worktree.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
|
* 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
|
* @returns Project name or "unknown-project" if extraction fails
|
||||||
*/
|
*/
|
||||||
export function getProjectName(cwd: string | null | undefined): string {
|
export function getProjectName(cwd: string | null | undefined): string {
|
||||||
@@ -15,8 +27,11 @@ export function getProjectName(cwd: string | null | undefined): string {
|
|||||||
return 'unknown-project';
|
return 'unknown-project';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Expand leading ~ before path operations
|
||||||
|
const expanded = expandTilde(cwd)
|
||||||
|
|
||||||
// Extract basename (handles trailing slashes automatically)
|
// 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 (/)
|
// Edge case: Drive roots on Windows (C:\, J:\) or Unix root (/)
|
||||||
// path.basename('C:\') returns '' (empty string)
|
// 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] };
|
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) {
|
if (worktreeInfo.isWorktree && worktreeInfo.parentProjectName) {
|
||||||
// In a worktree: include parent first for chronological ordering
|
// In a worktree: include parent first for chronological ordering
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user