Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/public/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ Use [LiteLLM Gateway](configuration/litellm-gateway) when you want `CLAUDE_MEM_P
| `CLAUDE_MEM_LOG_LEVEL` | `INFO` | Log verbosity (DEBUG, INFO, WARN, ERROR, SILENT) |
| `CLAUDE_MEM_PYTHON_VERSION` | `3.13` | Python version for chroma-mcp |
| `CLAUDE_CODE_PATH` | _(auto-detect)_ | Path to Claude Code CLI (for Windows) |
| `CLAUDE_MEM_PROJECT_NAME_SOURCE` | `path` | How the project name is derived: `path` (folder/git-root basename) or `git-remote` (stable `org/repo` slug from the git `origin` URL — survives directory renames). Opt-in; `path` preserves existing behavior |

## Model Configuration

Expand Down
2 changes: 2 additions & 0 deletions src/shared/SettingsDefaultsManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export interface SettingsDefaults {
CLAUDE_MEM_SERVER_BETA_URL: string;
CLAUDE_MEM_SERVER_BETA_API_KEY: string;
CLAUDE_MEM_SERVER_BETA_PROJECT_ID: string;
CLAUDE_MEM_PROJECT_NAME_SOURCE: string;
}

export class SettingsDefaultsManager {
Expand Down Expand Up @@ -160,6 +161,7 @@ export class SettingsDefaultsManager {
CLAUDE_MEM_SERVER_BETA_URL: `http://127.0.0.1:${process.env.CLAUDE_MEM_SERVER_PORT ?? String(37877 + ((process.getuid?.() ?? 77) % 100))}`, // Default server-beta runtime URL — UID-derived for multi-account isolation
CLAUDE_MEM_SERVER_BETA_API_KEY: '', // Local hook API key, populated by installer when runtime=server-beta
CLAUDE_MEM_SERVER_BETA_PROJECT_ID: '', // Default Postgres project_id used by hooks when runtime=server-beta
CLAUDE_MEM_PROJECT_NAME_SOURCE: 'path', // 'path' (default) = folder/git-root basename; 'git-remote' = derive a stable org/repo slug from the git `origin` URL (survives directory renames). Opt-in; default preserves existing behavior.
};

static getAllDefaults(): SettingsDefaults {
Expand Down
80 changes: 80 additions & 0 deletions src/utils/project-name.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(/\/+$/, '');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Suggested change
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.

// 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

return null;
}

function expandTilde(p: string): string {
if (p === '~' || p.startsWith('~/')) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 {
Expand Down
41 changes: 40 additions & 1 deletion tests/utils/project-name.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@

import { describe, it, expect, beforeAll, afterAll } from 'bun:test';
import { homedir } from 'os';
import { getProjectName, getProjectContext } from '../../src/utils/project-name.js';
import { getProjectName, getProjectContext, parseOriginUrlToSlug } from '../../src/utils/project-name.js';

describe('getProjectName', () => {
describe('tilde expansion', () => {
Expand Down Expand Up @@ -175,3 +175,42 @@ describe('getProjectContext', () => {
});
});
});

describe('parseOriginUrlToSlug — CLAUDE_MEM_PROJECT_NAME_SOURCE=git-remote', () => {
it('parses scp-style ssh URLs', () => {
expect(parseOriginUrlToSlug('git@github.com:thedotmack/claude-mem.git')).toBe('thedotmack/claude-mem');
});

it('parses https URLs', () => {
expect(parseOriginUrlToSlug('https://github.com/thedotmack/claude-mem.git')).toBe('thedotmack/claude-mem');
});

it('parses ssh:// URLs', () => {
expect(parseOriginUrlToSlug('ssh://git@github.com/thedotmack/claude-mem.git')).toBe('thedotmack/claude-mem');
});

it('tolerates a missing .git suffix', () => {
expect(parseOriginUrlToSlug('https://github.com/thedotmack/claude-mem')).toBe('thedotmack/claude-mem');
});

it('tolerates a trailing slash', () => {
expect(parseOriginUrlToSlug('https://github.com/thedotmack/claude-mem/')).toBe('thedotmack/claude-mem');
});

it('takes the last two segments for nested groups (e.g. GitLab subgroups)', () => {
expect(parseOriginUrlToSlug('https://gitlab.com/group/subgroup/repo.git')).toBe('subgroup/repo');
});

it('handles self-hosted hosts with ports (scp-style)', () => {
expect(parseOriginUrlToSlug('git@frango:money-marathon/prolific.git')).toBe('money-marathon/prolific');
});

it('returns a single segment when that is all there is', () => {
expect(parseOriginUrlToSlug('git@github.com:solorepo.git')).toBe('solorepo');
});

it('returns null for empty / blank input', () => {
expect(parseOriginUrlToSlug('')).toBeNull();
expect(parseOriginUrlToSlug(' ')).toBeNull();
});
});