Skip to content

fix(worktree): self-heal stale git-repo detection in reconciler#346

Open
willy-scr wants to merge 1 commit into
gnoviawan:devfrom
willy-scr:fix/git-repo-detection-self-heal
Open

fix(worktree): self-heal stale git-repo detection in reconciler#346
willy-scr wants to merge 1 commit into
gnoviawan:devfrom
willy-scr:fix/git-repo-detection-self-heal

Conversation

@willy-scr

@willy-scr willy-scr commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Problem

A project that is a valid git repository can be permanently misdetected as "no git repo" in the project context menu (the entry reads New Worktree (no git repo) and is disabled), even though the repo is perfectly valid:

git worktree list --porcelain   # works, exit 0

Root cause

The live useWorktreeReconciler hook (use-worktree-reconciler.ts) reconciled worktrees with this guard:

if (!project?.path || !project.isGitRepo || project.worktrees === undefined) return

Once isGitRepo was ever set to false, the periodic (60s) reconciler short-circuited and never re-checked, so a stale flag could never be corrected — a detection deadlock.

A project gets flagged isGitRepo: false at startup when git worktree list fails transiently — most commonly because a GUI app's PATH differs from the shell, so git isn't found at first detection (or the repo was initialised after the project was added).

There was also a second useWorktreeReconciler export in use-projects-persistence.ts (same name, no guard, could self-heal) that was dead code — never mounted — which masked the bug and made the duplication confusing.

Fix

  • Self-heal false → true: when a project is marked non-git, re-run reconcileProjectWorktreesNow (which executes git worktree list and flips isGitRepo) before skipping reconciliation.
  • Self-heal true → false: flip to false when worktree_list now returns NOT_A_GIT_REPO / GIT_NOT_FOUND (e.g. .git was removed).
  • Removed the dead-code duplicate useWorktreeReconciler in use-projects-persistence.ts.
  • Added tests for both self-heal directions.

Verification

  • bun run typecheck ✅ (node + web)
  • bun run vitest run use-worktree-reconciler6/6 pass (incl. 2 new self-heal tests)
  • bun run vitest run use-projects-persistence7/7 pass
  • biome check ✅ on changed files
  • Pre-commit hooks (husky: typecheck + biome) passed.

Behaviour change

After this change, a project that was wrongly flagged non-git will correct itself on the next reconciliation tick (≤60s) or when it becomes the active project, instead of being stuck forever.

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • Bug Fixes
    • Improved automatic detection and recovery when a project's git repository becomes unavailable (e.g., directory deleted or moved)
    • Enhanced project state management to properly synchronize status when repository conditions change

