From 66e687169973e917e415a6401586a24cdcf6a79a Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 27 Aug 2026 22:27:57 -0400 Subject: [PATCH 1/3] fix(jetbrains): skip probes for removed worktrees --- .changeset/worktree-probe-missing-dir.md | 5 ++ .../backend/rpc/KiloWorktreeRpcApiImpl.kt | 61 +++++++++++++++++-- .../backend/rpc/KiloWorktreeRpcApiImplTest.kt | 30 +++++++++ 3 files changed, 90 insertions(+), 6 deletions(-) create mode 100644 .changeset/worktree-probe-missing-dir.md diff --git a/.changeset/worktree-probe-missing-dir.md b/.changeset/worktree-probe-missing-dir.md new file mode 100644 index 00000000000..a566e654e38 --- /dev/null +++ b/.changeset/worktree-probe-missing-dir.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Stop showing a false "Git is not installed" warning for worktrees that were deleted from disk diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt index 3cbb16bf42c..4cd3d5ab0fa 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt @@ -152,13 +152,35 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { override suspend fun stats(directory: String): WorktreeStatsListDto = withContext(Dispatchers.IO) { val root = Path.of(directory).normalize() - val res = runGit(root, "worktree", "list", "--porcelain") - if (!res.ok) return@withContext WorktreeStatsListDto() - val items = managedWorktrees(parseWorktreeList(res.stdout)) + val items = sync(root) ?: return@withContext WorktreeStatsListDto() val fallback = baseBranch(items) ?: "HEAD" WorktreeStatsListDto(parallel(items.filter { !it.main }) { item -> stats(item, fallback) }) } + /** + * Lists the managed worktrees of [root] after reconciling git's metadata with the disk: stale + * entries are pruned and entries whose checkout is gone are dropped, so callers never probe a + * directory that no longer exists. Returns null when [root] itself is gone or git cannot list. + */ + private fun sync(root: Path): List? { + if (!Files.isDirectory(root)) { + LOG.info("worktree sync skipped, directory does not exist: $root") + return null + } + val res = runGit(root, "worktree", "list", "--porcelain") + if (!res.ok) return null + val raw = parseWorktreeList(res.stdout) + val gone = raw.any { !it.main && (it.prunable || !Files.isDirectory(Path.of(it.path))) } + val synced = if (!gone) managedWorktrees(raw) else { + val prune = runGit(root, "worktree", "prune") + if (!prune.ok) LOG.warn("worktree prune during sync failed: exit=${prune.exit} stderr=${snippet(prune.stderr)}") + val again = runGit(root, "worktree", "list", "--porcelain") + if (!again.ok) return null + managedWorktrees(parseWorktreeList(again.stdout)) + } + return synced.filter { Files.isDirectory(Path.of(it.path)) } + } + override suspend fun ghStatus(directory: String): GhAvailability = withContext(Dispatchers.IO) { probeGh(Path.of(directory).normalize(), "rpc") } @@ -167,11 +189,11 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { val now = System.currentTimeMillis() prs[directory]?.takeIf { now - it.time < PR_TTL }?.let { return@withContext it.value } val root = Path.of(directory).normalize() + // Sync the worktree list before any git/gh probe so a worktree that was added and then + // deleted on disk is pruned instead of probed from a directory that no longer exists. + val all = sync(root) ?: return@withContext WorktreePrListDto().also { prs[directory] = Timed(now, it) } val available = ghAvailable(root) if (available != GhAvailability.OK) return@withContext WorktreePrListDto(available).also { prs[directory] = Timed(now, it) } - val res = runGit(root, "worktree", "list", "--porcelain") - if (!res.ok) return@withContext WorktreePrListDto().also { prs[directory] = Timed(now, it) } - val all = managedWorktrees(parseWorktreeList(res.stdout)) val items = prTargets(all) val base = baseBranch(all) var status = GhAvailability.OK @@ -192,6 +214,10 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { val now = System.currentTimeMillis() branches[directory]?.takeIf { now - it.time < PR_TTL }?.let { return@withContext it.value } val root = Path.of(directory).normalize() + if (!Files.isDirectory(root)) { + LOG.info("branch status skipped, directory does not exist: $root") + return@withContext BranchStatusDto() + } val branch = runGit(root, "branch", "--show-current").stdout.trim() val worktree = isLinkedWorktree(root) val availability = ghAvailable(root) @@ -603,6 +629,10 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { } private fun ghAvailable(root: Path): GhAvailability { + if (!Files.isDirectory(root)) { + LOG.info("gh availability skipped dir=$root missing=true") + return GhAvailability.OK + } val status = probeGh(root, "availability") if (status != GhAvailability.MISSING) return status val now = System.currentTimeMillis() @@ -614,6 +644,13 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { } private fun probeGh(root: Path, reason: String): GhAvailability = synchronized(ghLock) { + // A stale/removed worktree directory makes the process spawn fail, which would be + // misreported as GIT_MISSING. Treat a missing directory as "nothing to report" and + // don't cache it, so the next probe on a real directory still runs. + if (!Files.isDirectory(root)) { + LOG.info("gh probe skipped reason=$reason dir=$root missing=true") + return@synchronized GhAvailability.OK + } val now = System.currentTimeMillis() ghCache?.takeIf { now - it.time < GH_STATUS_TTL }?.let { LOG.info("gh probe cache hit reason=$reason value=${it.value}") @@ -623,6 +660,12 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { LOG.info("gh probe start reason=$reason dir=$root") val git = runGit(root, "--version") if (!git.ok) { + // The directory can disappear between the check above and the spawn; a failed working + // directory is not evidence that git is uninstalled, so report nothing in that case. + if (badDir(git.stderr)) { + LOG.info("gh probe skipped reason=$reason dir=$root badDir=true stderr=${snippet(git.stderr)}") + return@synchronized GhAvailability.OK + } val value = GhAvailability.GIT_MISSING ghCache = Timed(System.currentTimeMillis(), value) LOG.info("gh probe result reason=$reason value=$value exit=${git.exit} ms=${System.currentTimeMillis() - start} stderr=${snippet(git.stderr)}") @@ -641,6 +684,12 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { } +/** True when a process failed because its working directory is gone, not because the tool is absent. */ +internal fun badDir(text: String): Boolean { + val msg = text.lowercase() + return msg.contains("working directory") && (msg.contains("does not exist") || msg.contains("not a directory")) +} + internal fun classifyGhError(text: String): GhAvailability { val msg = text.lowercase() if (msg.contains("not logged") || msg.contains("gh auth login") || msg.contains("authentication")) return GhAvailability.UNAUTH diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt index 89db0c7d006..b356a8c3668 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt @@ -37,6 +37,36 @@ class KiloWorktreeRpcApiImplTest { assertFalse(api.open(repo.resolve("missing").toString())) } + @Test + fun `ghStatus does not report git missing for a removed directory`() = runBlocking { + assertEquals(GhAvailability.OK, api.ghStatus(repo.resolve("missing").toString())) + } + + @Test + fun `prStatus does not report git missing for a removed directory`() = runBlocking { + assertEquals(GhAvailability.OK, api.prStatus(repo.resolve("missing").toString()).availability) + } + + @Test + fun `stats syncs away a worktree deleted from disk`() = runBlocking { + initRepo() + val created = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("feature/x")).worktree) + assertTrue(api.stats(repo.toString()).items.any { it.path == created.path }) + + delete(Path.of(created.path)) + + assertTrue(api.stats(repo.toString()).items.none { it.path == created.path }) + // The stale entry is pruned from git metadata, so later probes never target the gone directory. + val listed = output(repo, "worktree", "list", "--porcelain") + assertFalse(listed.contains(created.path), "stale worktree should be pruned during sync: $listed") + } + + @Test + fun `badDir detects a missing working directory spawn failure`() { + assertTrue(badDir("Cannot start a process, the working directory '/tmp/gone' does not exist")) + assertFalse(badDir("Cannot run program \"git\": error=2, No such file or directory")) + } + @Test fun `parseWorktreeList reads porcelain output and flags the main tree`() { val raw = """ From 835c9f1e5cf88d2f7070fbbcc9704a98e78cd6e9 Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 28 Aug 2026 09:01:04 -0400 Subject: [PATCH 2/3] fix(jetbrains): keep gh availability reporting when worktree sync fails --- .../kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt | 12 +++++++++--- .../backend/rpc/KiloWorktreeRpcApiImplTest.kt | 11 +++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt index 4cd3d5ab0fa..78a1965d905 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt @@ -189,11 +189,17 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { val now = System.currentTimeMillis() prs[directory]?.takeIf { now - it.time < PR_TTL }?.let { return@withContext it.value } val root = Path.of(directory).normalize() - // Sync the worktree list before any git/gh probe so a worktree that was added and then - // deleted on disk is pruned instead of probed from a directory that no longer exists. - val all = sync(root) ?: return@withContext WorktreePrListDto().also { prs[directory] = Timed(now, it) } + // A gone directory reports nothing and is not cached, so a real availability problem found + // from a live directory still reaches the UI. + if (!Files.isDirectory(root)) { + LOG.info("pr status skipped, directory does not exist: $root") + return@withContext WorktreePrListDto() + } val available = ghAvailable(root) if (available != GhAvailability.OK) return@withContext WorktreePrListDto(available).also { prs[directory] = Timed(now, it) } + // Sync the worktree list before the per-worktree lookups so a worktree that was added and + // then deleted on disk is pruned instead of resolved from a directory that no longer exists. + val all = sync(root) ?: return@withContext WorktreePrListDto().also { prs[directory] = Timed(now, it) } val items = prTargets(all) val base = baseBranch(all) var status = GhAvailability.OK diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt index b356a8c3668..f543b86f845 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt @@ -47,6 +47,17 @@ class KiloWorktreeRpcApiImplTest { assertEquals(GhAvailability.OK, api.prStatus(repo.resolve("missing").toString()).availability) } + @Test + fun `branch status skips a missing directory without caching the empty result`() = runBlocking { + initRepo() + val dir = repo.resolve(".kilo").resolve("worktrees").resolve("late") + assertEquals("", api.branchStatus(dir.toString()).branch) + + git(repo, "worktree", "add", "-b", "feature/x", dir.toString()) + + assertEquals("feature/x", api.branchStatus(dir.toString()).branch) + } + @Test fun `stats syncs away a worktree deleted from disk`() = runBlocking { initRepo() From 3d7d5f0e8906b838316c8fc852fc5eb4f4405f59 Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 28 Aug 2026 10:01:42 -0400 Subject: [PATCH 3/3] fix(jetbrains): prune only stale kilo managed worktrees --- .../backend/rpc/KiloWorktreeRpcApiImpl.kt | 35 +++++++++++++++---- .../backend/rpc/KiloWorktreeRpcApiImplTest.kt | 30 ++++++++++++++++ 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt index 78a1965d905..0a9be651701 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt @@ -158,9 +158,15 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { } /** - * Lists the managed worktrees of [root] after reconciling git's metadata with the disk: stale - * entries are pruned and entries whose checkout is gone are dropped, so callers never probe a - * directory that no longer exists. Returns null when [root] itself is gone or git cannot list. + * Lists the managed worktrees of [root] after reconciling git's metadata with the disk, so + * callers never probe a directory that no longer exists. Returns null when [root] itself is gone + * or git cannot list. + * + * Dropping gone entries from the result is what makes probing safe; the prune is only metadata + * hygiene. So the prune runs exclusively when a Kilo-managed worktree is the stale one, and a + * mis-parse can at worst skip it — git re-checks every entry on disk and only ever removes + * `$GIT_DIR/worktrees` bookkeeping for a checkout it finds missing, never any files, and never a + * locked worktree (the documented guard for worktrees on unmounted volumes). */ private fun sync(root: Path): List? { if (!Files.isDirectory(root)) { @@ -170,10 +176,12 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { val res = runGit(root, "worktree", "list", "--porcelain") if (!res.ok) return null val raw = parseWorktreeList(res.stdout) - val gone = raw.any { !it.main && (it.prunable || !Files.isDirectory(Path.of(it.path))) } - val synced = if (!gone) managedWorktrees(raw) else { - val prune = runGit(root, "worktree", "prune") + val stale = staleWorktrees(raw) + val synced = if (stale.isEmpty()) managedWorktrees(raw) else { + LOG.info("worktree sync pruning stale managed worktrees: ${stale.joinToString(", ") { it.path }}") + val prune = runGit(root, "worktree", "prune", "-v") if (!prune.ok) LOG.warn("worktree prune during sync failed: exit=${prune.exit} stderr=${snippet(prune.stderr)}") + if (prune.ok && prune.stdout.isNotBlank()) LOG.info("worktree sync pruned: ${snippet(prune.stdout)}") val again = runGit(root, "worktree", "list", "--porcelain") if (!again.ok) return null managedWorktrees(parseWorktreeList(again.stdout)) @@ -858,6 +866,21 @@ internal fun managedWorktrees(items: List): List { } } +/** + * Kilo-managed worktrees under `.kilo/worktrees/` whose checkout is gone. This is the only reason + * [KiloWorktreeRpcApiImpl.sync] runs a prune, so a stale worktree the user keeps somewhere else is + * never a reason for the plugin to touch git's administrative files on a polling loop. + */ +internal fun staleWorktrees(items: List): List { + val main = items.firstOrNull { it.main } ?: return emptyList() + val storage = Path.of(main.path).normalize().resolve(".kilo").resolve("worktrees").normalize() + return items.filter { item -> + if (item.main) return@filter false + if (Path.of(item.path).normalize().parent != storage) return@filter false + item.prunable || !Files.isDirectory(Path.of(item.path)) + } +} + /** * Worktrees eligible for a PR lookup. The main working tree is included — it can sit on a PR branch * just like a linked worktree — while detached heads have no branch to resolve and prunable entries diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt index f543b86f845..3bee088c917 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt @@ -72,6 +72,36 @@ class KiloWorktreeRpcApiImplTest { assertFalse(listed.contains(created.path), "stale worktree should be pruned during sync: $listed") } + @Test + fun `stats leaves a gone worktree outside the kilo storage registered`() = runBlocking { + initRepo() + val outside = remote.resolve("elsewhere") + git(repo, "worktree", "add", "-b", "feature/outside", outside.toString()) + delete(outside) + + // The gone entry is excluded from the probe targets, but pruning someone else's worktree + // metadata is not the plugin's business, so git's bookkeeping is left untouched. + assertTrue(api.stats(repo.toString()).items.none { it.path == outside.toString() }) + val listed = output(repo, "worktree", "list", "--porcelain") + assertTrue(listed.contains(outside.toString()), "unmanaged worktree must not be pruned: $listed") + } + + @Test + fun `staleWorktrees only reports gone worktrees inside the kilo storage`() { + val main = WorktreeDto("/repo", "repo", "main", "/repo", main = true) + val managed = WorktreeDto( + "/repo/.kilo/worktrees/gone", + "gone", + "feature/gone", + "/repo/.kilo/worktrees/gone", + prunable = true, + ) + val outside = WorktreeDto("/elsewhere/gone", "gone", "feature/other", "/elsewhere/gone", prunable = true) + + assertEquals(listOf(managed.path), staleWorktrees(listOf(main, managed, outside)).map { it.path }) + assertTrue(staleWorktrees(listOf(main, outside)).isEmpty()) + } + @Test fun `badDir detects a missing working directory spawn failure`() { assertTrue(badDir("Cannot start a process, the working directory '/tmp/gone' does not exist"))