Skip to content

fix(discovery): list sessions from git worktrees#191

Merged
matt1398 merged 6 commits into
matt1398:mainfrom
Arshgill01:fix/worktree-sessions-dropdown
May 6, 2026
Merged

fix(discovery): list sessions from git worktrees#191
matt1398 merged 6 commits into
matt1398:mainfrom
Arshgill01:fix/worktree-sessions-dropdown

Conversation

@Arshgill01
Copy link
Copy Markdown
Contributor

@Arshgill01 Arshgill01 commented May 3, 2026

GitIdentityResolver previously only looked for a .git file/directory exactly at projectPath instead of searching upwards to the project root. This caused discovery to fail when Claude was run inside a subdirectory of a git worktree or normal repository. Without a resolved identity, WorktreeGrouper fell back to heuristic path parsing and incorrectly split the session into a phantom repository group named after the subdirectory, preventing it from appearing in the worktree/session dropdown under the main repository.

Fix:

  • Modify resolveIdentity, isWorktree, getBranch, detectWorktreeSource, and getGitWorktreeName to search upwards for the .git file/directory.
  • Add test coverage for GitIdentityResolver and WorktreeGrouper to ensure worktrees and subdirectories group correctly.

Fixes #188.

Summary by CodeRabbit

  • Bug Fixes

    • Improved Git worktree recognition and handling: upward search for the nearest repo marker, correct resolution of worktree pointers (including relative paths), reliable detection of main repo vs worktree, and consistent branch/main-repo resolution when invoked from subdirectories or worktree locations.
  • Tests

    • Added end-to-end tests covering main repo vs worktree identities, upward-search scenarios, and repository grouping validation.

GitIdentityResolver previously only looked for a `.git` file/directory exactly at `projectPath` instead of searching upwards to the project root. This caused discovery to fail when Claude was run inside a subdirectory of a git worktree or normal repository. Without a resolved identity, `WorktreeGrouper` fell back to heuristic path parsing and incorrectly split the session into a phantom repository group named after the subdirectory, preventing it from appearing in the worktree/session dropdown under the main repository.

Fix:
- Modify `resolveIdentity`, `isWorktree`, `getBranch`, `detectWorktreeSource`, and `getGitWorktreeName` to search upwards for the `.git` file/directory.
- Add test coverage for `GitIdentityResolver` and `WorktreeGrouper` to ensure worktrees and subdirectories group correctly.

Fixes matt1398#188.
@coderabbitai coderabbitai Bot added the bug Something isn't working label May 3, 2026
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request implements upward searching for .git directories in GitIdentityResolver to better handle git worktrees and subdirectories. It introduces a findGitPath helper and updates several resolution methods, supported by new test cases. The feedback suggests consolidating the logic in getBranch and getRepoType by utilizing the new helper method to improve maintainability and ensure consistent path resolution.

Comment on lines 465 to 486
let currentPath = projectPath;
let gitPathExists = false;
let gitPath = '';

