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/jetbrains-exclude-worktrees-from-index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": minor
---

Exclude Kilo-managed agent worktrees from the containing project's index, so a large `.kilo/worktrees` checkout no longer doubles indexing time or shows duplicate results in Search Everywhere. Toggle "Index agent worktrees" in Kilo Settings → Advanced to opt back in. Opening a worktree as its own project still indexes it fully.
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import ai.kilocode.backend.app.ProfileResult
import ai.kilocode.backend.cli.KiloCliPlatform
import ai.kilocode.backend.cli.KiloProps
import ai.kilocode.backend.cli.KiloRepoCli
import ai.kilocode.backend.workspace.KiloWorktreeIndexSettings
import ai.kilocode.jetbrains.api.model.KiloProfile200Response
import ai.kilocode.log.KiloLog
import ai.kilocode.log.LogConfig
Expand All @@ -36,7 +37,12 @@ import ai.kilocode.rpc.dto.ProfileKiloPassDto
import ai.kilocode.rpc.dto.ProfileOrganizationDto
import ai.kilocode.rpc.dto.ProfileStatusDto
import ai.kilocode.rpc.dto.TelemetryCaptureDto
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.application.writeAction
import com.intellij.openapi.components.service
import com.intellij.openapi.project.ProjectManager
import com.intellij.openapi.project.RootsChangeRescanningInfo
import com.intellij.openapi.roots.ex.ProjectRootManagerEx
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
Expand Down Expand Up @@ -107,6 +113,21 @@ class KiloAppRpcApiImpl : KiloAppRpcApi {
LogConfig.apply(config.level, config.contentMode, config.previewMax)
}

override suspend fun indexWorktrees(): Boolean = KiloWorktreeIndexSettings.get()

override suspend fun setIndexWorktrees(value: Boolean) {
if (KiloWorktreeIndexSettings.get() == value) return
KiloWorktreeIndexSettings.set(value)
if (ApplicationManager.getApplication() == null) return
for (project in ProjectManager.getInstance().openProjects) {
if (project.isDisposed) continue
writeAction {
ProjectRootManagerEx.getInstanceEx(project)
.makeRootsChange({}, RootsChangeRescanningInfo.RESCAN_DEPENDENCIES_IF_NEEDED)
}
}
}

override suspend fun backendLogFile(): LogFileDto? = withContext(Dispatchers.IO) {
val path = KiloLog.logFile()
if (!Files.exists(path)) return@withContext null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -410,10 +410,8 @@ class KiloWorkspaceRpcApiImpl internal constructor(
private fun project(path: Path): Project? {
if (ApplicationManager.getApplication() == null) return null
val projects = ProjectManager.getInstance().openProjects.filter { !it.isDefault }
return projects.firstOrNull { item ->
val base = item.basePath?.let(::file) ?: return@firstOrNull false
path.startsWith(base)
} ?: projects.firstOrNull()
val index = deepest(projects.map { it.basePath?.let(::file) }, path)
return index?.let { projects[it] } ?: projects.firstOrNull()
}

private fun gitAvailable(base: Path): Boolean {
Expand Down Expand Up @@ -553,3 +551,24 @@ internal fun relativeWithinWorkspace(base: Path, target: Path): String? {
if (isManagedWorktreeStorage(rel)) return null
return rel
}

/**
* Returns the index of the [bases] entry that is an ancestor of [path] with the most path
* segments, or null if none matches. A managed worktree's path is a prefix match for both the
* main checkout's base path and, when open, the worktree's own project base path; preferring the
* deepest match routes the file to the worktree's own frame instead of always defaulting to
* whichever project happened to open first.
*/
internal fun deepest(bases: List<Path?>, path: Path): Int? {
var best: Int? = null
var depth = -1
for ((index, base) in bases.withIndex()) {
if (base == null || !path.startsWith(base)) continue
val count = base.nameCount
if (count > depth) {
depth = count
best = index
}
}
return best
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package ai.kilocode.backend.workspace

import ai.kilocode.rpc.WORKTREE_STORAGE
import com.intellij.openapi.project.Project
import com.intellij.openapi.roots.impl.DirectoryIndexExcludePolicy
import com.intellij.openapi.vfs.VfsUtilCore
import com.intellij.util.ArrayUtil

/**
* Excludes `<project>/.kilo/worktrees` from this project's index when the worktree is stored inside
* the project directory. Worktrees there are indexed like any other project file by default because
* IntelliJ scanning follows the workspace model, not git — a `.git/info/exclude` entry is never
* consulted by the indexer.
*
* This intentionally does not use [com.intellij.workspaceModel.core.fileIndex.WorkspaceFileIndexContributor],
* the modern replacement suggested by this interface's KDoc: the project-scoped entity it would need
* (`ProjectRootEntity`) is `@ApiStatus.Internal`, and the content-root-scoped variant registers one
* excluded root per content root, which the platform's own contributor avoids for `JAVA_MODULE` roots
* to limit cost. [DirectoryIndexExcludePolicy] is `@ApiStatus.OverrideOnly`, not deprecated, and returns
* URLs for directories that may not exist yet, so a worktree created later is covered with no listener.
*
* Opening a worktree as its own project is unaffected: exclusions are per project, so that project
* indexes its own checkout fully.
*/
internal class KiloWorktreeExcludePolicy(private val project: Project) : DirectoryIndexExcludePolicy {
override fun getExcludeUrlsForProject(): Array<String> {
if (KiloWorktreeIndexSettings.get()) return ArrayUtil.EMPTY_STRING_ARRAY
val base = project.basePath ?: return ArrayUtil.EMPTY_STRING_ARRAY
return arrayOf(VfsUtilCore.pathToUrl("$base/$WORKTREE_STORAGE"))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package ai.kilocode.backend.workspace

import com.intellij.ide.util.PropertiesComponent

/**
* Whether Kilo-managed worktrees under `.kilo/worktrees` should be indexed by the project that
* contains them. Defaults to `false`: worktrees are excluded from the containing project's index
* (see [KiloWorktreeExcludePolicy]). Opening a worktree as its own project is unaffected either way.
*/
object KiloWorktreeIndexSettings {
private const val KEY = "kilo.indexWorktrees"

@Volatile
private var fallback = false

fun get(): Boolean {
val props = props()
return props?.getBoolean(KEY, false) ?: fallback
}

fun set(value: Boolean) {
fallback = value
val props = props()
props?.setValue(KEY, value.toString())
}

private fun props(): PropertiesComponent? = runCatching { PropertiesComponent.getInstance() }.getOrNull()
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
<platform.rpc.backend.remoteApiProvider implementation="ai.kilocode.backend.rpc.KiloWorktreeRpcApiProvider"/>
<platform.rpc.backend.remoteApiProvider implementation="ai.kilocode.backend.rpc.KiloRunRpcApiProvider"/>
<applicationService serviceImplementation="ai.kilocode.backend.migration.KiloBackendLegacyMigrationStoreService"/>
<directoryIndexExcludePolicy implementation="ai.kilocode.backend.workspace.KiloWorktreeExcludePolicy"/>
</extensions>

<applicationListeners>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package ai.kilocode.backend.rpc

import ai.kilocode.backend.workspace.KiloWorktreeIndexSettings
import kotlinx.coroutines.runBlocking
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue

class KiloAppRpcApiImplIndexWorktreesTest {

@AfterTest
fun tearDown() {
KiloWorktreeIndexSettings.set(false)
}

@Test
fun `indexWorktrees reflects persisted setting`() = runBlocking {
val impl = KiloAppRpcApiImpl()

assertFalse(impl.indexWorktrees())

impl.setIndexWorktrees(true)

assertTrue(impl.indexWorktrees())
assertEquals(true, KiloWorktreeIndexSettings.get())
}

@Test
fun `setIndexWorktrees is idempotent for an unchanged value`() = runBlocking {
val impl = KiloAppRpcApiImpl()

impl.setIndexWorktrees(false)

assertFalse(impl.indexWorktrees())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,35 @@ class WorkspacePathScopingTest {
assertNull(relativeWithinWorkspace(root, root.resolve(".kilo/worktrees/bar/backend/src/Main.java")))
}

@Test
fun `deepest prefers the nested worktree project over the main checkout`() {
val worktree = at(".kilo", "worktrees", "foo")
val file = worktree.resolve("backend/src/Main.java")

assertEquals(1, deepest(listOf(base, worktree), file))
}

@Test
fun `deepest returns null when no base matches`() {
assertNull(deepest(listOf(base, base.resolveSibling("other")), base.resolveSibling("other-2").resolve("A.kt")))
}

@Test
fun `deepest ignores null bases`() {
val worktree = at(".kilo", "worktrees", "foo")
val file = worktree.resolve("backend/src/Main.java")

assertEquals(1, deepest(listOf(null, worktree), file))
}

@Test
fun `deepest is deterministic for equal-depth matches`() {
val a = at("a")
val b = at("a")

assertEquals(0, deepest(listOf(a, b), a.resolve("A.kt")))
}

@Test
fun `normalizes encoded file URLs`() {
val path = base.resolve("dir with spaces").resolve("A.kt")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package ai.kilocode.backend.workspace

import com.intellij.openapi.project.ProjectManager
import com.intellij.openapi.vfs.VfsUtilCore
import com.intellij.testFramework.fixtures.BasePlatformTestCase

class KiloWorktreeExcludePolicyTest : BasePlatformTestCase() {
override fun tearDown() {
try {
KiloWorktreeIndexSettings.set(false)
} finally {
super.tearDown()
}
}

fun `test excludes kilo worktrees under the project base path by default`() {
val base = project.basePath!!
val policy = KiloWorktreeExcludePolicy(project)

assertOrderedEquals(
policy.getExcludeUrlsForProject().toList(),
listOf(VfsUtilCore.pathToUrl("$base/.kilo/worktrees")),
)
}

fun `test returns nothing when indexing worktrees is enabled`() {
KiloWorktreeIndexSettings.set(true)
val policy = KiloWorktreeExcludePolicy(project)

assertEmpty(policy.getExcludeUrlsForProject().toList())
}

fun `test returns nothing when the project has no base path`() {
val default = ProjectManager.getInstance().defaultProject
val policy = KiloWorktreeExcludePolicy(default)

assertEmpty(policy.getExcludeUrlsForProject().toList())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,26 @@ class KiloAppService internal constructor(
}
}

/** Whether Kilo-managed worktrees under `.kilo/worktrees` are indexed by their containing project. */
suspend fun indexWorktrees(): Boolean = try {
call { indexWorktrees() }
} catch (e: Exception) {
LOG.warn("index worktrees read failed", e)
false
}

/**
* Persist the worktree-indexing setting and reindex every open project. Runs on the app-lifetime
* [scope] so the write and the reindex it triggers survive the settings dialog closing on OK.
*/
fun setIndexWorktreesAsync(value: Boolean): Job = cs.launch {
try {
call { setIndexWorktrees(value) }
} catch (e: Exception) {
LOG.warn("index worktrees apply failed", e)
}
}

private fun setModelState(state: ModelStateDto) {
_models.value = state
_favorites.value = state.favorite
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,30 @@
package ai.kilocode.client.settings

import ai.kilocode.client.app.KiloAppService
import ai.kilocode.client.plugin.KiloBundle
import com.intellij.openapi.application.EDT
import com.intellij.openapi.application.ModalityState
import com.intellij.openapi.application.asContextElement
import com.intellij.openapi.components.service
import com.intellij.openapi.options.Configurable
import com.intellij.openapi.options.ConfigurationException
import com.intellij.openapi.options.SearchableConfigurable
import javax.swing.JComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext

class AdvancedConfigurable(
private val settings: KiloLogSettingsService = KiloLogSettingsService.getInstance(),
private val save: (KiloLogSettingsService) -> Unit = { it.apply() },
private val app: KiloAppService = service(),
private val newScope: () -> CoroutineScope = { CoroutineScope(SupervisorJob() + Dispatchers.Default) },
) : SearchableConfigurable, Configurable.NoScroll {
private var ui: AdvancedSettingsUi? = null
private var scope: CoroutineScope? = null

override fun getId(): String = ID

Expand All @@ -20,6 +34,12 @@ class AdvancedConfigurable(
settings.applyLocal()
val panel = AdvancedSettingsUi()
ui = panel
val cs = newScope()
scope = cs
cs.launch {
val value = app.indexWorktrees()
withContext(edt) { panel.refreshIndexWorktrees(value) }
}
return panel
}

Expand All @@ -32,18 +52,25 @@ class AdvancedConfigurable(
val value = panel.value()
settings.update(value.level, value.mode, value.preview)
save(settings)
val indexWorktreesChanged = value.indexWorktrees != panel.savedIndexWorktrees()
panel.sync()
// Runs on the app scope, not ours: OK calls apply() then disposeUIResources(), which cancels
// this configurable's scope before a coroutine launched here could reach the RPC.
if (indexWorktreesChanged) app.setIndexWorktreesAsync(value.indexWorktrees)
}

override fun reset() {
ui?.resetForm()
}

override fun disposeUIResources() {
scope?.cancel()
scope = null
ui = null
}

companion object {
const val ID = "ai.kilocode.jetbrains.settings.advanced"
private val edt = Dispatchers.EDT + ModalityState.any().asContextElement()
}
}
Loading
Loading