feat(project-name): CLAUDE_MEM_PROJECT_NAME_SOURCE=git-remote for stable org/repo slugs#2827
feat(project-name): CLAUDE_MEM_PROJECT_NAME_SOURCE=git-remote for stable org/repo slugs#2827surfingdoggo wants to merge 1 commit into
Conversation
… stable org/repo slugs By default the project name is derived from the folder/git-root basename, which silently changes if a checkout directory is renamed and collides when two different repos share a folder name. This adds an opt-in setting, CLAUDE_MEM_PROJECT_NAME_SOURCE (default 'path'), that when set to 'git-remote' derives a stable org/repo slug from the git origin remote URL instead. - Default 'path' preserves existing behavior exactly (no-op for current users). - In git-remote mode, getProjectContext skips parent/child worktree compositing, since a repo's worktrees share its origin and already resolve to one slug. - parseOriginUrlToSlug is a pure, exported helper (scp-style, https, ssh://, optional .git/trailing slash, nested subgroups) with unit tests. - Documented in the configuration reference. Relates to upstream discussion on stable project identity (thedotmack#2663, thedotmack#2711). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Greptile SummaryThis PR adds an opt-in
Confidence Score: 3/5Safe to merge for existing users (default The regex replacement order in src/utils/project-name.ts — specifically the Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A["getProjectName(cwd)"] --> B{"cwd empty?"}
B -- yes --> C["return 'unknown-project'"]
B -- no --> D["findGitRepoRoot"]
D --> E{"repoRoot found?"}
E -- no --> F["basename of cwd"]
E -- yes --> G{"useRemoteProjectName?"}
G -- no --> H["basename of repoRoot"]
G -- yes --> I["deriveSlugFromRemote"]
I --> J{"slug parsed OK?"}
J -- yes --> K["return org/repo slug"]
J -- no --> H
L["getProjectContext(cwd)"] --> M{"cwd null?"}
M -- yes --> N["return cwdProjectName, no worktree info"]
M -- no --> O["detectWorktree"]
O --> P{"useRemoteProjectName?"}
P -- yes --> Q["return cwdProjectName, parent=null, skip compositing"]
P -- no --> R{"isWorktree and parentName?"}
R -- yes --> S["return composite parent/child name"]
R -- no --> T["return cwdProjectName"]
Prompt To Fix All With AIFix the following 2 code review issues. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 2
src/utils/project-name.ts:53
The `.git` suffix replacement runs before the trailing-slash strip, so a URL ending with `.git/` (e.g. `https://github.com/org/repo.git/`) is left with `.git` in the final slug (`org/repo.git`). Reversing the two replacements — strip trailing slashes first, then `.git` — fixes the combination.
```suggestion
const cleaned = url.trim().replace(/\/+$/, '').replace(/\.git$/, '');
```
### Issue 2 of 2
src/utils/project-name.ts:56-63
**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`.
Reviews (1): Last reviewed commit: "feat(project-name): add CLAUDE_MEM_PROJE..." | Re-trigger Greptile |
| */ | ||
| export function parseOriginUrlToSlug(url: string): string | null { | ||
| if (!url || !url.trim()) return null; | ||
| const cleaned = url.trim().replace(/\.git$/, '').replace(/\/+$/, ''); |
There was a problem hiding this comment.
The
.git suffix replacement runs before the trailing-slash strip, so a URL ending with .git/ (e.g. https://github.com/org/repo.git/) is left with .git in the final slug (org/repo.git). Reversing the two replacements — strip trailing slashes first, then .git — fixes the combination.
| const cleaned = url.trim().replace(/\.git$/, '').replace(/\/+$/, ''); | |
| const cleaned = url.trim().replace(/\/+$/, '').replace(/\.git$/, ''); |
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/utils/project-name.ts
Line: 53
Comment:
The `.git` suffix replacement runs before the trailing-slash strip, so a URL ending with `.git/` (e.g. `https://github.com/org/repo.git/`) is left with `.git` in the final slug (`org/repo.git`). Reversing the two replacements — strip trailing slashes first, then `.git` — fixes the combination.
```suggestion
const cleaned = url.trim().replace(/\/+$/, '').replace(/\.git$/, '');
```
How can I resolve this? If you propose a fix, please make it concise.| 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]; |
There was a problem hiding this 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.
Prompt To Fix With AI
This 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.
Problem
claude-mem derives the project name from the folder / git-root basename. That name silently changes if a checkout directory is renamed, and two unrelated repos that happen to share a folder name collide into the same project — scattering or merging memory incorrectly.
Fix
Add an opt-in setting
CLAUDE_MEM_PROJECT_NAME_SOURCE(defaultpath). When set togit-remote, the project name becomes a stableorg/reposlug parsed from the gitoriginremote URL — stable across directory renames and identical across a repo's worktrees (they share the remote).pathis a no-op for existing users — behavior is unchanged unless you opt in.git-remotemode, worktree parent/child compositing is skipped (a repo's worktrees share its origin and already resolve to one slug).parseOriginUrlToSlugis a pure, exported helper covering scp-style (git@host:org/repo.git),https://, andssh://forms, optional.git/trailing slash, and nested subgroups — with unit tests.Changes
src/utils/project-name.ts,src/shared/SettingsDefaultsManager.ts).parseOriginUrlToSlug.Note on CI
maincurrently has unrelated pre-existing failures (PID-file / process-manager and logger-standards tests, plus a duplicateModeManagerimport typecheck error). This change is isolated to project-name resolution; theproject-nametest suite passes (29/29).Relates to upstream discussion on stable project identity (#2663, #2711).
🤖 Generated with Claude Code