The active worktree reconciler skipped any project flagged isGitRepo=false,
so a project detected as non-git at startup (e.g. git missing from the GUI
app's PATH, or the repo initialised afterwards) could never be re-detected.
The label showed "New Worktree (no git repo)" even for a valid repository.

- Re-run reconcileProjectWorktreesNow when a project is marked non-git so the
  flag is re-checked and flipped back to true.
- Heal the reverse case: flip to false when git now reports NOT_A_GIT_REPO
  (e.g. .git was removed).
- Remove a dead-code useWorktreeReconciler duplicate (same export name) in
  use-projects-persistence.ts that masked the live hook and was never mounted.
- Add tests for both self-heal directions.

Co-Authored-By: Claude <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This PR refactors worktree reconciliation by removing automatic hook triggering from the persistence module and enhancing the reconciliation hook with self-healing logic that detects when projects are no longer git repos and triggers full re-detection.

Changes

Worktree Reconciliation Refactor

Layer / File(s) Summary
Remove automatic reconciliation from persistence module
src/renderer/hooks/use-projects-persistence.ts
The exported useWorktreeReconciler hook that previously triggered on active project selection and every 60 seconds is removed. Reconciliation now occurs only via explicit reconcileProjectWorktreesNow calls and during project loading.
Add self-healing to worktree reconciliation logic
src/renderer/hooks/use-worktree-reconciler.ts
The hook now imports and calls reconcileProjectWorktreesNow to self-heal projects marked as non-git. The periodic reconciliation callback re-fetches project state and triggers recovery when isGitRepo is false. Error handling for worktreeApi.list is refined to detect when a directory is no longer a git repo and update store state (isGitRepo: false) instead of returning immediately on non-success.
Update test mocks and assertions for self-healing behavior
src/renderer/hooks/use-worktree-reconciler.test.ts
Test setup now uses a hoisted mocks object with shared mock functions and mutable state (activeWorktreeId, isGitRepo) read live by the store mock. All test assertions reference the shared mocks.* functions. New test flows validate self-healing when a project becomes non-git and updated git-repo detection logic.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • gnoviawan/termul#171: Introduced and wired the original useWorktreeReconciler hook and reconcileProjectWorktreesNow behavior; this PR refactors the triggering mechanism and enhances the self-healing logic.

Poem

🐰 Worktrees dance with care and grace,
Self-heal now finds its proper place,
No more hooks to auto-fire,
Just smart recovery when repos tire!
🌿✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main fix: enabling self-healing of stale git-repo detection in the worktree reconciler by re-running reconciliation when projects are marked non-git.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

Caution

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

⚠️ Outside diff range comments (1)
src/renderer/hooks/use-worktree-reconciler.test.ts (1)

166-180: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add assertion for the reverse heal behavior.

With the new reverse heal logic (implementation lines 51-52), when worktreeApi.list returns NOT_A_GIT_REPO, the hook calls updateProject(projectId, { isGitRepo: false }). This test should verify that behavior in addition to checking that removeWorktree and addWorktree are not called.

🧪 Recommended assertion addition
   await vi.waitFor(() => {
     expect(mocks.removeWorktree).not.toHaveBeenCalled()
     expect(mocks.addWorktree).not.toHaveBeenCalled()
+    expect(mocks.updateProject).toHaveBeenCalledWith('proj-1', { isGitRepo: false })
   })
 })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/renderer/hooks/use-worktree-reconciler.test.ts` around lines 166 - 180,
Update the test for useWorktreeReconciler to also assert the reverse-heal call:
after mocking worktreeApi.list to return NOT_A_GIT_REPO and rendering the hook,
add an expectation that updateProject was called with the project id and {
isGitRepo: false } (e.g.
expect(mocks.updateProject).toHaveBeenCalledWith('proj-1', { isGitRepo: false
})). Keep the existing assertions that removeWorktree and addWorktree were not
called and wrap these expectations in the same async waitFor to ensure the async
logic in useWorktreeReconciler runs first.
🧹 Nitpick comments (2)
src/renderer/hooks/use-worktree-reconciler.test.ts (1)

182-196: 💤 Low value

Consider mocking worktreeApi.list for the post-heal reconciliation attempt.

After reconcileNow flips isGitRepo to true, the hook re-reads the project and proceeds to call worktreeApi.list (implementation line 48). Without an explicit mock, the test relies on the default undefined return, which triggers the error path. While this doesn't break the test, explicitly mocking the success case would make the test's intent clearer and more robust.

🧪 Suggested mock addition
   mocks.state.isGitRepo = false
   // The shared reconciler re-runs `git worktree list`; simulate the heal side-effect.
   mocks.reconcileNow.mockImplementation(async () => {
     mocks.state.isGitRepo = true
   })
+  // After heal, the hook will call worktreeApi.list again; mock a successful response.
+  vi.mocked(worktreeApi.list).mockResolvedValue({
+    success: true,
+    data: [
+      {
+        name: 'feat-1',
+        branch: 'feat-1',
+        path: '/test/project/.termul/worktrees/feat-1',
+        headCommit: 'abc'
+      }
+    ]
+  })

   renderHook(() => useWorktreeReconciler('proj-1'))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/renderer/hooks/use-worktree-reconciler.test.ts` around lines 182 - 196,
