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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/worktree-probe-missing-dir.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---

Stop showing a false "Git is not installed" warning for worktrees that were deleted from disk
Original file line number Diff line number Diff line change
Expand Up @@ -152,13 +152,43 @@ 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, 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<WorktreeDto>? {
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 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))
}
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")
}
Expand All @@ -167,11 +197,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()
// 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) }
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))
// 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
Expand All @@ -192,6 +228,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)
Expand Down Expand Up @@ -603,6 +643,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()
Expand All @@ -614,6 +658,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}")
Expand All @@ -623,6 +674,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)}")
Expand All @@ -641,6 +698,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
Expand Down Expand Up @@ -803,6 +866,21 @@ internal fun managedWorktrees(items: List<WorktreeDto>): List<WorktreeDto> {
}
}

/**
* 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<WorktreeDto>): List<WorktreeDto> {
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,77 @@ 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 `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()
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 `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"))
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 = """
Expand Down
Loading