Skip to content

Fix git index.lock polling in sidebar metadata watcher - #2797

Merged
austinywang merged 42 commits into
mainfrom
issue-2722-git-index-lock-poll
May 20, 2026
Merged

austinywang merged 42 commits into
mainfrom
issue-2722-git-index-lock-poll

Conversation

@austinywang

@austinywang austinywang commented Apr 10, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Fixes cmux 0.63.2 spawns git every ~5s, creates/removes .git/index.lock — breaks watchfiles / fastapi dev #2722 by removing git subprocesses from sidebar git metadata refresh, including the prior git status --porcelain -uno / git branch --show-current path that users confirmed still touched .git/index.lock in 0.64.4 and 0.64.7.
  • Replaces idle polling with repository file watchers plus direct .git/HEAD, .git/config, and .git/index reads so sidebar branch/dirty/PR metadata refreshes without taking Git's index lock.
  • Adds a sidebar.watchGitStatus opt-out in Settings, command palette, shell integration env, and cmux.json; disabling it clears cached sidebar git/PR badge state, and re-enabling restarts probes from current terminal directories.
  • Updates zsh/bash shell integrations to read branch and origin config directly instead of spawning git for prompt/sidebar metadata.

Regression Coverage

  • First commit is test-only: testNoIndexLockTouchDuringSidebarGitMetadataRefreshWindow watches .git/index.lock every 100ms for 90.5s while driving the sidebar git-refresh path repeatedly.
  • The test asserts zero git subprocess invocations, branch metadata still updates, and .git/index.lock is observed zero times.
  • Added regressions for branch-only shell reports preserving dirty state, disabling git watch clearing default-enabled PR badges, re-enabling git watch restarting from current panel directories, and Git index v4 signature tracking.

Validation

  • Local baseline repro confirmed git status --porcelain -uno can create .git/index.lock briefly in /tmp/cmux-clone while a watcher observes the path.
  • Ran JSON parsing for Resources/Localizable.xcstrings and web/data/cmux.schema.json.
  • Ran shell syntax checks for zsh/bash integration files.
  • Ran git diff --check and targeted search for remaining sidebar/shell git subprocess commands.
  • Ran tagged dev build on the pushed branch: CMUX_SKIP_ZIG_BUILD=1 ./scripts/reload.sh --tag issue-2722-git-index-lock-poll --launch.
  • Dogfooded the tagged app on GitLockProbe with cwd=/tmp/cmux-clone and git_branch=main clean; watched /tmp/cmux-clone/.git/index.lock every 100ms for 95s: LOCK_COUNT=0, SAMPLES=768, FIRST_LOCK=none, LAST_LOCK=none.
  • Sampled the tagged app during that window with top -pid 63222: 0.0 CPU, 497M memory. Full suite validation is running in CI.

Note

Medium Risk
Moderate risk due to a large refactor of sidebar git/PR metadata collection (new file-watcher + git index parsing) and new settings-driven enable/disable behavior that affects live workspace UI state.

Overview
Replaces sidebar git/PR metadata refresh from periodic git subprocess polling with file-based detection: TabManager now resolves repositories by walking .git/worktree metadata, watches relevant paths via FSEvents, and derives branch/dirty/index state by directly reading HEAD, refs, and parsing the git index (including gitlinks/submodules). A fallback timer remains to refresh metadata when watchers miss events.

Adds a new sidebar.watchGitStatus setting (UI, command palette, settings search aliases, JSON paths/template) that can disable all git watching; when off, shell integration and socket handlers stop emitting/accepting git/PR updates, existing sidebar git/PR badges are cleared, and re-enabling restarts watching from current panel directories.

Updates bash/zsh shell integrations to avoid invoking git for branch and origin URL discovery by reading .git/HEAD and parsing git config files (including include/includeIf), and introduces CMUX_NO_GIT_WATCH to control this behavior. Also tightens agent-hook session status handling to avoid idle updates clobbering newer sessions, refines Grok stop-notification fallback behavior, and improves shortcut window resolution plus related tests.

CI/perf workflows are adjusted to run an additional shell config parsing test and to cache/resolve Swift packages for faster perf builds.

Reviewed by Cursor Bugbot for commit ac1ff65. Bugbot is set up for automated code reviews on this repo. Configure here.

@vercel

vercel Bot commented Apr 10, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment May 20, 2026 10:21pm

@coderabbitai

coderabbitai Bot commented Apr 10, 2026 •

Copy link
Copy Markdown

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 a per-workspace and global toggle to disable the Git Metadata Watcher, wires UI (context menu + Settings), adds localization, persists the flag in session snapshots, clears cached sidebar git metadata when disabled or on remote transitions, extends settings parsing, pins two GitHub Action steps, and adds extensive tests.

Changes

Cohort / File(s) Summary
Workflow Configuration
\.github/workflows/claude.yml
Pinned actions/checkout and anthropics/claude-code-action uses to specific commit SHAs; removed trailing blank line.
Localization
Resources/Localizable.xcstrings
Added English/Japanese keys for context-menu labels and settings rows for enabling/disabling the Git Metadata Watcher.
UI / Context Menu
Sources/ContentView.swift, Sources/.../VerticalTabsSidebar*, Sources/.../TabItemView*
Added WorkspaceGitMetadataWatcherContextMenuMode, computed per-selection menu mode, threaded mode & workspace ID subsets into sidebar/tab props, updated TabItemView equality, and added context-menu action to enable/disable watcher via TabManager.
Settings Storage & App Init
Sources/KeyboardShortcutSettingsFileStore.swift, Sources/cmuxApp.swift
Added sidebar.disableGitMetadataWatcher to settings JSON parsing/template; added @AppStorage toggle in SettingsView; moved startup instance initialization of notificationStore/sidebarState into cmuxApp.init().
Session Persistence
Sources/SessionPersistence.swift
Added optional gitMetadataWatcherDisabled: Bool? to SessionWorkspaceSnapshot schema for persistence and round-trip.
Workspace Model & Logic
Sources/Workspace.swift
Added @Published var gitMetadataWatcherDisabled with didSet side effects to clear local cached sidebar git metadata, gated snapshotting/restoration of git fields, added clearCachedSidebarGitMetadata(), and reset behavior on remote transitions.
TabManager Snapshot / Fingerprint (implicit)
Sources/.../TabManager*
Autosave fingerprint and session snapshots now account for workspace watcher-disabled state; disabled workspaces omit git metadata from snapshots (exercised by tests).
Tests — Persistence & Session Snapshots
cmuxTests/SessionPersistenceTests.swift, cmuxTests/TabManagerSessionSnapshotTests.swift
Added snapshot round-trip tests for gitMetadataWatcherDisabled, autosave-fingerprint test, and assertions that snapshots omit git metadata when disabled.
Tests — Unit / Integration (Git Metadata)
cmuxTests/TabManagerUnitTests.swift
Large additions (~1,200 LOC) adding many tests and helpers covering watcher/probe behavior, PR-refresh/cache/throttling, repo opt-outs, recovery scenarios, and related edge cases.
Tests — Context Menu Mode
cmuxTests/WorkspaceGitMetadataWatcherContextMenuTests.swift
New test suite validating context-menu mode computation across local/remote/mixed selections and that remote workspaces ignore disable requests.

Sequence Diagram(s)