The test should explicitly mock worktreeApi.list so the post-heal reconciliation
path succeeds: in the test that flips mocks.state.isGitRepo to true via
mocks.reconcileNow, add a mock implementation or return value for
worktreeApi.list (the symbol referenced in useWorktreeReconciler) that returns
the expected worktree data (or a resolved Promise) after the heal; keep the
existing mocks.reconcileNow behavior and assertions (reconcileNow and proj-1)
but ensure worktreeApi.list is stubbed to avoid relying on undefined behavior
and to exercise the success branch.
src/renderer/hooks/use-worktree-reconciler.ts (1)

30-45: ⚡ Quick win

Consider clarifying the double state read pattern.

The code reads project state twice: once at line 30 to decide whether to self-heal, then again at line 42 to proceed with reconciliation. While this is intentional (covering both the heal and concurrent updates), a brief inline comment explaining the re-read purpose would improve maintainability.

📝 Suggested comment addition
   if (!initial.isGitRepo) {
     await reconcileProjectWorktreesNow(projectId)
   }

-  // Re-read the latest state (covers the heal above and any concurrent store writes).
+  // Re-read the latest state. This covers two cases:
+  // 1. The self-heal above may have flipped isGitRepo from false to true.
+  // 2. Concurrent store updates during the await above.
   const project = useProjectStore.getState().projects.find((p) => p.id === projectId)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/renderer/hooks/use-worktree-reconciler.ts` around lines 30 - 45, The two
separate reads of useProjectStore.getState() (into initial and later project)
are intentional but unclear; add a concise inline comment above the second read
(before const project = useProjectStore.getState()...) explaining that the first
read is used to decide whether to self-heal via
reconcileProjectWorktreesNow(projectId) and the second read deliberately
re-reads state to pick up the heal or any concurrent store updates before
proceeding with reconciliation (mention useProjectStore, initial, project, and
reconcileProjectWorktreesNow by name to make intent obvious).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/renderer/hooks/use-worktree-reconciler.ts`:
- Around line 37-39: The call to reconcileProjectWorktreesNow(projectId) can
throw and must be covered by the existing error handling: move or wrap the await
reconcileProjectWorktreesNow(projectId) into the same try-catch used by the
reconciler (the callback invoked by reconcile()), i.e., ensure
reconcileProjectWorktreesNow is awaited inside a try block and any errors are
caught and handled (log or suppress) in the catch so the promise rejection
doesn't escape the reconcile callback.

---

Outside diff comments:
In `@src/renderer/hooks/use-worktree-reconciler.test.ts`:
- Around line 166-180: Update the test for useWorktreeReconciler to also assert
the reverse-heal call: after mocking worktreeApi.list to return NOT_A_GIT_REPO
and rendering the hook, add an expectation that updateProject was called with
the project id and { isGitRepo: false } (e.g.
expect(mocks.updateProject).toHaveBeenCalledWith('proj-1', { isGitRepo: false
})). Keep the existing assertions that removeWorktree and addWorktree were not
called and wrap these expectations in the same async waitFor to ensure the async
logic in useWorktreeReconciler runs first.

---

Nitpick comments:
In `@src/renderer/hooks/use-worktree-reconciler.test.ts`:
- Around line 182-196: The test should explicitly mock worktreeApi.list so the
post-heal reconciliation path succeeds: in the test that flips
mocks.state.isGitRepo to true via mocks.reconcileNow, add a mock implementation
or return value for worktreeApi.list (the symbol referenced in
useWorktreeReconciler) that returns the expected worktree data (or a resolved
Promise) after the heal; keep the existing mocks.reconcileNow behavior and
assertions (reconcileNow and proj-1) but ensure worktreeApi.list is stubbed to
avoid relying on undefined behavior and to exercise the success branch.

In `@src/renderer/hooks/use-worktree-reconciler.ts`:
- Around line 30-45: The two separate reads of useProjectStore.getState() (into
initial and later project) are intentional but unclear; add a concise inline
comment above the second read (before const project =
useProjectStore.getState()...) explaining that the first read is used to decide
whether to self-heal via reconcileProjectWorktreesNow(projectId) and the second
read deliberately re-reads state to pick up the heal or any concurrent store
updates before proceeding with reconciliation (mention useProjectStore, initial,
project, and reconcileProjectWorktreesNow by name to make intent obvious).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2b7e062d-636b-4bd7-a590-c555f9692c30