try {
await fs.promises.access(gitPath);
} catch {
return null;
while (currentPath) {
gitPath = path.join(currentPath, '.git');
try {
await fs.promises.access(gitPath);
gitPathExists = true;
break; // Found it
} catch {
const parentDir = path.dirname(currentPath);
if (parentDir === currentPath) {
break;
}
currentPath = parentDir;
}
}

if (!gitPathExists) return null;

const stats = await fs.promises.stat(gitPath);
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.

medium

Consolidate the upward search logic by using the findGitPath helper method to improve maintainability and ensure consistent path resolution behavior.

Suggested change
let currentPath = projectPath;
let gitPathExists = false;
let gitPath = '';
try {
await fs.promises.access(gitPath);
} catch {
return null;
while (currentPath) {
gitPath = path.join(currentPath, '.git');
try {
await fs.promises.access(gitPath);
gitPathExists = true;
break; // Found it
} catch {
const parentDir = path.dirname(currentPath);
if (parentDir === currentPath) {
break;
}
currentPath = parentDir;
}
}
if (!gitPathExists) return null;
const stats = await fs.promises.stat(gitPath);
const gitInfo = await this.findGitPath(projectPath);
if (!gitInfo) return null;
const { gitPath, stats } = gitInfo;

Comment on lines 587 to 600
let currentPath = projectPath;
while (currentPath) {
const gitPath = path.join(currentPath, '.git');
try {
await fs.promises.access(gitPath);
return 'git';
} catch {
const parentDir = path.dirname(currentPath);
if (parentDir === currentPath) {
break;
}
currentPath = parentDir;
}
}
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.

medium

Consolidate the upward search logic by using the findGitPath helper method.

      if (await this.findGitPath(projectPath)) {
        return 'git';
      }

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 3, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds an upward-search helper to find the nearest .git file or directory and updates GitIdentityResolver to use it across resolveIdentity, isWorktree, getBranch, detectWorktreeSource, and getGitWorktreeName; adds Vitest tests covering upward search, worktree resolution, and worktree grouping.

Changes

Worktree Detection & Identity Resolution

Layer / File(s) Summary
Upward Search Helper
src/main/services/parsing/GitIdentityResolver.ts
Adds private findGitPath(projectPath) that walks parent directories to locate the nearest .git, returning { repoRoot, gitPath, stats } or null.
Core Identity Resolution
src/main/services/parsing/GitIdentityResolver.ts
resolveIdentity(projectPath) now calls findGitPath; when the located .git is a worktree file it reads gitdir: and resolves relative targets against the discovered repoRoot to compute mainGitDir and derive identity fields.
Branch Resolution
src/main/services/parsing/GitIdentityResolver.ts
getBranch(projectPath) uses findGitPath; for worktree .git files it parses gitdir: and reads HEAD from the resolved worktree gitdir, otherwise reads HEAD from the discovered .git directory.
Worktree Detection / Name Parsing
src/main/services/parsing/GitIdentityResolver.ts
isWorktree(projectPath) uses findGitPath and returns gitInfo.stats.isFile() for the discovered .git; getGitWorktreeName reads the located .git file’s gitdir: entry, resolves relative paths against repoRoot, and extracts the worktree name from the .../.git/worktrees/<name> segment.
Worktree Source Detection
src/main/services/parsing/GitIdentityResolver.ts
detectWorktreeSource(projectPath) treats "standard git" as present when an upward findGitPath succeeds (instead of only when projectPath/.git is directly accessible).
Tests / Integration
test/main/services/parsing/GitIdentityResolver.test.ts, test/main/services/parsing/GitIdentityResolver.upwards.test.ts, test/main/services/discovery/WorktreeGrouper.test.ts
Adds Vitest suites that create on-disk main repo and worktree layouts (including worktree .git pointer files with gitdir:), verify resolveIdentity() and isWorktree() from both repo and worktree subdirectories, and assert grouping of main repo and worktree into a single repository group.
🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The pull request fully addresses issue #188 by implementing upward .git search in GitIdentityResolver and adding comprehensive test coverage for worktree detection and session grouping.
Out of Scope Changes check ✅ Passed All changes are directly scoped to resolving issue #188: upward .git discovery logic, worktree detection, and test coverage for the affected functionality.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (3)
src/main/services/parsing/GitIdentityResolver.ts (1)

82-100: ⚡ Quick win

Refactor to use findGitPath() helper to avoid code duplication.

resolveIdentity() has an inline upward-search loop (lines 87-100) that duplicates the logic in findGitPath() (lines 39-64). The same duplication exists in getBranch() (lines 469-482) and detectWorktreeSource() (lines 588-600). Consider refactoring these methods to use the helper for consistency and maintainability.

♻️ Proposed refactor for resolveIdentity()
   async resolveIdentity(projectPath: string): Promise<RepositoryIdentity | null> {
     try {
-      // Search upwards for .git
-      let currentPath = projectPath;
-      let gitPathExists = false;
-      let gitPath = '';
-
-      while (currentPath) {
-        gitPath = path.join(currentPath, '.git');
-        try {
-          await fs.promises.access(gitPath);
-          gitPathExists = true;
-          break; // Found it
-        } catch {
-          const parentDir = path.dirname(currentPath);
-          if (parentDir === currentPath) {
-            break;
-          }
-          currentPath = parentDir;
-        }
-      }
+      const gitInfo = await this.findGitPath(projectPath);
 
-      if (gitPathExists) {
-        const stats = await fs.promises.stat(gitPath);
+      if (gitInfo) {
+        const { repoRoot: currentPath, gitPath, stats } = gitInfo;
 
         let mainGitDir: string;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/services/parsing/GitIdentityResolver.ts` around lines 82 - 100, The
upward-search loop in resolveIdentity() duplicates logic already implemented in
findGitPath(); replace the inline loop with a call to findGitPath(projectPath)
and use the returned git path (or handle not-found) so resolveIdentity() reuses
the helper; apply the same refactor pattern to getBranch() and
detectWorktreeSource() by removing their inline directory-walk loops and
invoking findGitPath() (or its exported equivalent) to obtain the .git location,
then adapt any downstream variable names and error/exists handling to match
findGitPath()'s return contract.
test/main/services/parsing/GitIdentityResolver.test.ts (1)

1-1: 💤 Low value

Remove unused vi import.

The vi import is not used in this test file.

♻️ Fix
-import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
+import { describe, expect, it, beforeEach, afterEach } from 'vitest';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/main/services/parsing/GitIdentityResolver.test.ts` at line 1, The import
list at the top of GitIdentityResolver.test.ts includes an unused symbol `vi`;
remove `vi` from the named imports in the line importing from 'vitest' (so leave
only the used symbols `describe`, `expect`, `it`, `beforeEach`, `afterEach`) to
eliminate the unused import warning.
test/main/services/discovery/WorktreeGrouper.test.ts (1)

50-61: 💤 Low value

Move vi.mock() calls to module level.

vi.mock() is hoisted by Vitest at compile time, so placing it inside it() doesn't scope it to that test—it applies to the entire file. While this works with a single test, it's misleading and may cause issues if more tests are added. Move mocks to the top level (after imports) or use vi.doMock() if per-test mocking is intended.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/main/services/discovery/WorktreeGrouper.test.ts` around lines 50 - 61,
The vi.mock calls for SubprojectRegistry and SessionContentFilter are inside a
test scope and must be moved to module-level so Vitest's hoisting behavior
doesn't make them global/misleading; relocate the two vi.mock(...) blocks
(mocking subprojectRegistry.getSessionFilter and
SessionContentFilter.hasNonNoiseMessages) to the top of the test file
immediately after imports, or if you need per-test behavior change them to
vi.doMock(...) within the specific it()/describe() block and use
vi.importMock/vi.clearAllMocks as appropriate.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/main/services/parsing/GitIdentityResolver.ts`:
- Around line 82-100: The upward-search loop in resolveIdentity() duplicates
logic already implemented in findGitPath(); replace the inline loop with a call
to findGitPath(projectPath) and use the returned git path (or handle not-found)
so resolveIdentity() reuses the helper; apply the same refactor pattern to
getBranch() and detectWorktreeSource() by removing their inline directory-walk
loops and invoking findGitPath() (or its exported equivalent) to obtain the .git
location, then adapt any downstream variable names and error/exists handling to
match findGitPath()'s return contract.

In `@test/main/services/discovery/WorktreeGrouper.test.ts`:
- Around line 50-61: The vi.mock calls for SubprojectRegistry and
SessionContentFilter are inside a test scope and must be moved to module-level
so Vitest's hoisting behavior doesn't make them global/misleading; relocate the
two vi.mock(...) blocks (mocking subprojectRegistry.getSessionFilter and
SessionContentFilter.hasNonNoiseMessages) to the top of the test file
immediately after imports, or if you need per-test behavior change them to
vi.doMock(...) within the specific it()/describe() block and use
vi.importMock/vi.clearAllMocks as appropriate.

In `@test/main/services/parsing/GitIdentityResolver.test.ts`:
- Line 1: The import list at the top of GitIdentityResolver.test.ts includes an
unused symbol `vi`; remove `vi` from the named imports in the line importing
from 'vitest' (so leave only the used symbols `describe`, `expect`, `it`,
`beforeEach`, `afterEach`) to eliminate the unused import warning.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c7ff0f77-300f-4875-837a-bc26c14d519c

📥 Commits

Reviewing files that changed from the base of the PR and between eb633ed and 4ed123c.

📒 Files selected for processing (4)
  • src/main/services/parsing/GitIdentityResolver.ts
  • test/main/services/discovery/WorktreeGrouper.test.ts
  • test/main/services/parsing/GitIdentityResolver.test.ts
  • test/main/services/parsing/GitIdentityResolver.upwards.test.ts

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/main/services/parsing/GitIdentityResolver.ts (1)

453-466: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Missing relative path resolution for gitdir: in getBranch.

When the .git file contains a relative gitdir: path (e.g., gitdir: ../../.git/worktrees/foo), this code uses it directly without resolving it against the repo root. This is inconsistent with resolveIdentity (line 103) and getGitWorktreeName (lines 691-693), which both resolve relative paths.

🐛 Proposed fix to handle relative gitdir paths
       if (stats.isFile()) {
         // Worktree - read .git file to find the HEAD location
         const gitFileContent = (await fs.promises.readFile(gitPath, 'utf-8')).trim();
         const gitDirMatch = /^gitdir:\s*(\S[^\r\n]*)$/.exec(gitFileContent);
 
         if (!gitDirMatch) {
           return null;
         }
 
-        headPath = path.join(gitDirMatch[1], 'HEAD');
+        let worktreeGitDir = gitDirMatch[1].trim();
+        if (!path.isAbsolute(worktreeGitDir)) {
+          worktreeGitDir = path.resolve(gitInfo.repoRoot, worktreeGitDir);
+        }
+        headPath = path.join(worktreeGitDir, 'HEAD');
       } else {

Note: You'll need to access repoRoot from gitInfo by adjusting the destructuring at line 450:

-      const { gitPath, stats } = gitInfo;
+      const { repoRoot, gitPath, stats } = gitInfo;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/services/parsing/GitIdentityResolver.ts` around lines 453 - 466,
getBranch currently uses the raw gitdir value from gitFileContent without
resolving relative paths; update getBranch to destructure repoRoot from gitInfo
(add repoRoot to the existing destructuring near where gitPath is obtained) and,
when gitDirMatch is present, compute the resolvedDir as
path.isAbsolute(gitDirMatch[1]) ? gitDirMatch[1] : path.resolve(repoRoot,
gitDirMatch[1]) and then set headPath = path.join(resolvedDir, 'HEAD')
(replacing the direct gitDirMatch[1] join) so relative gitdir entries (e.g.,
../../.git/...) resolve the same way as resolveIdentity and getGitWorktreeName.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@src/main/services/parsing/GitIdentityResolver.ts`:
- Around line 453-466: getBranch currently uses the raw gitdir value from
gitFileContent without resolving relative paths; update getBranch to destructure
repoRoot from gitInfo (add repoRoot to the existing destructuring near where
gitPath is obtained) and, when gitDirMatch is present, compute the resolvedDir
as path.isAbsolute(gitDirMatch[1]) ? gitDirMatch[1] : path.resolve(repoRoot,
gitDirMatch[1]) and then set headPath = path.join(resolvedDir, 'HEAD')
(replacing the direct gitDirMatch[1] join) so relative gitdir entries (e.g.,
../../.git/...) resolve the same way as resolveIdentity and getGitWorktreeName.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 23afd5a6-8348-48a0-9d0c-fd3e527533f1

📥 Commits

Reviewing files that changed from the base of the PR and between 4ed123c and cefd7a4.

📒 Files selected for processing (1)
  • src/main/services/parsing/GitIdentityResolver.ts

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
test/main/services/parsing/GitIdentityResolver.upwards.test.ts (1)

42-54: 💤 Low value

Consider asserting identity.remoteUrl to cover the config-parsing path.

Both tests already have a working .git/config with a [remote "origin"] URL in the fixture. Adding an assertion on identity.remoteUrl would exercise the getRemoteUrl → parseConfig path end-to-end at no extra setup cost, and would catch regressions in that path independently of the upward-search logic.

💡 Suggested addition to each `it` block
  it('resolves identity when path is a subdirectory of main repo', async () => {
    const identity = await gitIdentityResolver.resolveIdentity(path.join(mainRepoDir, 'src'));
    expect(identity).toBeDefined();
    expect(identity?.mainGitDir).toBe(fs.realpathSync(path.join(mainRepoDir, '.git')));
+   expect(identity?.remoteUrl).toBe('git@github.com:matt1398/claude-devtools.git');
    expect(await gitIdentityResolver.isWorktree(path.join(mainRepoDir, 'src'))).toBe(false);
  });

  it('resolves identity when path is a subdirectory of a worktree (with relative gitdir)', async () => {
    const identity = await gitIdentityResolver.resolveIdentity(path.join(worktreeDir, 'src'));
    expect(identity).toBeDefined();
    expect(identity?.mainGitDir).toBe(fs.realpathSync(path.join(mainRepoDir, '.git')));
+   expect(identity?.remoteUrl).toBe('git@github.com:matt1398/claude-devtools.git');
    expect(await gitIdentityResolver.isWorktree(path.join(worktreeDir, 'src'))).toBe(true);
  });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/main/services/parsing/GitIdentityResolver.upwards.test.ts` around lines
42 - 54, Add assertions to exercise the config-parsing path by checking the
resolved identity's remote URL: after calling
gitIdentityResolver.resolveIdentity(...) in each test, assert that
identity.remoteUrl equals the expected remote (the `[remote "origin"]` URL
present in the fixture). This will exercise getRemoteUrl → parseConfig
end-to-end; update the two it blocks that call
gitIdentityResolver.resolveIdentity(...) and gitIdentityResolver.isWorktree(...)
to include expect(identity?.remoteUrl).toBe(...) using the fixture's origin URL.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@test/main/services/parsing/GitIdentityResolver.upwards.test.ts`:
- Around line 42-54: Add assertions to exercise the config-parsing path by
checking the resolved identity's remote URL: after calling
gitIdentityResolver.resolveIdentity(...) in each test, assert that
identity.remoteUrl equals the expected remote (the `[remote "origin"]` URL
present in the fixture). This will exercise getRemoteUrl → parseConfig
end-to-end; update the two it blocks that call
gitIdentityResolver.resolveIdentity(...) and gitIdentityResolver.isWorktree(...)
to include expect(identity?.remoteUrl).toBe(...) using the fixture's origin URL.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ffa3787c-86b8-43a2-a9fa-1375d7963255

📥 Commits

Reviewing files that changed from the base of the PR and between e7f3e0a and 8f2b079.

📒 Files selected for processing (2)
  • test/main/services/parsing/GitIdentityResolver.test.ts
  • test/main/services/parsing/GitIdentityResolver.upwards.test.ts
✅ Files skipped from review due to trivial changes (1)
  • test/main/services/parsing/GitIdentityResolver.test.ts

@matt1398 matt1398 merged commit a477242 into matt1398:main May 6, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] worktree sessions not listed

2 participants