sequenceDiagram
    participant User as "User (context menu / settings)"
    participant UI as "ContentView / TabItemView"
    participant TM as "TabManager"
    participant WS as "Workspace"
    participant SP as "SessionPersistence"

    User->>UI: Toggle watcher via context menu or Settings
    UI->>TM: setWorkspaceGitMetadataWatcherDisabled(workspaceIds, disabled)
    TM->>WS: update gitMetadataWatcherDisabled for each workspace
    WS->>WS: if local && disabled -> clearCachedSidebarGitMetadata()
    WS->>SP: include gitMetadataWatcherDisabled in snapshot
    SP-->>SP: persist snapshot (omit git fields when disabled)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I twitch my whiskers, tap a tiny switch,

Branches hush and watchers take a hitch.
Snapshots hug the setting, saved through every night,
Local trees lie quiet, remotes keep the light.
🥕✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 2.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The PR title 'Fix git index.lock polling in sidebar metadata watcher' is specific and directly related to the main change (removing index.lock polling and switching to event-driven FSEvents updates).
Description check ✅ Passed The PR description provides a comprehensive summary of changes, detailed testing methodology, regression coverage, and validation steps, addressing the template structure.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch issue-2722-git-index-lock-poll

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

Choose a reason for hiding this comment

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

Actionable comments posted: 4

Caution

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

⚠️ Outside diff range comments (1)
Sources/Workspace.swift (1)

295-301: ⚠️ Potential issue | 🟠 Major

Don't persist git metadata when the watcher is disabled.

Line 301 saves the opt-out flag, but the surrounding snapshot code still serializes gitBranch, and sessionPanelSnapshot(...) still serializes each panel's branch. That means the branch data still lands in the session JSON even while the watcher is disabled; restoreSessionSnapshot only hides it after reload.