📥 Commits

Reviewing files that changed from the base of the PR and between dc6231e and 7a9c122.

📒 Files selected for processing (3)
  • src/renderer/hooks/use-projects-persistence.ts
  • src/renderer/hooks/use-worktree-reconciler.test.ts
  • src/renderer/hooks/use-worktree-reconciler.ts
💤 Files with no reviewable changes (1)
  • src/renderer/hooks/use-projects-persistence.ts

Comment on lines +37 to +39
if (!initial.isGitRepo) {
await reconcileProjectWorktreesNow(projectId)
}

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.

⚠️ Potential issue | 🟠 Major | ⚖️ Poor tradeoff

Wrap the self-heal call in try-catch to prevent unhandled rejections.

reconcileProjectWorktreesNow is awaited outside the try-catch block that starts at line 47. If it throws (e.g., due to I/O errors, store exceptions, or API failures), the exception will propagate as an unhandled promise rejection because the reconcile callback is invoked with void reconcile() (lines 111, 114).

🛡️ Recommended fix: extend try-catch coverage
   // Self-heal stale git-repo detection. A project can be marked non-git if git
   // was not available when it was first detected (e.g. the GUI app's PATH differs
   // from the shell at startup, or the repo was initialised afterwards). Re-run the
   // shared reconciler, which executes `git worktree list` and flips `isGitRepo`.
-  if (!initial.isGitRepo) {
-    await reconcileProjectWorktreesNow(projectId)
-  }
-
-  // Re-read the latest state (covers the heal above and any concurrent store writes).
-  const project = useProjectStore.getState().projects.find((p) => p.id === projectId)
-  if (!project?.isGitRepo || !project.path) return
-  // Allow empty worktrees array (still reconcile to discover newly created worktrees)
-  if (project.worktrees === undefined) return
-
-  try {
+  try {
+    if (!initial.isGitRepo) {
+      await reconcileProjectWorktreesNow(projectId)
+    }
+
+    // Re-read the latest state (covers the heal above and any concurrent store writes).
+    const project = useProjectStore.getState().projects.find((p) => p.id === projectId)
+    if (!project?.isGitRepo || !project.path) return
+    // Allow empty worktrees array (still reconcile to discover newly created worktrees)
+    if (project.worktrees === undefined) return
+
     const result = await worktreeApi.list(project.path)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (!initial.isGitRepo) {
await reconcileProjectWorktreesNow(projectId)
}
try {
if (!initial.isGitRepo) {
await reconcileProjectWorktreesNow(projectId)
}
// Re-read the latest state (covers the heal above and any concurrent store writes).
const project = useProjectStore.getState().projects.find((p) => p.id === projectId)
if (!project?.isGitRepo || !project.path) return
// Allow empty worktrees array (still reconcile to discover newly created worktrees)
if (project.worktrees === undefined) return
const result = await worktreeApi.list(project.path)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/renderer/hooks/use-worktree-reconciler.ts` around lines 37 - 39, The call
to reconcileProjectWorktreesNow(projectId) can throw and must be covered by the
existing error handling: move or wrap the await
reconcileProjectWorktreesNow(projectId) into the same try-catch used by the
reconciler (the callback invoked by reconcile()), i.e., ensure
reconcileProjectWorktreesNow is awaited inside a try block and any errors are
caught and handled (log or suppress) in the catch so the promise rejection
doesn't escape the reconcile callback.

@gnoviawan

gnoviawan commented Jun 14, 2026

Copy link
Copy Markdown
Owner

Hey @willy-scr — triage update 🔁

CI is green and this is mergeable ✅. One open CodeRabbit thread is worth a look before merging: wrap the reconcileProjectWorktreesNow self-heal call in try/catch to avoid unhandled rejections (🟠 Major). Please address it, or resolve the thread as N/A. Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants