From 5e87ea145601ba35505727a0a5b98f71120caaa6 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Wed, 24 Jun 2026 20:38:54 -0400 Subject: [PATCH 1/4] fix(context): use cwd basename for project name in monorepo subdirectories (closes #2882) --- src/utils/project-name.ts | 14 +++++++++++++- tests/utils/project-name.test.ts | 12 ++++++++++-- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/utils/project-name.ts b/src/utils/project-name.ts index 48bb9bd5b3..ecf056d8cc 100644 --- a/src/utils/project-name.ts +++ b/src/utils/project-name.ts @@ -32,6 +32,14 @@ function findGitRepoRoot(dir: string): string | null { } } +function samePath(a: string, b: string): boolean { + const left = path.resolve(a); + const right = path.resolve(b); + return process.platform === 'win32' + ? left.toLowerCase() === right.toLowerCase() + : left === right; +} + export function getProjectName(cwd: string | null | undefined): string { if (!cwd || cwd.trim() === '') { logger.warn('PROJECT_NAME', 'Empty cwd provided, using fallback', { cwd }); @@ -43,8 +51,12 @@ export function getProjectName(cwd: string | null | undefined): string { // #2663 — derive the project name from the git repo root when inside a repo so // the name is stable across subdirectories/worktrees. Fall back to the cwd // basename when not in a repo. + // #2882 — when cwd is a subdirectory of the repo root (monorepo package), + // use the cwd basename instead of the repo root basename. const repoRoot = findGitRepoRoot(expanded); - const nameSource = repoRoot ?? expanded; + const nameSource = repoRoot && samePath(expanded, repoRoot) + ? repoRoot + : expanded; const basename = path.basename(nameSource); diff --git a/tests/utils/project-name.test.ts b/tests/utils/project-name.test.ts index 648ada75e4..6a354c991e 100644 --- a/tests/utils/project-name.test.ts +++ b/tests/utils/project-name.test.ts @@ -79,14 +79,22 @@ describe('getProjectName', () => { rmSync(tmp, { recursive: true, force: true }); }); - it('deep subdirectory inside a repo yields the repo-root name', () => { - expect(getProjectName(nestedDir)).toBe('my-real-repo'); + it('deep subdirectory inside a repo yields the cwd basename', () => { + expect(getProjectName(nestedDir)).toBe('nested'); }); it('repo root itself yields the repo-root name', () => { expect(getProjectName(repoRoot)).toBe('my-real-repo'); }); + it('package directory inside a monorepo yields the package basename', () => { + const { mkdirSync } = require('fs'); + const { join } = require('path'); + const packageDir = join(repoRoot, 'packages', 'api'); + mkdirSync(packageDir, { recursive: true }); + expect(getProjectName(packageDir)).toBe('api'); + }); + it('non-repo path falls back to basename(cwd)', () => { // A path that does not exist (and therefore cannot be in a repo) must // fall back to basename(cwd) rather than throwing or returning a root. From 5115a77885bb4a7ed8982aa6ad3c4329d801d444 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Wed, 24 Jun 2026 21:01:25 -0400 Subject: [PATCH 2/4] fix(context): resolve symlinks in samePath to avoid repo-root misclassification --- src/utils/project-name.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/utils/project-name.ts b/src/utils/project-name.ts index ecf056d8cc..6b3f85e8b9 100644 --- a/src/utils/project-name.ts +++ b/src/utils/project-name.ts @@ -1,5 +1,6 @@ import { homedir } from 'os' import path from 'path'; +import { realpathSync } from 'fs'; import { execFileSync } from 'child_process'; import { logger } from './logger.js'; import { detectWorktree } from './worktree.js'; @@ -33,8 +34,9 @@ function findGitRepoRoot(dir: string): string | null { } function samePath(a: string, b: string): boolean { - const left = path.resolve(a); - const right = path.resolve(b); + const realOrResolve = (p: string) => { try { return realpathSync(path.resolve(p)); } catch { return path.resolve(p); } }; + const left = realOrResolve(a); + const right = realOrResolve(b); return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right; From ec02f79ffbb0bbf808a341e8169c46a0de37a0cd Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Wed, 24 Jun 2026 21:27:31 -0400 Subject: [PATCH 3/4] fix(context): narrow monorepo detection to package.json roots and linked worktrees --- src/utils/project-name.ts | 24 ++++++++++++++++++------ tests/utils/project-name.test.ts | 7 ++++--- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/src/utils/project-name.ts b/src/utils/project-name.ts index 6b3f85e8b9..289d8b8349 100644 --- a/src/utils/project-name.ts +++ b/src/utils/project-name.ts @@ -1,6 +1,6 @@ import { homedir } from 'os' import path from 'path'; -import { realpathSync } from 'fs'; +import { realpathSync, statSync } from 'fs'; import { execFileSync } from 'child_process'; import { logger } from './logger.js'; import { detectWorktree } from './worktree.js'; @@ -42,6 +42,16 @@ function samePath(a: string, b: string): boolean { : left === right; } +// .git FILE (not dir) means this is a linked worktree root, not the main repo. +function isLinkedWorktreeRoot(dir: string): boolean { + try { return statSync(path.join(dir, '.git')).isFile(); } catch { return false; } +} + +// Treat cwd as an independent package only when it has its own package.json. +function hasOwnPackageJson(dir: string): boolean { + try { return statSync(path.join(dir, 'package.json')).isFile(); } catch { return false; } +} + export function getProjectName(cwd: string | null | undefined): string { if (!cwd || cwd.trim() === '') { logger.warn('PROJECT_NAME', 'Empty cwd provided, using fallback', { cwd }); @@ -53,12 +63,14 @@ export function getProjectName(cwd: string | null | undefined): string { // #2663 — derive the project name from the git repo root when inside a repo so // the name is stable across subdirectories/worktrees. Fall back to the cwd // basename when not in a repo. - // #2882 — when cwd is a subdirectory of the repo root (monorepo package), - // use the cwd basename instead of the repo root basename. + // #2882 — when cwd is a package root inside a monorepo (has its own package.json) + // or a linked worktree root (has a .git file), use the cwd basename so each + // package/worktree gets an independent project name. const repoRoot = findGitRepoRoot(expanded); - const nameSource = repoRoot && samePath(expanded, repoRoot) - ? repoRoot - : expanded; + const isSubdir = repoRoot != null && !samePath(expanded, repoRoot); + const nameSource = isSubdir && (isLinkedWorktreeRoot(expanded) || hasOwnPackageJson(expanded)) + ? expanded + : (repoRoot ?? expanded); const basename = path.basename(nameSource); diff --git a/tests/utils/project-name.test.ts b/tests/utils/project-name.test.ts index 6a354c991e..ea00df59b9 100644 --- a/tests/utils/project-name.test.ts +++ b/tests/utils/project-name.test.ts @@ -79,8 +79,8 @@ describe('getProjectName', () => { rmSync(tmp, { recursive: true, force: true }); }); - it('deep subdirectory inside a repo yields the cwd basename', () => { - expect(getProjectName(nestedDir)).toBe('nested'); + it('deep subdirectory without package.json yields the repo-root name', () => { + expect(getProjectName(nestedDir)).toBe('my-real-repo'); }); it('repo root itself yields the repo-root name', () => { @@ -88,10 +88,11 @@ describe('getProjectName', () => { }); it('package directory inside a monorepo yields the package basename', () => { - const { mkdirSync } = require('fs'); + const { mkdirSync, writeFileSync } = require('fs'); const { join } = require('path'); const packageDir = join(repoRoot, 'packages', 'api'); mkdirSync(packageDir, { recursive: true }); + writeFileSync(join(packageDir, 'package.json'), JSON.stringify({ name: 'api' })); expect(getProjectName(packageDir)).toBe('api'); }); From 391e55c3451751b356ddd1c6b75da99b889df6a8 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Wed, 24 Jun 2026 21:34:05 -0400 Subject: [PATCH 4/4] fix(context): walk upward to package.json so package subdirs share the package name --- src/utils/project-name.ts | 32 ++++++++++++++++++++++++-------- tests/utils/project-name.test.ts | 9 +++++++++ 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/src/utils/project-name.ts b/src/utils/project-name.ts index 289d8b8349..40baaa047e 100644 --- a/src/utils/project-name.ts +++ b/src/utils/project-name.ts @@ -47,9 +47,23 @@ function isLinkedWorktreeRoot(dir: string): boolean { try { return statSync(path.join(dir, '.git')).isFile(); } catch { return false; } } -// Treat cwd as an independent package only when it has its own package.json. -function hasOwnPackageJson(dir: string): boolean { - try { return statSync(path.join(dir, 'package.json')).isFile(); } catch { return false; } +// Walk from start toward repoRoot, returning the nearest directory that has a +// package.json. Stops when it reaches repoRoot (inclusive). Returns null when +// no directory in the walk has package.json. +function findNearestPackageRoot(start: string, repoRoot: string): string | null { + const resolve = (p: string) => { try { return realpathSync(path.resolve(p)); } catch { return path.resolve(p); } }; + const rootReal = resolve(repoRoot); + const rootNorm = process.platform === 'win32' ? rootReal.toLowerCase() : rootReal; + let dir = path.resolve(start); + while (true) { + try { if (statSync(path.join(dir, 'package.json')).isFile()) return dir; } catch { /* not found */ } + const dirNorm = process.platform === 'win32' ? resolve(dir).toLowerCase() : resolve(dir); + if (dirNorm === rootNorm) break; + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + return null; } export function getProjectName(cwd: string | null | undefined): string { @@ -63,14 +77,16 @@ export function getProjectName(cwd: string | null | undefined): string { // #2663 — derive the project name from the git repo root when inside a repo so // the name is stable across subdirectories/worktrees. Fall back to the cwd // basename when not in a repo. - // #2882 — when cwd is a package root inside a monorepo (has its own package.json) - // or a linked worktree root (has a .git file), use the cwd basename so each - // package/worktree gets an independent project name. + // #2882 — when cwd is inside a monorepo package, use the nearest ancestor with + // package.json as the boundary so all sessions within a package share one name. + // Linked worktree roots (.git file) always use the cwd basename for correct + // parent/leaf composite naming. const repoRoot = findGitRepoRoot(expanded); const isSubdir = repoRoot != null && !samePath(expanded, repoRoot); - const nameSource = isSubdir && (isLinkedWorktreeRoot(expanded) || hasOwnPackageJson(expanded)) + const nearestPkg = isSubdir ? findNearestPackageRoot(expanded, repoRoot!) : null; + const nameSource = isLinkedWorktreeRoot(expanded) ? expanded - : (repoRoot ?? expanded); + : (nearestPkg ?? repoRoot ?? expanded); const basename = path.basename(nameSource); diff --git a/tests/utils/project-name.test.ts b/tests/utils/project-name.test.ts index ea00df59b9..c816c77c5b 100644 --- a/tests/utils/project-name.test.ts +++ b/tests/utils/project-name.test.ts @@ -96,6 +96,15 @@ describe('getProjectName', () => { expect(getProjectName(packageDir)).toBe('api'); }); + it('subdirectory inside a monorepo package shares the package basename', () => { + const { mkdirSync } = require('fs'); + const { join } = require('path'); + // api/package.json was created by the previous test; src/ has none + const packageSrcDir = join(repoRoot, 'packages', 'api', 'src'); + mkdirSync(packageSrcDir, { recursive: true }); + expect(getProjectName(packageSrcDir)).toBe('api'); + }); + it('non-repo path falls back to basename(cwd)', () => { // A path that does not exist (and therefore cannot be in a repo) must // fall back to basename(cwd) rather than throwing or returning a root.