Suggested fix
-        let gitBranchSnapshot = gitBranch.map { branch in
+        let gitBranchSnapshot = gitMetadataWatcherDisabled ? nil : gitBranch.map { branch in
             SessionGitBranchSnapshot(branch: branch.branch, isDirty: branch.isDirty)
         }

Also gate the per-panel branch snapshot in sessionPanelSnapshot(panelId:includeScrollback:):

-        let branchSnapshot = panelGitBranches[panelId].map {
+        let branchSnapshot = gitMetadataWatcherDisabled ? nil : panelGitBranches[panelId].map {
             SessionGitBranchSnapshot(branch: $0.branch, isDirty: $0.isDirty)
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Workspace.swift` around lines 295 - 301, The snapshot currently
persists git branch data even when gitMetadataWatcherDisabled is true; update
the snapshot creation to omit git metadata when the watcher is disabled by: in
SessionWorkspaceSnapshot construction (SessionWorkspaceSnapshot(...)) set
workspace-level git fields (e.g., gitBranch) to nil or skip them when
gitMetadataWatcherDisabled is true, and in
sessionPanelSnapshot(panelId:includeScrollback:) gate per-panel branch
serialization so each panel's branch is only included when
gitMetadataWatcherDisabled is false (or the watcher is enabled); keep
restoreSessionSnapshot behavior unchanged. Ensure you reference
SessionWorkspaceSnapshot and sessionPanelSnapshot(panelId:includeScrollback:)
when making these conditional changes.
🧹 Nitpick comments (2)
cmuxTests/SessionPersistenceTests.swift (1)

875-883: Add one explicit non-nil round-trip test for gitMetadataWatcherDisabled.

The updated fixture only validates the nil path. A serialization regression for true/false would still pass this suite.

💡 Suggested test addition
+    func testSaveAndLoadRoundTripPreservesGitMetadataWatcherDisabled() {
+        let tempDir = FileManager.default.temporaryDirectory
+            .appendingPathComponent("cmux-session-tests-\(UUID().uuidString)", isDirectory: true)
+        try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
+        defer { try? FileManager.default.removeItem(at: tempDir) }
+
+        let snapshotURL = tempDir.appendingPathComponent("session.json", isDirectory: false)
+        var snapshot = makeSnapshot(version: SessionSnapshotSchema.currentVersion)
+        snapshot.windows[0].tabManager.workspaces[0].gitMetadataWatcherDisabled = true
+
+        XCTAssertTrue(SessionPersistenceStore.save(snapshot, fileURL: snapshotURL))
+        let loaded = SessionPersistenceStore.load(fileURL: snapshotURL)
+        XCTAssertEqual(
+            loaded?.windows.first?.tabManager.workspaces.first?.gitMetadataWatcherDisabled,
+            true
+        )
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxTests/SessionPersistenceTests.swift` around lines 875 - 883, Add explicit
non-nil round-trip tests for gitMetadataWatcherDisabled by extending the
SessionPersistenceTests to create AppSessionSnapshot instances via makeSnapshot
with SessionWorkspaceSnapshot.gitMetadataWatcherDisabled set to true and false
(in addition to the existing nil case), serialize and deserialize them, and
assert the deserialized snapshots preserve the true/false values; locate the
helper makeSnapshot and the AppSessionSnapshot/SessionWorkspaceSnapshot usage to
add two small test cases that verify round-trip equality of
gitMetadataWatcherDisabled for both true and false.
cmuxTests/WorkspaceGitMetadataWatcherContextMenuTests.swift (1)

36-51: Consider extracting a shared remote-workspace fixture helper.

The remote configuration setup is duplicated; pulling it into a small helper would reduce noise and make future test updates safer.

♻️ Optional refactor sketch
 final class WorkspaceGitMetadataWatcherContextMenuTests: XCTestCase {
+    private func makeRemoteWorkspace(in manager: TabManager) -> Workspace {
+        let workspace = manager.addWorkspace(select: false)
+        workspace.configureRemoteConnection(
+            WorkspaceRemoteConfiguration(
+                destination: "cmux-macmini",
+                port: nil,
+                identityFile: nil,
+                sshOptions: [],
+                localProxyPort: nil,
+                relayPort: 64017,
+                relayID: String(repeating: "a", count: 16),
+                relayToken: String(repeating: "b", count: 64),
+                localSocketPath: "/tmp/cmux-debug-test.sock",
+                terminalStartupCommand: "ssh cmux-macmini"
+            ),
+            autoConnect: false
+        )
+        return workspace
+    }
+
     func testContextMenuModeHidesToggleWhenAnyTargetWorkspaceIsRemote() {
         let manager = TabManager()
         guard let localWorkspace = manager.selectedWorkspace else {
             XCTFail("Expected local workspace")
             return
         }
-
-        let remoteWorkspace = manager.addWorkspace(select: false)
-        remoteWorkspace.configureRemoteConnection(
-            WorkspaceRemoteConfiguration(
-                destination: "cmux-macmini",
-                port: nil,
-                identityFile: nil,
-                sshOptions: [],
-                localProxyPort: nil,
-                relayPort: 64017,
-                relayID: String(repeating: "a", count: 16),
-                relayToken: String(repeating: "b", count: 64),
-                localSocketPath: "/tmp/cmux-debug-test.sock",
-                terminalStartupCommand: "ssh cmux-macmini"
-            ),
-            autoConnect: false
-        )
+        let remoteWorkspace = makeRemoteWorkspace(in: manager)
@@
     func testSetWorkspaceGitMetadataWatcherDisabledSkipsRemoteWorkspace() {
         let manager = TabManager()
-        let remoteWorkspace = manager.addWorkspace(select: false)
-        remoteWorkspace.configureRemoteConnection(...)
+        let remoteWorkspace = makeRemoteWorkspace(in: manager)

Also applies to: 64-79

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

In `@cmuxTests/WorkspaceGitMetadataWatcherContextMenuTests.swift` around lines 36
- 51, Extract the duplicated remote workspace setup into a small test helper
(e.g., a function like makeRemoteWorkspace or addRemoteWorkspaceFixture) that
calls manager.addWorkspace(select:), builds the WorkspaceRemoteConfiguration
(with destination "cmux-macmini", relayPort 64017, relayID/relayToken,
localSocketPath, terminalStartupCommand, etc.), and calls
workspace.configureRemoteConnection(autoConnect: false); replace the duplicated
blocks in WorkspaceGitMetadataWatcherContextMenuTests (the current block using
manager.addWorkspace and the similar block at lines 64–79) with calls to that
helper to centralize configuration and reduce duplication.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@cmuxTests/TabManagerUnitTests.swift`:
- Around line 1599-1635: The test shows git watcher state is not reattached when
a workspace is moved via detachWorkspace/attachWorkspace: update
TabManager.attachWorkspace to reinitialize git watchers for the incoming
workspace by iterating its surfaces/panels and starting the watcher for any
panel with a directory (i.e. call whatever internal start/ensure watcher helper
you have for panels) so
attachedWorkspaceGitWatcherPanelIdsForTesting(workspaceId:) contains the
original panelId after attach; also review detachWorkspace to ensure it clears
watcher state for the correct workspace id so watchers aren’t left stranded
under the wrong key.

In `@Sources/ContentView.swift`:
- Around line 2342-2355: The context-menu enable/disable logic in
workspaceGitMetadataWatcherContextMenuMode incorrectly treats globalDisabled as
a per-workspace flag so the menu shows "Enable" even when the global watcher is
off; fix by either (A) hiding/disabling the Enable action when
GitMetadataWatcherSettings.isEnabled() is false (so
workspaceGitMetadataWatcherContextMenuMode returns .hidden when the global
setting is off), or (B) add a setter on GitMetadataWatcherSettings (e.g.,
setEnabled(_:)) and update setWorkspaceGitMetadataWatcherDisabled to clear the
global flag when the user chooses "Enable" so both
GitMetadataWatcherSettings.isEnabled() and !workspace.gitMetadataWatcherDisabled
become true. Ensure references to workspaceGitMetadataWatcherContextMenuMode,
setWorkspaceGitMetadataWatcherDisabled, and GitMetadataWatcherSettings are
updated accordingly.

In `@Sources/Workspace.swift`:
- Line 6498: When gitMetadataWatcherDisabled changes at runtime we must clear
the same cached git state that restoreSessionSnapshot(_:) clears to avoid stale
metadata; add logic to observe changes to the `@Published` var
gitMetadataWatcherDisabled and when it flips (true or false as appropriate)
clear gitBranch, panelGitBranches, pullRequest, and panelPullRequests (reuse or
call the same clearing helper used by restoreSessionSnapshot(_:)), so that
disabling the watcher immediately purges those fields and related UI helpers
don’t surface stale data.

---

Outside diff comments:
In `@Sources/Workspace.swift`:
- Around line 295-301: The snapshot currently persists git branch data even when
gitMetadataWatcherDisabled is true; update the snapshot creation to omit git
metadata when the watcher is disabled by: in SessionWorkspaceSnapshot
construction (SessionWorkspaceSnapshot(...)) set workspace-level git fields
(e.g., gitBranch) to nil or skip them when gitMetadataWatcherDisabled is true,
and in sessionPanelSnapshot(panelId:includeScrollback:) gate per-panel branch
serialization so each panel's branch is only included when
gitMetadataWatcherDisabled is false (or the watcher is enabled); keep
restoreSessionSnapshot behavior unchanged. Ensure you reference
SessionWorkspaceSnapshot and sessionPanelSnapshot(panelId:includeScrollback:)
when making these conditional changes.

---

Nitpick comments:
In `@cmuxTests/SessionPersistenceTests.swift`:
- Around line 875-883: Add explicit non-nil round-trip tests for
gitMetadataWatcherDisabled by extending the SessionPersistenceTests to create
AppSessionSnapshot instances via makeSnapshot with
SessionWorkspaceSnapshot.gitMetadataWatcherDisabled set to true and false (in
addition to the existing nil case), serialize and deserialize them, and assert
the deserialized snapshots preserve the true/false values; locate the helper
makeSnapshot and the AppSessionSnapshot/SessionWorkspaceSnapshot usage to add
two small test cases that verify round-trip equality of
gitMetadataWatcherDisabled for both true and false.

In `@cmuxTests/WorkspaceGitMetadataWatcherContextMenuTests.swift`:
- Around line 36-51: Extract the duplicated remote workspace setup into a small
test helper (e.g., a function like makeRemoteWorkspace or
addRemoteWorkspaceFixture) that calls manager.addWorkspace(select:), builds the
WorkspaceRemoteConfiguration (with destination "cmux-macmini", relayPort 64017,
relayID/relayToken, localSocketPath, terminalStartupCommand, etc.), and calls
workspace.configureRemoteConnection(autoConnect: false); replace the duplicated
blocks in WorkspaceGitMetadataWatcherContextMenuTests (the current block using
manager.addWorkspace and the similar block at lines 64–79) with calls to that
helper to centralize configuration and reduce duplication.
🪄 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: 8742623b-8a32-4f67-97a1-efa6b17d9c01

📥 Commits

Reviewing files that changed from the base of the PR and between 049c653 and ce854b8.

📒 Files selected for processing (12)
  • .github/workflows/claude.yml
  • Resources/Localizable.xcstrings
  • Sources/ContentView.swift
  • Sources/KeyboardShortcutSettingsFileStore.swift
  • Sources/SessionPersistence.swift
  • Sources/TabManager.swift
  • Sources/Workspace.swift
  • Sources/cmuxApp.swift
  • cmuxTests/SessionPersistenceTests.swift
  • cmuxTests/TabManagerSessionSnapshotTests.swift
  • cmuxTests/TabManagerUnitTests.swift
  • cmuxTests/WorkspaceGitMetadataWatcherContextMenuTests.swift

Comment thread cmuxTests/TabManagerUnitTests.swift Outdated
Comment thread cmuxTests/TabManagerUnitTests.swift Outdated
Comment thread Sources/Workspace.swift Outdated
@greptile-apps

greptile-apps Bot commented Apr 10, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR replaces sidebar git metadata polling (git status --porcelain -uno / git branch --show-current) with FSEvents-based file watchers and direct reads of .git/HEAD, .git/index, .git/config, and refs, eliminating the .git/index.lock contention reported in #2722. A new sidebar.watchGitStatus opt-out setting is threaded through the UI, command palette, settings search, cmux.json, and shell environment.

  • Core watcher: WorkspaceGitMetadataWatcher wraps FSEventStreamCreate with per-watcher serial-queue isolation and debounced dispatch; TabManager drives descriptor resolution off-main via detached tasks and maintains clean-index signatures (raw checksum + FNV-1a content hash) to distinguish stat-cache rewrites from real content changes (stash fix).
  • Index parser: gitIndexSnapshot reads the git binary index format (v2/v3/v4), filters assume-unchanged and skip-worktree entries, handles gitlink submodule entries via live HEAD comparison, and decodes v4 VLI path-strip lengths matching git's varint.c bijection.
  • Shell integration: bash and zsh integrations parse .git/HEAD and config files directly for branch and origin URL, now report --status=unknown so the app-side index scan owns dirty state; CMUX_NO_GIT_WATCH gates all shell git paths.

Confidence Score: 5/5

Safe to merge; all previous blocking issues are addressed in the current head.

All previously flagged defects in the watcher, index parser, and shell integration have been resolved. The new code correctly handles gitlinks via commit comparison, sparse-checkout via extended-flags filtering, v4 VLI decoding matching git's varint.c bijection, and the stash scenario via dual index-signature tracking. The two open notes are a file-organisation suggestion and a correctness note for a non-default git configuration.

Sources/TabManager.swift warrants a follow-up extraction into a focused SwiftPM package given its 9,250-line size post-merge.

Important Files Changed

Filename Overview
Sources/TabManager.swift Core change: replaces polling with FSEvents watcher + direct git index/HEAD/config reads. Adds 1,654 lines including index binary parser, varint decoder, config includeIf resolver, glob-to-regex converter, and ref follower — all inside an already 9,250-line file. Logic is correct but the file now exceeds any reasonable single-file boundary.
Resources/shell-integration/cmux-bash-integration.bash Replaces git subprocess calls with direct HEAD/config parsing; adds full awk-based config parser with inline-comment stripping, escape handling, and recursive include support. CMUX_NO_GIT_WATCH guards added consistently.
Resources/shell-integration/cmux-zsh-integration.zsh Parallel zsh implementation of the same git-subprocess-free path. Guards correctly applied. Same onbranch: gap as bash; parity otherwise maintained.
Sources/TerminalController.swift reportGitBranch updated to accept --status=unknown (nil isDirty). watchGitStatus guards added to reportGitBranch, report_pr, and report_pr_action. Logic correct.
cmuxTests/WorkspacePullRequestSidebarTests.swift New 1,437-line test file covering: no-index-lock regression, branch-only shell reports preserving dirty, watchGitStatus toggle, index v4 strip-length, sparse-checkout, stash baseline, and submodule mode handling.
Resources/Localizable.xcstrings Adds three new strings for watchGitStatus setting with entries for all 19 supported locales — previously-reported gap is fixed.

Reviews (26): Last reviewed commit: "fix: honor assume unchanged git index en..." | Re-trigger Greptile

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

1 issue found across 12 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="Sources/ContentView.swift">

<violation number="1" location="Sources/ContentView.swift:13199">
P2: Avoid reading `tabManager` inside `TabItemView`’s body. This view is marked as typing-latency sensitive and should only use precomputed parameters; pulling `tabManager.tabs` during body evaluation can invalidate the `.equatable()` optimization and cause extra re-renders while typing.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread Sources/ContentView.swift Outdated
Comment thread Sources/TabManager.swift Outdated
Comment thread Sources/TabManager.swift

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (3)
cmuxTests/SessionPersistenceTests.swift (1)

120-156: Add the missing-key compatibility case for the new optional field.

These tests cover explicit true and false, but the backward-compatible path is an older snapshot omitting gitMetadataWatcherDisabled entirely. A companion assertion that nil omits the key and decodes back as nil would protect the legacy restore behavior this optional schema change depends on.

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

In `@cmuxTests/SessionPersistenceTests.swift` around lines 120 - 156, Add a third
test to cover the backward-compatibility case where gitMetadataWatcherDisabled
is omitted: create a snapshot via makeSnapshot, explicitly set
snapshot.windows[0].tabManager.workspaces[0].gitMetadataWatcherDisabled = nil,
save with SessionPersistenceStore.save(snapshot, fileURL:), then load with
SessionPersistenceStore.load(fileURL:) and assert that
loaded?.windows.first?.tabManager.workspaces.first?.gitMetadataWatcherDisabled
is nil; optionally also read the saved JSON from snapshotURL and assert the
"gitMetadataWatcherDisabled" key is absent to ensure the encoder omits the
optional key.
cmuxTests/TabManagerUnitTests.swift (2)

511-539: The terminal-sweep branch still isn't exercised.

This covers overdue polls and suppression cases, but it never hits the nextPollAt > now + non-.open + stale lastTerminalStateRefreshAt path. Add one positive sweep assertion so the background refresh branch stays pinned.

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

In `@cmuxTests/TabManagerUnitTests.swift` around lines 511 - 539, Add a test
assertion that exercises the terminal-sweep branch of
TabManager.shouldRefreshWorkspacePullRequestForTesting by calling it with now:
now, nextPollAt set in the future (nextPollAt > now), currentPullRequestStatus
set to a non-.open value (e.g., .closed or .merged), and
lastTerminalStateRefreshAt set to a stale timestamp (e.g.,
now.addingTimeInterval(-3600)); assert that the result is true so the
terminal-sweep/background refresh path is covered. Reference
TabManager.shouldRefreshWorkspacePullRequestForTesting, nextPollAt,
lastTerminalStateRefreshAt, and currentPullRequestStatus when adding this new
positive assertion.

471-509: Add the unsupported-repository switch variant here.

This only locks down GitHub → GitHub repo switches. The brittle path is switching to a non-GitHub/unsupported repo, where the old PR badge and poll state must be cleared; without that case, the repository-switch regression can still slip through.

Based on learnings: "In applyWorkspacePullRequestRefreshResults(...), resolution .unsupportedRepository must (1) clear any existing workspace.panelPullRequests[panelId] entry and (2) reset lastTerminalState timestamp."

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

In `@cmuxTests/TabManagerUnitTests.swift` around lines 471 - 509, The test only
covers GitHub→GitHub switches; add a variant that switches the panel directory
to a non-GitHub/unsupported repo and assert that
applyWorkspacePullRequestRefreshResults handles resolution
.unsupportedRepository by clearing any existing
workspace.panelPullRequests[panelId] entry and resetting the workspace's
lastTerminalState timestamp (or the manager field that tracks it). Concretely,
in
testWorkspacePullRequestOnDemandRefreshUsesCurrentPanelDirectoryAfterRepoSwitch
add steps to (1) prime a pull-request/PR-badge state for panelId, (2) switch the
panel directory to a non-GitHub repo URL, (3) invoke the same refresh/resolution
path, and (4) assert workspace.panelPullRequests[panelId] is nil/removed and
lastTerminalState is updated/reset; reference
applyWorkspacePullRequestRefreshResults, resolution .unsupportedRepository,
workspace.panelPullRequests[panelId], and lastTerminalState to locate the logic
to validate.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@cmuxTests/TabManagerUnitTests.swift`:
- Around line 1496-1541: The test mutates the global PATH via setenv while
background git watchers may run, causing flakiness; update the test and/or code
under test so the git shim is used only for the process that computes the
workspace metadata instead of changing process-wide environment: refactor
TabManager.workspaceGitMetadataSummaryForTesting (or add a new helper) to accept
an environment dictionary or PATH string and pass that env into the subprocess
launcher used for git calls (or run the shimbed git in a serialized/external
helper process), and remove the global setenv/unsetenv usage in the test so
other watcher threads cannot observe the shimmed PATH.

In `@Sources/Workspace.swift`:
- Around line 6510-6515: The gitMetadataWatcherDisabled flag is being treated as
global across local/remote transitions; update the Workspace property logic so
it is ignored or reset for remote workspaces: in the gitMetadataWatcherDisabled
didSet (and the analogous setter around the other occurrence), only perform the
clearCachedSidebarGitMetadata() when gitMetadataWatcherDisabled changed AND
remoteConfiguration == nil (i.e., guard gitMetadataWatcherDisabled,
gitMetadataWatcherDisabled != oldValue, remoteConfiguration == nil) or
alternatively set gitMetadataWatcherDisabled = false whenever
remoteConfiguration becomes non-nil (observe/handle changes to
remoteConfiguration in Workspace and reset the flag), so remote workspaces keep
their gitBranch/pullRequest state.

---

Nitpick comments:
In `@cmuxTests/SessionPersistenceTests.swift`:
- Around line 120-156: Add a third test to cover the backward-compatibility case
where gitMetadataWatcherDisabled is omitted: create a snapshot via makeSnapshot,
explicitly set
snapshot.windows[0].tabManager.workspaces[0].gitMetadataWatcherDisabled = nil,
save with SessionPersistenceStore.save(snapshot, fileURL:), then load with
SessionPersistenceStore.load(fileURL:) and assert that
loaded?.windows.first?.tabManager.workspaces.first?.gitMetadataWatcherDisabled
is nil; optionally also read the saved JSON from snapshotURL and assert the
"gitMetadataWatcherDisabled" key is absent to ensure the encoder omits the
optional key.

In `@cmuxTests/TabManagerUnitTests.swift`:
- Around line 511-539: Add a test assertion that exercises the terminal-sweep
branch of TabManager.shouldRefreshWorkspacePullRequestForTesting by calling it
with now: now, nextPollAt set in the future (nextPollAt > now),
currentPullRequestStatus set to a non-.open value (e.g., .closed or .merged),
and lastTerminalStateRefreshAt set to a stale timestamp (e.g.,
now.addingTimeInterval(-3600)); assert that the result is true so the
terminal-sweep/background refresh path is covered. Reference
TabManager.shouldRefreshWorkspacePullRequestForTesting, nextPollAt,
lastTerminalStateRefreshAt, and currentPullRequestStatus when adding this new
positive assertion.
- Around line 471-509: The test only covers GitHub→GitHub switches; add a
variant that switches the panel directory to a non-GitHub/unsupported repo and
assert that applyWorkspacePullRequestRefreshResults handles resolution
.unsupportedRepository by clearing any existing
workspace.panelPullRequests[panelId] entry and resetting the workspace's
lastTerminalState timestamp (or the manager field that tracks it). Concretely,
in
testWorkspacePullRequestOnDemandRefreshUsesCurrentPanelDirectoryAfterRepoSwitch
add steps to (1) prime a pull-request/PR-badge state for panelId, (2) switch the
panel directory to a non-GitHub repo URL, (3) invoke the same refresh/resolution
path, and (4) assert workspace.panelPullRequests[panelId] is nil/removed and
lastTerminalState is updated/reset; reference
applyWorkspacePullRequestRefreshResults, resolution .unsupportedRepository,
workspace.panelPullRequests[panelId], and lastTerminalState to locate the logic
to validate.
🪄 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: e4be677c-a6e8-400f-b116-a815a056fcdd

📥 Commits

Reviewing files that changed from the base of the PR and between ce854b8 and a4315ce.

📒 Files selected for processing (7)
  • Sources/ContentView.swift
  • Sources/TabManager.swift
  • Sources/Workspace.swift
  • cmuxTests/SessionPersistenceTests.swift
  • cmuxTests/TabManagerSessionSnapshotTests.swift
  • cmuxTests/TabManagerUnitTests.swift
  • cmuxTests/WorkspaceGitMetadataWatcherContextMenuTests.swift
🚧 Files skipped from review as they are similar to previous changes (2)
  • cmuxTests/WorkspaceGitMetadataWatcherContextMenuTests.swift
  • cmuxTests/TabManagerSessionSnapshotTests.swift

Comment thread cmuxTests/TabManagerUnitTests.swift Outdated
Comment thread Sources/Workspace.swift Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
cmuxTests/TabManagerUnitTests.swift (1)

656-695: Consider using the new makeTempGitRepoWithInitialCommit helper.

This test manually sets up the git repository with the same pattern as the new helper function at lines 125-153. Refactoring to use the helper would reduce duplication and improve maintainability.

♻️ Proposed refactor
     func testInheritedBackgroundWorkspaceFetchesGitBranchWithoutSelection() throws {
         let fileManager = FileManager.default
-        let repoURL = fileManager.temporaryDirectory.appendingPathComponent(
-            "cmux-git-inherited-background-\(UUID().uuidString)",
-            isDirectory: true
-        )
-        try fileManager.createDirectory(at: repoURL, withIntermediateDirectories: true)
+        let repoURL = try makeTempGitRepoWithInitialCommit(prefix: "cmux-git-inherited-background")
         defer { try? fileManager.removeItem(at: repoURL) }
 
-        try runGit(["init", "-b", "main"], in: repoURL)
-        try runGit(["config", "user.name", "cmux tests"], in: repoURL)
-        try runGit(["config", "user.email", "cmux@example.invalid"], in: repoURL)
-        try "seed\n".write(
-            to: repoURL.appendingPathComponent("README.md"),
-            atomically: true,
-            encoding: .utf8
-        )
-        try runGit(["add", "README.md"], in: repoURL)
-        try runGit(["commit", "-m", "Initial commit"], in: repoURL)
-
         let manager = TabManager()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxTests/TabManagerUnitTests.swift` around lines 656 - 695, The test
testInheritedBackgroundWorkspaceFetchesGitBranchWithoutSelection duplicates repo
setup; replace the manual Git init/commit block with the new helper
makeTempGitRepoWithInitialCommit to create the temporary repo and return its
URL, then assign workspace.currentDirectory = repoURL.path as before; ensure you
preserve the defer cleanup semantics the helper provides and keep the subsequent
assertions and background workspace creation unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/Workspace.swift`:
- Around line 8148-8156: configureRemoteConnection assigns remoteConfiguration
before clearing gitMetadataWatcherDisabled, triggering its didSet to bail and
leaving stale git state (gitBranch, panelGitBranches, pullRequest,
panelPullRequests) that will be serialized by sessionSnapshot for remote
workspaces; fix by resetting/clearing the git-related state (clear gitBranch,
panelGitBranches, pullRequest, panelPullRequests) immediately after assigning
remoteConfiguration and/or flip gitMetadataWatcherDisabled before setting
remoteConfiguration so the didSet can run, ensuring unsupportedRepository
handling in TabManager is mirrored to clear PR/state when remote target changes.

---

Nitpick comments:
In `@cmuxTests/TabManagerUnitTests.swift`:
- Around line 656-695: The test
testInheritedBackgroundWorkspaceFetchesGitBranchWithoutSelection duplicates repo
setup; replace the manual Git init/commit block with the new helper
makeTempGitRepoWithInitialCommit to create the temporary repo and return its
URL, then assign workspace.currentDirectory = repoURL.path as before; ensure you
preserve the defer cleanup semantics the helper provides and keep the subsequent
assertions and background workspace creation unchanged.
🪄 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: 938f2dd9-6927-4017-85be-ad6a72129f6a

📥 Commits

Reviewing files that changed from the base of the PR and between a4315ce and d6ac717.

📒 Files selected for processing (3)
  • Sources/TabManager.swift
  • Sources/Workspace.swift
  • cmuxTests/TabManagerUnitTests.swift

Comment thread Sources/Workspace.swift Outdated
austinywang added a commit that referenced this pull request Apr 14, 2026
Addresses coderabbitai feedback on #2797: the prior fix reset
gitMetadataWatcherDisabled to false in configureRemoteConnection,
but the didSet only fires a cache clear when transitioning to true.
That meant local-origin gitBranch / panelGitBranches / pullRequest /
panelPullRequests survived the local→remote promotion, and because
sessionSnapshot() now serializes git metadata for remote workspaces,
stale local badges could get persisted and restored as remote state —
especially bad when the new remote target is unsupported or offline.

Capture the previous remoteConfiguration, and when transitioning from
local→remote or switching to a different remote target, explicitly
clear the cached sidebar git metadata before assigning the new config.
Reconfigure with the same configuration is still a no-op.

Also reorder testRemoteWorkspaceIgnoresGitMetadataWatcherDisabledFlag
to seed git state *after* the remote promotion (since the transition
now clears it) and assert the clear-on-promote behavior explicitly.

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (2)
cmuxTests/TabManagerUnitTests.swift (2)

1036-1042: Prefer waiting for metadata clear instead of same-tick assertions.

This test currently assumes workspace.gitMetadataWatcherDisabled = true clears metadata synchronously. Using waitForCondition { ... } (as done in nearby tests) would make it less brittle if clear behavior is ever scheduled asynchronously.

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

In `@cmuxTests/TabManagerUnitTests.swift` around lines 1036 - 1042, The test sets
workspace.gitMetadataWatcherDisabled = true and then asserts metadata cleared
synchronously; instead, change the assertions to waitForCondition { ... } to
poll until workspace.panelGitBranches[panelId],
workspace.panelPullRequests[panelId], workspace.gitBranch, and
workspace.pullRequest are nil, so the test tolerates asynchronous clearing;
locate the assertions referencing panelGitBranches, panelPullRequests,
gitBranch, and pullRequest and wrap them in a waitForCondition closure (as used
in nearby tests) that returns true when all four are nil.

1098-1106: Consider a small helper for global watcher defaults save/restore.

The UserDefaults snapshot/restore pattern is repeated many times. A tiny test helper (e.g., withGitWatcherDefault(_:)) would reduce repetition and lower the chance of inconsistent cleanup in future edits.

Also applies to: 1137-1145, 1194-1202, 1239-1250, 1304-1315, 1679-1690, 1730-1738, 1777-1785, 1856-1864

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

In `@cmuxTests/TabManagerUnitTests.swift` around lines 1098 - 1106, The tests
repeatedly snapshot and restore UserDefaults for
GitMetadataWatcherSettings.disabledKey, so add a small test helper (e.g.,
withGitWatcherDefault(_ value: Any?, execute: () -> Void) or
withGitWatcherDefault<T>(_ value: T?, block: () -> Void)) that captures
UserDefaults.standard.object(forKey: GitMetadataWatcherSettings.disabledKey),
sets or removes the key, runs the provided closure, and always restores the
original setting in a defer; then replace the repeated blocks around
UserDefaults.standard / GitMetadataWatcherSettings.disabledKey in
TabManagerUnitTests.swift (and the other listed ranges) with calls to this
helper to centralize snapshot/restore logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/ContentView.swift`:
- Around line 2422-2436: The helper workspaceGitMetadataWatcherContextMenuMode
currently hides the menu when any selected workspace is remote; change it to
first filter targetWorkspaces to only local ones (e.g., let localTargets =
targetWorkspaces.filter { !$0.isRemoteWorkspace }) and base the empty/isDisabled
checks and the allEffectivelyDisabled computation on localTargets so mixed
selections still show an action when locals exist; additionally thread a
gitMetadataWatcherWorkspaceIds (only local workspace IDs) through TabItemView
and include that property in TabItemView == so remote IDs are not forwarded and
equality reflects the new ID set.

In `@Sources/Workspace.swift`:
- Around line 8167-8182: When disconnectRemoteConnection(clearConfiguration:
true) demotes remote→local it must mirror the remote→remote change: call
clearCachedSidebarGitMetadata() and explicitly reset remote-related state (clear
gitBranch, panelGitBranches, pullRequest, panelPullRequests and any stored
remote git metadata) so the last remote badge doesn't survive into local
sessions; locate disconnectRemoteConnection(clearConfiguration:) and add the
same cache clear/reset logic used around remoteConfiguration handling (i.e. the
clearCachedSidebarGitMetadata() call and clearing of watcher flags/remote git
fields).

---

Nitpick comments:
In `@cmuxTests/TabManagerUnitTests.swift`:
- Around line 1036-1042: The test sets workspace.gitMetadataWatcherDisabled =
true and then asserts metadata cleared synchronously; instead, change the
assertions to waitForCondition { ... } to poll until
workspace.panelGitBranches[panelId], workspace.panelPullRequests[panelId],
workspace.gitBranch, and workspace.pullRequest are nil, so the test tolerates
asynchronous clearing; locate the assertions referencing panelGitBranches,
panelPullRequests, gitBranch, and pullRequest and wrap them in a
waitForCondition closure (as used in nearby tests) that returns true when all
four are nil.
- Around line 1098-1106: The tests repeatedly snapshot and restore UserDefaults
for GitMetadataWatcherSettings.disabledKey, so add a small test helper (e.g.,
withGitWatcherDefault(_ value: Any?, execute: () -> Void) or
withGitWatcherDefault<T>(_ value: T?, block: () -> Void)) that captures
UserDefaults.standard.object(forKey: GitMetadataWatcherSettings.disabledKey),
sets or removes the key, runs the provided closure, and always restores the
original setting in a defer; then replace the repeated blocks around
UserDefaults.standard / GitMetadataWatcherSettings.disabledKey in
TabManagerUnitTests.swift (and the other listed ranges) with calls to this
helper to centralize snapshot/restore logic.
🪄 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: bf1efd3f-3493-4702-aadd-bc1ca9971cdb

📥 Commits

Reviewing files that changed from the base of the PR and between d6ac717 and bce38fe.

📒 Files selected for processing (8)
  • Resources/Localizable.xcstrings
  • Sources/ContentView.swift
  • Sources/KeyboardShortcutSettingsFileStore.swift
  • Sources/SessionPersistence.swift
  • Sources/TabManager.swift
  • Sources/Workspace.swift
  • Sources/cmuxApp.swift
  • cmuxTests/TabManagerUnitTests.swift
✅ Files skipped from review due to trivial changes (2)
  • Sources/KeyboardShortcutSettingsFileStore.swift
  • Resources/Localizable.xcstrings
🚧 Files skipped from review as they are similar to previous changes (1)
  • Sources/SessionPersistence.swift

Comment thread Sources/ContentView.swift Outdated
Comment thread Sources/Workspace.swift Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

1 issue found across 2 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="cmuxTests/TabManagerUnitTests.swift">

<violation number="1" location="cmuxTests/TabManagerUnitTests.swift:1074">
P2: This emptiness check is effectively vacuous: metadata is already cleared before promotion, so the test won’t catch regressions where remote promotion fails to clear existing local metadata.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread cmuxTests/TabManagerUnitTests.swift Outdated
Comment thread Sources/TabManager.swift Outdated
austinywang added a commit that referenced this pull request Apr 14, 2026
Four follow-up fixes from coderabbitai, cubic, and cursor on #2797:

- WorkspaceGitEventWatcher.flushPendingPaths now captures the debounce
  timer before nilling it and calls setEventHandler({}) + cancel() on
  the fired timer, instead of dropping the reference. GCD dispatch
  sources should be cancelled before their last strong reference is
  released (cursor-bot, Sources/TabManager.swift#L428).
- Workspace.disconnectRemoteConnection(clearConfiguration: true) now
  mirrors the clear-on-promotion behavior by calling
  clearCachedSidebarGitMetadata() before nilling remoteConfiguration,
  so remote-origin sidebar badges don't survive into local sessions
  and can't be serialized as local metadata (coderabbitai major).
- ContentView.workspaceGitMetadataWatcherContextMenuMode now filters
  mixed local+remote selections down to the local subset instead of
  hiding the toggle outright, and TabItemView threads a new
  gitMetadataWatcherWorkspaceIds (local-only) through to the button
  so remote IDs are never forwarded to setWorkspaceGitMetadataWatcherDisabled
  (coderabbitai minor). Updated the existing context-menu test to
  assert the new derive-from-local-subset behavior and added an
  all-remote-hides coverage case.
- testRemoteWorkspaceIgnoresGitMetadataWatcherDisabledFlag now seeds
  local-origin panelGitBranches / panelPullRequests before the
  local→remote promotion so the post-promotion emptiness assertions
  are no longer vacuous and actually verify
  configureRemoteConnection's clear path (cubic P2).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Comment thread Sources/TabManager.swift Outdated
Comment thread Sources/TabManager.swift Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

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)
Sources/ContentView.swift (1)

10195-10211: ⚠️ Potential issue | 🟠 Major

Add a parent-level refresh hook for workspace git-watcher-disabled changes, consistent with terminalScrollBarHidden.

The parent VerticalTabsSidebar precomputes gitMetadataWatcherMenuMode at lines 10208–10211, then passes it down to TabItemView. When the user toggles the watcher via the context menu button (lines 13512–13514), setWorkspaceGitMetadataWatcherDisabled(…) mutates workspace.gitMetadataWatcherDisabled. Unlike terminalScrollBarHidden, which triggers an explicit notification observed at lines 10383–10394 to bump terminalScrollBarVisibilityGeneration and invalidate the parent's precomputed state, gitMetadataWatcherDisabled has no parent-level generation hook. The precomputed mode remains stale until an unrelated parent state change forces re-evaluation, so reopening the menu shows the old label and resends the old disabled: value.

Add a gitMetadataWatcherDisabledNotification to Workspace and post it from the didSet block, then observe it in VerticalTabsSidebar to bump a generation variable (following the terminalScrollBarHiddenDidChangeNotification pattern).

Snapshot of terminalScrollBarHidden pattern for reference
Workspace.swift:6508-6510:
    static let terminalScrollBarHiddenDidChangeNotification = Notification.Name(
        "cmux.workspaceTerminalScrollBarHiddenDidChange"
    )

ContentView.swift:10383-10394:
        .onReceive(
            NotificationCenter.default.publisher(for: Workspace.terminalScrollBarHiddenDidChangeNotification)
                .receive(on: RunLoop.main)
        ) { notification in
            guard let workspace = notification.object as? Workspace,
                  tabManager.tabs.contains(where: { $0 === workspace }) else {
                return
            }

            // Workspace scrollbar visibility changes do not publish on TabManager.tabs,
            // so bump a local generation to refresh the precomputed context-menu state.
            terminalScrollBarVisibilityGeneration &+= 1
        }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/ContentView.swift` around lines 10195 - 10211, Add a Workspace-level
notification and a parent-side observer so toggling
Workspace.gitMetadataWatcherDisabled invalidates the precomputed
gitMetadataWatcherMenuMode in the VerticalTabsSidebar/ContentView. Specifically,
add a static Notification.Name like
Workspace.gitMetadataWatcherDisabledDidChangeNotification and post it from the
didSet of Workspace.gitMetadataWatcherDisabled (same place
setWorkspaceGitMetadataWatcherDisabled mutates the property). Then in
VerticalTabsSidebar (where gitMetadataWatcherMenuMode is precomputed) add an
.onReceive(NotificationCenter.default.publisher(for:
Workspace.gitMetadataWatcherDisabledDidChangeNotification).receive(on:
RunLoop.main)) handler that checks the notification.object is the workspace in
tabManager.tabs and increments a local generation counter (e.g.
gitMetadataWatcherGeneration) analogous to terminalScrollBarVisibilityGeneration
so the precomputed gitMetadataWatcherMenuMode is recomputed.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/Workspace.swift`:
- Line 312: The code currently overwrites the persisted per-workspace flag by
setting gitMetadataWatcherDisabled to isRemoteWorkspace ? false :
gitMetadataWatcherDisabled, which loses a user's saved local opt-out; instead,
stop mutating the stored preference and keep gitMetadataWatcherDisabled equal to
the saved value, and derive runtime behavior from isRemoteWorkspace when
deciding whether to actually run the watcher (e.g., compute
effectiveWatcherDisabled = isRemoteWorkspace || gitMetadataWatcherDisabled where
the watcher decision is made). Update the initializer/snapping code that
contains gitMetadataWatcherDisabled and any other spots mentioned (lines
~8177-8182) to preserve the persisted flag and only combine it with
isRemoteWorkspace at runtime so returning to a local session restores the user’s
saved preference.

---

Outside diff comments:
In `@Sources/ContentView.swift`:
- Around line 10195-10211: Add a Workspace-level notification and a parent-side
observer so toggling Workspace.gitMetadataWatcherDisabled invalidates the
precomputed gitMetadataWatcherMenuMode in the VerticalTabsSidebar/ContentView.
Specifically, add a static Notification.Name like
Workspace.gitMetadataWatcherDisabledDidChangeNotification and post it from the
didSet of Workspace.gitMetadataWatcherDisabled (same place
setWorkspaceGitMetadataWatcherDisabled mutates the property). Then in
VerticalTabsSidebar (where gitMetadataWatcherMenuMode is precomputed) add an
.onReceive(NotificationCenter.default.publisher(for:
Workspace.gitMetadataWatcherDisabledDidChangeNotification).receive(on:
RunLoop.main)) handler that checks the notification.object is the workspace in
tabManager.tabs and increments a local generation counter (e.g.
gitMetadataWatcherGeneration) analogous to terminalScrollBarVisibilityGeneration
so the precomputed gitMetadataWatcherMenuMode is recomputed.
🪄 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: ac717944-0213-45f7-84bd-70e6c5830920

📥 Commits

Reviewing files that changed from the base of the PR and between bce38fe and 6b7054c.

📒 Files selected for processing (5)
  • Sources/ContentView.swift
  • Sources/TabManager.swift
  • Sources/Workspace.swift
  • cmuxTests/TabManagerUnitTests.swift
  • cmuxTests/WorkspaceGitMetadataWatcherContextMenuTests.swift
🚧 Files skipped from review as they are similar to previous changes (1)
  • cmuxTests/WorkspaceGitMetadataWatcherContextMenuTests.swift

Comment thread Sources/Workspace.swift Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

1 issue found across 4 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="cmuxTests/TabManagerUnitTests.swift">

<violation number="1" location="cmuxTests/TabManagerUnitTests.swift:1088">
P2: This test no longer actually flips `gitMetadataWatcherDisabled` before asserting remote metadata retention, so it may miss regressions in the flag-change path.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread cmuxTests/TabManagerUnitTests.swift Outdated
Comment thread Sources/Workspace.swift Outdated
Comment thread Sources/TabManager.swift Outdated
Comment thread Sources/TabManager.swift

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

2 issues found across 4 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name=".github/workflows/claude.yml">

<violation number="1">
P1: Use a commit-pinned ref for third-party actions to reduce CI supply-chain risk.</violation>

<violation number="2">
P1: Pin GitHub Actions to a full commit SHA instead of a mutable tag to prevent unreviewed upstream changes from altering CI behavior.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

@sbmueller

sbmueller commented May 5, 2026 •

Copy link
Copy Markdown

Worth to mention #2722 here. Any chance this can get merged soon?

@austinywang
austinywang force-pushed the issue-2722-git-index-lock-poll branch from 0810ac7 to d23d126 Compare May 12, 2026 03:30

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit b23f367. Configure here.

Comment thread Sources/TabManager.swift
@austinywang
austinywang merged commit e53b979 into main May 20, 2026
20 checks passed
@austinywang austinywang mentioned this pull request May 22, 2026
ejc3 added a commit to ejc3/cmux that referenced this pull request Jul 23, 2026
testPullRequestRefreshRepositoryDiscoveryDoesNotBlockMainRunLoop counted calls
to a stubbed `git remote -v` subprocess as its proxy for "repository discovery
ran". The refresh stopped spawning that process in manaflow-ai#2797, which replaced it with
in-process config parsing, so the counter sat at zero and the assertion failed.
The two assertions after it were worse than failing: with no discovery observed,
"did not run on the main thread" and "the run loop kept ticking" both hold when
nothing happens at all, so the test could not have caught the regression it
names.

Repository discovery is now the only blocking filesystem work the refresh does
before it reaches the network, so that is what the test should watch. This adds
GitRepositoryDiscovering for the two calls PullRequestProbeService makes while
resolving candidate seeds, and lets a host inject it. GitMetadataService conforms
and stays the only implementation the app installs, so behavior is unchanged;
PullRequestPollService and the probe service just accept the protocol instead of
the concrete type, which every existing call site already satisfies.

The test injects a discovery that counts, records its thread, and sleeps, so all
three assertions describe something that happened. It resolves no slugs, which
also keeps the refresh off the GitHub transport and away from `gh auth token`.
ejc3 added a commit to ejc3/cmux that referenced this pull request Jul 23, 2026
testPullRequestRefreshRepositoryDiscoveryDoesNotBlockMainRunLoop counted calls to
a stubbed `git remote -v` subprocess as its proxy for "repository discovery ran".
The refresh stopped spawning that process in manaflow-ai#2797, which replaced it with
in-process config parsing, so the counter sat at zero and the assertion failed.
The checks after it were worse than failing: with no discovery observed, they held
whether or not anything happened at all.

Repository discovery is the blocking filesystem work the refresh does before it
reaches the network, so that is what the test should watch. This adds
GitRepositoryDiscovering for the two calls PullRequestProbeService makes while
resolving candidate seeds, and lets a host inject it. GitMetadataService conforms
and stays the only implementation the app installs, so behavior is unchanged;
PullRequestPollService and the probe service accept the protocol instead of the
concrete type, which every existing call site already satisfies.

The test injects a discovery that counts, records its thread, and sleeps. It
resolves no slugs, which keeps the refresh off the GitHub transport and away from
`gh auth token`.

The invocation count is the assertion that can now fail for a product reason. The
thread observation is recorded but not asserted as a product property: because
`repositorySlugs` is a nonisolated async requirement, SE-0338 runs it off the
caller's actor no matter how the refresh schedules it, so asserting it would pass
even if discovery were awaited inline. The run-loop tick gap stays as a coarse
guard against a seconds-long stall.
ejc3 added a commit to ejc3/cmux that referenced this pull request Jul 23, 2026
testPullRequestRefreshRepositoryDiscoveryDoesNotBlockMainRunLoop counted calls to
a stubbed `git remote -v` subprocess as its proxy for "repository discovery ran".
The refresh stopped spawning that process in manaflow-ai#2797, which replaced it with
in-process config parsing, so the counter sat at zero and the assertion failed.
The checks after it were worse than failing: with no discovery observed, they held
whether or not anything happened at all.

Repository discovery is the blocking filesystem work the refresh does before it
reaches the network, so that is what the test should watch. This adds
GitRepositoryDiscovering for the two calls PullRequestProbeService makes while
resolving candidate seeds, and lets a host inject it. GitMetadataService conforms
and stays the only implementation the app installs, so behavior is unchanged;
PullRequestPollService and the probe service accept the protocol instead of the
concrete type, which every existing call site already satisfies.

The test injects a discovery that counts and sleeps. It resolves no slugs, which
keeps the refresh off the GitHub transport and away from `gh auth token`.

The test now makes two claims rather than three. The invocation count is the one
that can fail for a product reason, and it is the one that was broken. The
run-loop tick gap stays as a coarse guard against a seconds-long stall.

The old "discovery did not run on the main thread" check is gone, along with the
observation box that fed it. `repositorySlugs` is a nonisolated async requirement,
so SE-0338 runs it off the caller's actor however the refresh schedules it: the
check passed no matter what the product did, including if discovery were rewritten
to be awaited inline. A test that cannot fail is not evidence, and keeping it
would have implied coverage the test does not have.
ejc3 added a commit to ejc3/cmux that referenced this pull request Jul 23, 2026
testPullRequestRefreshRepositoryDiscoveryDoesNotBlockMainRunLoop counted calls to
a stubbed `git remote -v` subprocess as its proxy for "repository discovery ran".
The refresh stopped spawning that process in manaflow-ai#2797, which replaced it with
in-process config parsing, so the counter sat at zero and the assertion failed.
The checks after it were worse than failing: with no discovery observed, they held
whether or not anything happened at all.

Repository discovery is the blocking filesystem work the refresh does before it
reaches the network, so that is what the test should watch. This adds
GitRepositoryDiscovering for the two calls PullRequestProbeService makes while
resolving candidate seeds, and lets a host inject it. GitMetadataService conforms
and stays the only implementation the app installs, so behavior is unchanged;
PullRequestPollService and the probe service accept the protocol instead of the
concrete type, which every existing call site already satisfies.

The test injects a discovery that counts and sleeps. It resolves no slugs, which
keeps the refresh off the GitHub transport and away from `gh auth token`.

The test now makes two claims rather than three. The invocation count is the one
that can fail for a product reason, and it is the one that was broken. The
run-loop tick gap stays as a coarse guard against a seconds-long stall.

The old "discovery did not run on the main thread" check is gone, along with the
observation box that fed it. `repositorySlugs` is a nonisolated async requirement,
so SE-0338 runs it off the caller's actor however the refresh schedules it: the
check passed no matter what the product did, including if discovery were rewritten
to be awaited inline. A test that cannot fail is not evidence, and keeping it
would have implied coverage the test does not have.

The run-loop tick gap stays as a coarse guard, and its comment now says why it is
loose: 45 seeds times 30ms of injected blocking is 1.35s against a 2.0s ceiling, so
this test's own work cannot trip it. It fires only if the product adds a
multi-second main-thread stall on top.

The counter is renamed to RepositoryDiscoveryInvocationCounter, since it counts
discovery calls rather than command-runner calls, and the new TabManager parameter
carries a note that it overrides discovery for the pull-request refresh only.
ejc3 added a commit to ejc3/cmux that referenced this pull request Jul 25, 2026
…inst

Four session-restore tests in WorkspaceManualUnreadTests still describe the
pre-manaflow-ai#2797-era restore model, where an unread notification present at snapshot time
was dropped and came back as a purely visual "restored unread indicator" with a
count of zero.

e485692 changed that on purpose. Snapshots now carry the notifications
themselves, restore re-inserts them still unread, and the restored-unread
indicator is set only when a snapshot claims unread with no unread notification to
back it. Both gates are live: the workspace level checks
`snapshot.notifications?.contains { !$0.isRead }` before setting the indicator, and
the panel level does the same. Setting both would count one notification twice.

So these tests asserted an indicator that the product deliberately no longer sets,
and a count of zero for a notification the product deliberately keeps unread. They
now assert the restored notification directly and leave the indicator false, which
is the behavior the product implements. The independence the last two tests are
named for still holds: manual unread and a restored notification each contribute,
so the count is two until the manual half is cleared.

The assertions after markPanelRead and markRead are untouched, because marking read
clears the notification and the old expectations there were already correct. The
test names are unchanged; the CI shard timings key on them.

This branch was successfully deployed

2 active (1 outdated) deployments
Preview – cmux — ac1ff653 Deployed May 20, 2026 by vercel[bot]
Preview — 0810ac7e Deployed Apr 19, 2026 by vercel[bot]
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.

cmux 0.63.2 spawns git every ~5s, creates/removes .git/index.lock — breaks watchfiles / fastapi dev

3 participants