-
-
Notifications
You must be signed in to change notification settings - Fork 7k
feat(project-name): CLAUDE_MEM_PROJECT_NAME_SOURCE=git-remote for stable org/repo slugs #2827
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,66 @@ import path from 'path'; | |
| import { execFileSync } from 'child_process'; | ||
| import { logger } from './logger.js'; | ||
| import { detectWorktree } from './worktree.js'; | ||
| import { loadFromFileOnce } from '../shared/hook-settings.js'; | ||
|
|
||
| /** | ||
| * Opt-in (CLAUDE_MEM_PROJECT_NAME_SOURCE=git-remote): derive the project name from | ||
| * the git `origin` remote instead of the folder basename. Default ('path') | ||
| * preserves existing behavior. Reading settings is cached (loadFromFileOnce). | ||
| */ | ||
| function useRemoteProjectName(): boolean { | ||
| try { | ||
| return String(loadFromFileOnce().CLAUDE_MEM_PROJECT_NAME_SOURCE ?? 'path') | ||
| .trim() | ||
| .toLowerCase() === 'git-remote'; | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Resolve a stable `org/repo` slug from the repo's `origin` remote URL. This is | ||
| * stable across directory renames (unlike the folder basename) and identical | ||
| * across a repo's worktrees (they share remotes). Handles scp-style | ||
| * (`git@host:org/repo.git`) and URL forms (`https://host/org/repo.git`). | ||
| * Returns null when there is no `origin` remote or the URL can't be parsed. | ||
| */ | ||
| function deriveSlugFromRemote(dir: string): string | null { | ||
| let url: string; | ||
| try { | ||
| url = execFileSync('git', ['remote', 'get-url', 'origin'], { | ||
| cwd: dir, | ||
| encoding: 'utf-8', | ||
| stdio: ['ignore', 'pipe', 'ignore'], | ||
| }).trim(); | ||
| } catch { | ||
| return null; | ||
| } | ||
| return parseOriginUrlToSlug(url); | ||
| } | ||
|
|
||
| /** | ||
| * Pure parser: turn a git remote URL into an `org/repo` slug. Handles scp-style | ||
| * (`git@host:org/repo.git`) and URL forms (`https://host/org/repo.git`, with or | ||
| * without a trailing slash or `.git`). Returns the last two path segments | ||
| * (`org/repo`), a single segment when that's all there is, or null when the URL | ||
| * is empty/unparseable. Exported for unit testing. | ||
| */ | ||
| export function parseOriginUrlToSlug(url: string): string | null { | ||
| if (!url || !url.trim()) return null; | ||
| const cleaned = url.trim().replace(/\.git$/, '').replace(/\/+$/, ''); | ||
| // scp-style: user@host:org/repo → capture the part after the colon. | ||
| const scp = cleaned.match(/^[^/@]+@[^:]+:(.+)$/); | ||
| const pathPart = scp | ||
| ? scp[1] | ||
| // URL form: scheme://host/org/repo → strip scheme + host. | ||
| : cleaned.replace(/^[a-z][a-z0-9+.-]*:\/\/[^/]+\//i, ''); | ||
|
|
||
| const segments = pathPart.split('/').filter(Boolean); | ||
| if (segments.length >= 2) return segments.slice(-2).join('/'); | ||
| if (segments.length === 1) return segments[0]; | ||
|
Comment on lines
+56
to
+63
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a URL doesn't match the scheme+host+slash pattern (e.g. a Prompt To Fix With AIThis is a comment left during a code review.
Path: src/utils/project-name.ts
Line: 56-63
Comment:
**URL-form fallback produces garbage slugs for bare-host URLs**
When a URL doesn't match the scheme+host+slash pattern (e.g. a `file:///home/user/repo` local remote or a URL with no path like `https://github.com`), the `replace` call is a no-op and the full raw string becomes `pathPart`. Splitting `file:///home/user/repo` by `/` yields `['file:', 'home', 'user', 'repo']`, so `slice(-2)` returns `user/repo` — a silently wrong slug derived from filesystem path components instead of project identity. Since `deriveSlugFromRemote` only returns `null` on exec failure, not on parse failure, there is no safety net; the bad slug propagates back to `getProjectName`.
How can I resolve this? If you propose a fix, please make it concise. |
||
| return null; | ||
| } | ||
|
|
||
| function expandTilde(p: string): string { | ||
| if (p === '~' || p.startsWith('~/')) { | ||
|
|
@@ -44,6 +104,14 @@ export function getProjectName(cwd: string | null | undefined): string { | |
| // the name is stable across subdirectories/worktrees. Fall back to the cwd | ||
| // basename when not in a repo. | ||
| const repoRoot = findGitRepoRoot(expanded); | ||
|
|
||
| // Opt-in: derive a stable org/repo slug from the origin remote. Falls through | ||
| // to the folder-basename logic below when disabled, no remote, or unparseable. | ||
| if (repoRoot && useRemoteProjectName()) { | ||
| const slug = deriveSlugFromRemote(repoRoot); | ||
| if (slug) return slug; | ||
| } | ||
|
|
||
| const nameSource = repoRoot ?? expanded; | ||
|
|
||
| const basename = path.basename(nameSource); | ||
|
|
@@ -83,6 +151,18 @@ export function getProjectContext(cwd: string | null | undefined): ProjectContex | |
| const expandedCwd = expandTilde(cwd); | ||
| const worktreeInfo = detectWorktree(expandedCwd); | ||
|
|
||
| // In remote mode the origin URL is the canonical repo identity, and a repo's | ||
| // worktrees share its remotes — so cwdProjectName already collapses worktrees | ||
| // onto the parent repo. Skip the parent/child compositing to avoid doubling. | ||
| if (useRemoteProjectName()) { | ||
| return { | ||
| primary: cwdProjectName, | ||
| parent: null, | ||
| isWorktree: worktreeInfo.isWorktree, | ||
| allProjects: [cwdProjectName] | ||
| }; | ||
| } | ||
|
|
||
| if (worktreeInfo.isWorktree && worktreeInfo.parentProjectName) { | ||
| const composite = `${worktreeInfo.parentProjectName}/${cwdProjectName}`; | ||
| return { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
.gitsuffix replacement runs before the trailing-slash strip, so a URL ending with.git/(e.g.https://github.com/org/repo.git/) is left with.gitin the final slug (org/repo.git). Reversing the two replacements — strip trailing slashes first, then.git— fixes the combination.Prompt To Fix With AI