mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
Merge pull request #13431 from Kilo-Org/sleepy-meadow
fix(jetbrains): harden agent manager worktrees
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Keep JetBrains Agent Manager worktrees in the main repository storage, prevent nested worktree deletion from removing child worktrees, and show a clear missing-folder error for deleted workspaces.
|
||||
+4
@@ -508,6 +508,10 @@ class KiloWorkspaceRpcApiImpl internal constructor(
|
||||
status = KiloWorkspaceStatusDto.UNSUPPORTED,
|
||||
error = state.reason,
|
||||
)
|
||||
is KiloWorkspaceState.Missing -> KiloWorkspaceStateDto(
|
||||
status = KiloWorkspaceStatusDto.MISSING,
|
||||
error = state.path,
|
||||
)
|
||||
is KiloWorkspaceState.Error -> KiloWorkspaceStateDto(
|
||||
status = KiloWorkspaceStatusDto.ERROR,
|
||||
error = state.message,
|
||||
|
||||
+75
-12
@@ -83,9 +83,10 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi {
|
||||
val res = runGit(base, "worktree", "list", "--porcelain")
|
||||
if (!res.ok) return@withContext WorktreeListDto()
|
||||
val items = managedWorktrees(parseWorktreeList(res.stdout))
|
||||
val store = worktreeNameStore(items)
|
||||
val state = store?.let { syncWorktreeState(it, worktreePaths(items)) } ?: WorktreeState()
|
||||
val named = overlayWorktreeNames(items, state.names)
|
||||
val alive = items.filter { it.main || Files.isDirectory(Path.of(it.path)) }
|
||||
val store = worktreeNameStore(alive)
|
||||
val state = store?.let { syncWorktreeState(it, worktreePaths(alive)) } ?: WorktreeState()
|
||||
val named = overlayWorktreeNames(alive, state.names)
|
||||
WorktreeListDto(orderWorktrees(named, state.worktreeOrder))
|
||||
}
|
||||
|
||||
@@ -276,6 +277,14 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi {
|
||||
return Path.of(lines[0]).normalize() != Path.of(lines[1]).normalize()
|
||||
}
|
||||
|
||||
/** Main working tree for the repo containing [base]; falls back to [base] when git fails. */
|
||||
private fun mainWorktree(base: Path): Path {
|
||||
val res = runGit(base, "worktree", "list", "--porcelain")
|
||||
if (!res.ok) return base
|
||||
val main = parseWorktreeList(res.stdout).firstOrNull { it.main } ?: return base
|
||||
return Path.of(main.path).normalize()
|
||||
}
|
||||
|
||||
override suspend fun create(directory: String, request: CreateWorktreeRequestDto): CreateWorktreeResultDto =
|
||||
withContext(Dispatchers.IO) {
|
||||
val base = Path.of(directory).normalize()
|
||||
@@ -312,7 +321,12 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi {
|
||||
|
||||
/** Runs `git worktree add` under `<base>/.kilo/worktrees/<slug>` and records list bookkeeping. */
|
||||
private fun addWorktree(base: Path, branch: String, existing: Boolean, baseRef: String?): CreateWorktreeResultDto {
|
||||
val dir = base.resolve(".kilo").resolve("worktrees").resolve(branch.replace('/', '-'))
|
||||
val root = mainWorktree(base)
|
||||
val storage = root.resolve(".kilo").resolve("worktrees").normalize()
|
||||
val parts = branch.split('/')
|
||||
if (parts.any { it.isBlank() || it == "." || it == ".." }) return CreateWorktreeResultDto(error = "Invalid branch name")
|
||||
val dir = storage.resolve(branch.replace('/', '-')).normalize()
|
||||
if (dir.parent != storage) return CreateWorktreeResultDto(error = "Invalid branch name")
|
||||
Files.createDirectories(dir.parent)
|
||||
val args = buildList {
|
||||
addAll(listOf("worktree", "add"))
|
||||
@@ -327,7 +341,7 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi {
|
||||
}
|
||||
}
|
||||
LOG.info("worktree add requested: branch=$branch existing=$existing base=${baseRef ?: "(current)"} dir=$dir")
|
||||
val res = runGit(base, *args.toTypedArray())
|
||||
val res = add(base, args)
|
||||
if (!res.ok) {
|
||||
LOG.warn("worktree add failed: branch=$branch exit=${res.exit} stderr=${res.stderr.trim()}")
|
||||
return CreateWorktreeResultDto(error = res.stderr.ifBlank { "git worktree add failed" })
|
||||
@@ -347,15 +361,42 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi {
|
||||
val base = Path.of(directory).normalize()
|
||||
LOG.info("worktree remove requested: path=$path branch=${branch ?: "(none)"} force=$force base=$base")
|
||||
val list = runGit(base, "worktree", "list", "--porcelain")
|
||||
val store = (if (list.ok) worktreeNameStore(managedWorktrees(parseWorktreeList(list.stdout))) else null)
|
||||
?: base.resolve(".kilo").resolve(WORKTREE_NAMES_FILE)
|
||||
if (!list.ok) return@withContext RemoveWorktreeResultDto(error = list.stderr.ifBlank { "git worktree list failed" })
|
||||
val all = parseWorktreeList(list.stdout)
|
||||
val items = managedWorktrees(all)
|
||||
val main = all.firstOrNull { it.main }
|
||||
val storage = main?.let { Path.of(it.path).normalize().resolve(".kilo").resolve("worktrees").normalize() }
|
||||
val target = all.firstOrNull {
|
||||
val item = Path.of(it.path).normalize()
|
||||
!it.main && samePath(it.path, path) && item.parent == storage
|
||||
}
|
||||
?: return@withContext RemoveWorktreeResultDto(error = "Refusing to remove unmanaged worktree: $path")
|
||||
// Compare canonical (symlink-resolved) paths: on macOS the temp/repo root is a symlink
|
||||
// (/var -> /private/var), so a raw startsWith against normalized porcelain paths would miss
|
||||
// a live child and let `git worktree remove --force` delete it recursively.
|
||||
val root = realPath(path)
|
||||
val nested = all.filter {
|
||||
!it.prunable && Files.isDirectory(Path.of(it.path)) && !samePath(it.path, path) && realPath(it.path).startsWith(root)
|
||||
}
|
||||
if (nested.isNotEmpty()) {
|
||||
val names = nested.joinToString("\n") { it.path }
|
||||
return@withContext RemoveWorktreeResultDto(error = "Delete nested worktrees first:\n$names")
|
||||
}
|
||||
val store = worktreeNameStore(items) ?: base.resolve(".kilo").resolve(WORKTREE_NAMES_FILE)
|
||||
// Force means the user accepted removing a locked worktree; unlock first so the plain
|
||||
// remove succeeds. Unlock fails harmlessly when the tree isn't actually locked.
|
||||
if (force) {
|
||||
val unlock = runGit(base, "worktree", "unlock", path)
|
||||
val unlock = runGit(base, "worktree", "unlock", target.path)
|
||||
if (!unlock.ok) LOG.info("worktree unlock skipped: path=$path exit=${unlock.exit} stderr=${unlock.stderr.trim()}")
|
||||
}
|
||||
val res = runGit(base, "worktree", "remove", "--force", path)
|
||||
// Only skip git's own removal when the checkout directory is actually gone. Git also flags a
|
||||
// worktree prunable when its admin metadata is stale while the files remain; those must still
|
||||
// be deleted so a later create of the same slug is not blocked by leftovers.
|
||||
val res = if (!Files.isDirectory(Path.of(target.path))) {
|
||||
GitResult(0, "", "")
|
||||
} else {
|
||||
runGit(base, "worktree", "remove", "--force", target.path)
|
||||
}
|
||||
if (!res.ok) {
|
||||
val locked = res.stderr.contains("locked working tree", ignoreCase = true)
|
||||
LOG.warn("worktree remove failed: path=$path locked=$locked exit=${res.exit} stderr=${res.stderr.trim()}")
|
||||
@@ -370,7 +411,11 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi {
|
||||
if (!del.ok) LOG.warn("worktree branch delete failed: branch=$it exit=${del.exit} stderr=${del.stderr.trim()}")
|
||||
}
|
||||
LOG.info("worktree removed: path=$path branch=${branch ?: "(none)"}")
|
||||
removeWorktreeState(store, path)
|
||||
removeWorktreeState(store, target.path)
|
||||
val prune = runGit(base, "worktree", "prune")
|
||||
if (!prune.ok) LOG.warn("worktree prune failed: exit=${prune.exit} stderr=${prune.stderr.trim()}")
|
||||
runCatching { service<KiloBackendAppService>().workspaces.remove(target.path) }
|
||||
.onFailure { err -> LOG.info("workspace cache eviction skipped: path=${target.path} message=${err.message}") }
|
||||
RemoveWorktreeResultDto(ok = true)
|
||||
}
|
||||
|
||||
@@ -471,6 +516,20 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi {
|
||||
}
|
||||
}
|
||||
|
||||
private fun add(base: Path, args: List<String>): GitResult {
|
||||
val first = runGit(base, *args.toTypedArray())
|
||||
if (first.ok || !stale(first.stderr)) return first
|
||||
val prune = runGit(base, "worktree", "prune")
|
||||
if (!prune.ok) LOG.warn("worktree prune before retry failed: exit=${prune.exit} stderr=${prune.stderr.trim()}")
|
||||
return runGit(base, *args.toTypedArray())
|
||||
}
|
||||
|
||||
private fun stale(text: String): Boolean {
|
||||
return text.contains("is already checked out", ignoreCase = true) ||
|
||||
text.contains("already used by worktree", ignoreCase = true) ||
|
||||
text.contains("missing but already registered worktree", ignoreCase = true)
|
||||
}
|
||||
|
||||
private suspend fun <T, R> parallel(items: List<T>, block: suspend (T) -> R): List<R> = coroutineScope {
|
||||
val sem = Semaphore(4)
|
||||
items.map { item -> async { sem.withPermit { block(item) } } }.map { it.await() }
|
||||
@@ -637,16 +696,18 @@ internal fun parseWorktreeList(raw: String): List<WorktreeDto> {
|
||||
var branch = "(detached)"
|
||||
var locked = false
|
||||
var lockReason: String? = null
|
||||
var prunable = false
|
||||
var first = true
|
||||
fun flush() {
|
||||
val p = path ?: return
|
||||
val name = p.substringAfterLast('/').ifBlank { p }
|
||||
out.add(WorktreeDto(p, name, branch, p, main = first, locked = locked, lockReason = lockReason))
|
||||
out.add(WorktreeDto(p, name, branch, p, main = first, locked = locked, lockReason = lockReason, prunable = prunable))
|
||||
first = false
|
||||
path = null
|
||||
branch = "(detached)"
|
||||
locked = false
|
||||
lockReason = null
|
||||
prunable = false
|
||||
}
|
||||
for (line in raw.lines()) {
|
||||
when {
|
||||
@@ -656,6 +717,7 @@ internal fun parseWorktreeList(raw: String): List<WorktreeDto> {
|
||||
locked = true
|
||||
lockReason = line.removePrefix("locked").trim().takeIf { it.isNotEmpty() }
|
||||
}
|
||||
line == "prunable" || line.startsWith("prunable ") -> prunable = true
|
||||
line.isBlank() -> flush()
|
||||
}
|
||||
}
|
||||
@@ -669,8 +731,9 @@ internal fun managedWorktrees(items: List<WorktreeDto>): List<WorktreeDto> {
|
||||
val storage = root.resolve(".kilo").resolve("worktrees").normalize()
|
||||
return items.filter { item ->
|
||||
if (item.main) return@filter true
|
||||
if (item.prunable) return@filter false
|
||||
val path = Path.of(item.path).normalize()
|
||||
path.startsWith(storage) && path != storage
|
||||
path.parent == storage
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+7
@@ -28,6 +28,8 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
/**
|
||||
@@ -77,6 +79,11 @@ class KiloBackendWorkspace(
|
||||
_state.value = KiloWorkspaceState.Unsupported(reason)
|
||||
return@launch
|
||||
}
|
||||
if (!Files.isDirectory(Path.of(directory))) {
|
||||
log.info("Workspace directory is missing: $directory")
|
||||
_state.value = KiloWorkspaceState.Missing(directory)
|
||||
return@launch
|
||||
}
|
||||
val progress = AtomicReference(KiloWorkspaceLoadProgress())
|
||||
_state.value = KiloWorkspaceState.Loading(progress.get())
|
||||
|
||||
|
||||
+22
-2
@@ -8,6 +8,8 @@ import ai.kilocode.jetbrains.api.client.DefaultApi
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import okhttp3.OkHttpClient
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
@@ -74,8 +76,26 @@ class KiloBackendWorkspaceManager(
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove a workspace (e.g. when a worktree is deleted). */
|
||||
/**
|
||||
* Remove any cached workspace whose directory resolves to the same real path as [dir].
|
||||
* Callers pass git porcelain paths, while workspaces are often keyed by the resolved
|
||||
* (`toRealPath`) path or the IDE base path, so an exact-string match would miss the entry
|
||||
* and leave a deleted worktree cached as Ready — still producing backend errors.
|
||||
*/
|
||||
fun remove(dir: String) {
|
||||
workspaces.remove(dir)?.stop()
|
||||
val target = canonical(dir)
|
||||
workspaces.keys.filter { canonical(it) == target }.forEach { key ->
|
||||
log.info("Removing cached workspace for $key")
|
||||
workspaces.remove(key)?.stop()
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve symlinks on the parent so `/var/...` and `/private/var/...` compare equal even after the leaf is deleted. */
|
||||
private fun canonical(dir: String): String {
|
||||
val path = Path.of(dir).normalize()
|
||||
val parent = path.parent ?: return path.toString()
|
||||
val name = path.fileName ?: return path.toString()
|
||||
val root = runCatching { if (Files.exists(parent)) parent.toRealPath() else parent }.getOrDefault(parent)
|
||||
return root.resolve(name).toString()
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -20,6 +20,7 @@ sealed class KiloWorkspaceState {
|
||||
val skills: List<SkillInfo>,
|
||||
) : KiloWorkspaceState()
|
||||
data class Unsupported(val reason: String) : KiloWorkspaceState()
|
||||
data class Missing(val path: String) : KiloWorkspaceState()
|
||||
data class Error(val message: String, val errors: List<LoadError> = emptyList()) : KiloWorkspaceState()
|
||||
}
|
||||
|
||||
|
||||
+174
@@ -80,6 +80,26 @@ class KiloWorktreeRpcApiImplTest {
|
||||
assertEquals("Air Agent worktree", list[1].lockReason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseWorktreeList captures the prunable flag`() {
|
||||
val raw = """
|
||||
worktree /repo
|
||||
HEAD 1111111111111111111111111111111111111111
|
||||
branch refs/heads/main
|
||||
|
||||
worktree /repo/.kilo/worktrees/hyper-video
|
||||
HEAD 2222222222222222222222222222222222222222
|
||||
branch refs/heads/hyper-video
|
||||
prunable gitdir file points to non-existent location
|
||||
|
||||
""".trimIndent()
|
||||
|
||||
val list = parseWorktreeList(raw)
|
||||
|
||||
assertFalse(list[0].prunable, "main tree is not prunable")
|
||||
assertTrue(list[1].prunable, "second tree should be flagged prunable")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `managedWorktrees keeps only agent manager worktrees`() {
|
||||
val raw = """
|
||||
@@ -124,6 +144,33 @@ class KiloWorktreeRpcApiImplTest {
|
||||
assertEquals(listOf("/repo"), list.map { it.path })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `managedWorktrees rejects nested and prunable worktrees`() {
|
||||
val raw = """
|
||||
worktree /repo
|
||||
HEAD 1111111111111111111111111111111111111111
|
||||
branch refs/heads/main
|
||||
|
||||
worktree /repo/.kilo/worktrees/feature-x
|
||||
HEAD 2222222222222222222222222222222222222222
|
||||
branch refs/heads/feature/x
|
||||
|
||||
worktree /repo/.kilo/worktrees/feature-x/.kilo/worktrees/nested
|
||||
HEAD 3333333333333333333333333333333333333333
|
||||
branch refs/heads/nested
|
||||
|
||||
worktree /repo/.kilo/worktrees/dead
|
||||
HEAD 4444444444444444444444444444444444444444
|
||||
branch refs/heads/dead
|
||||
prunable gitdir file points to non-existent location
|
||||
|
||||
""".trimIndent()
|
||||
|
||||
val list = managedWorktrees(parseWorktreeList(raw))
|
||||
|
||||
assertEquals(listOf("/repo", "/repo/.kilo/worktrees/feature-x"), list.map { it.path })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `classifyGhError detects missing and unauthorized gh states`() {
|
||||
assertEquals(GhAvailability.UNAUTH, classifyGhError("You are not logged into any GitHub hosts. Run gh auth login to authenticate."))
|
||||
@@ -232,6 +279,44 @@ class KiloWorktreeRpcApiImplTest {
|
||||
assertFalse(after.any { it.branch == "feature/x" }, "removed worktree should be gone")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `create from inside linked worktree uses main worktree storage`() = runBlocking {
|
||||
initRepo()
|
||||
val first = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("feature/x")).worktree)
|
||||
|
||||
val result = api.create(first.path, CreateWorktreeRequestDto("feature/y"))
|
||||
val created = assertNotNull(result.worktree, "create failed: ${result.error}")
|
||||
|
||||
assertEquals(repo.resolve(".kilo").resolve("worktrees").resolve("feature-y").toRealPath().toString(), created.path)
|
||||
assertFalse(
|
||||
Files.exists(Path.of(first.path).resolve(".kilo").resolve("worktrees").resolve("feature-y")),
|
||||
"creating from a linked worktree must not nest storage inside it",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `create rejects a branch slug that escapes storage`() = runBlocking {
|
||||
initRepo()
|
||||
|
||||
val result = api.create(repo.toString(), CreateWorktreeRequestDto("../escape"))
|
||||
|
||||
assertNull(result.worktree)
|
||||
assertEquals("Invalid branch name", result.error)
|
||||
assertFalse(Files.exists(repo.resolve(".kilo").resolve("escape")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `create succeeds after pruning a deleted checked out branch`() = runBlocking {
|
||||
initRepo()
|
||||
val first = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("feature/x")).worktree)
|
||||
delete(Path.of(first.path))
|
||||
|
||||
val result = api.create(repo.toString(), CreateWorktreeRequestDto("feature/x", existingBranch = true))
|
||||
|
||||
val created = assertNotNull(result.worktree, "create should prune stale metadata and retry: ${result.error}")
|
||||
assertTrue(Files.isDirectory(Path.of(created.path)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `create records order so reload keeps creation order`() = runBlocking {
|
||||
initRepo()
|
||||
@@ -359,6 +444,84 @@ class KiloWorktreeRpcApiImplTest {
|
||||
assertTrue(result.error != null, "failure should carry an error message")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `remove refuses a path outside managed storage`() = runBlocking {
|
||||
initRepo()
|
||||
val outside = repo.resolve("outside")
|
||||
Files.createDirectories(outside)
|
||||
|
||||
val result = api.remove(repo.toString(), outside.toString(), null)
|
||||
|
||||
assertFalse(result.ok)
|
||||
assertTrue(result.error?.contains("Refusing") == true)
|
||||
assertTrue(Files.isDirectory(outside), "unmanaged directory must not be touched")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `remove refuses a worktree containing a live nested worktree`() = runBlocking {
|
||||
initRepo()
|
||||
val parent = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("feature/x")).worktree)
|
||||
val nested = assertNotNull(api.create(parent.path, CreateWorktreeRequestDto("feature/y")).worktree)
|
||||
val old = Path.of(parent.path).resolve(".kilo").resolve("worktrees").resolve("nested")
|
||||
Files.createDirectories(old.parent)
|
||||
git(parent.path, "worktree", "move", nested.path, old.toString())
|
||||
|
||||
val result = api.remove(repo.toString(), parent.path, parent.branch)
|
||||
|
||||
assertFalse(result.ok)
|
||||
assertTrue(result.error?.contains(old.toString()) == true, "error should name the blocker: ${result.error}")
|
||||
assertTrue(Files.isDirectory(Path.of(parent.path)))
|
||||
assertTrue(Files.isDirectory(old))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `remove succeeds when nested worktree directory is already gone`() = runBlocking {
|
||||
initRepo()
|
||||
val parent = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("feature/x")).worktree)
|
||||
val nested = assertNotNull(api.create(parent.path, CreateWorktreeRequestDto("feature/y")).worktree)
|
||||
val old = Path.of(parent.path).resolve(".kilo").resolve("worktrees").resolve("nested")
|
||||
Files.createDirectories(old.parent)
|
||||
git(parent.path, "worktree", "move", nested.path, old.toString())
|
||||
delete(old)
|
||||
|
||||
val result = api.remove(repo.toString(), parent.path, parent.branch)
|
||||
|
||||
assertTrue(result.ok, "remove should succeed despite dead nested metadata: ${result.error}")
|
||||
assertFalse(Files.exists(Path.of(parent.path)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `remove prunes dangling metadata on success`() = runBlocking {
|
||||
initRepo()
|
||||
val dead = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("dead")).worktree)
|
||||
delete(Path.of(dead.path))
|
||||
val live = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("live")).worktree)
|
||||
|
||||
val result = api.remove(repo.toString(), live.path, live.branch)
|
||||
|
||||
assertTrue(result.ok, "remove should succeed: ${result.error}")
|
||||
val out = output(repo, "worktree", "list", "--porcelain")
|
||||
assertFalse(out.contains(dead.path), "remove should prune unrelated dangling worktree metadata")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `list drops missing worktrees and reconciles stored state`() = runBlocking {
|
||||
initRepo()
|
||||
val live = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("live")).worktree)
|
||||
val dead = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("dead")).worktree)
|
||||
assertNotNull(api.rename(repo.toString(), live.path, "Live").worktree)
|
||||
assertNotNull(api.rename(repo.toString(), dead.path, "Dead").worktree)
|
||||
delete(Path.of(dead.path))
|
||||
|
||||
val listed = api.list(repo.toString()).worktrees
|
||||
|
||||
assertTrue(listed.any { it.path == live.path })
|
||||
assertFalse(listed.any { it.path == dead.path })
|
||||
val state = readWorktreeState(repo.resolve(".kilo").resolve("jetbrains.json"))
|
||||
assertEquals(mapOf(live.path to "Live"), state.names)
|
||||
assertEquals(listOf(live.path), state.worktreeOrder)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `listBranches returns local branches and the current one`() = runBlocking {
|
||||
initRepo()
|
||||
@@ -592,6 +755,17 @@ class KiloWorktreeRpcApiImplTest {
|
||||
assertEquals(0, out.exitCode, "git ${args.joinToString(" ")} failed: ${out.stderr}")
|
||||
}
|
||||
|
||||
private fun git(dir: String, vararg args: String) {
|
||||
git(Path.of(dir), *args)
|
||||
}
|
||||
|
||||
private fun output(dir: Path, vararg args: String): String {
|
||||
val cmd = GeneralCommandLine(listOf("git") + args).withWorkDirectory(dir.toFile())
|
||||
val out = CapturingProcessHandler(cmd).runProcess(30_000)
|
||||
assertEquals(0, out.exitCode, "git ${args.joinToString(" ")} failed: ${out.stderr}")
|
||||
return out.stdout
|
||||
}
|
||||
|
||||
private fun delete(dir: Path) {
|
||||
if (!Files.exists(dir)) return
|
||||
Files.walk(dir).use { paths ->
|
||||
|
||||
+53
-16
@@ -25,6 +25,8 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
@@ -39,6 +41,8 @@ class KiloBackendWorkspaceTest {
|
||||
private val log = TestLog()
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val apps = mutableListOf<KiloBackendAppService>()
|
||||
private val root: Path = Files.createTempDirectory("kilo-backend-workspace")
|
||||
private val project: Path = Files.createDirectories(root.resolve("project"))
|
||||
|
||||
@AfterTest
|
||||
fun tearDown() {
|
||||
@@ -48,6 +52,7 @@ class KiloBackendWorkspaceTest {
|
||||
scope.cancel()
|
||||
mock.close()
|
||||
withTimeout(10_000) { scope.coroutineContext[Job]?.join() }
|
||||
delete(root)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,9 +74,11 @@ class KiloBackendWorkspaceTest {
|
||||
|
||||
private suspend fun ready(app: KiloBackendAppService): KiloBackendWorkspace {
|
||||
connect(app)
|
||||
return app.workspaces.get("/test/project")
|
||||
return app.workspaces.get(project.toString())
|
||||
}
|
||||
|
||||
private fun dir(name: String): String = Files.createDirectories(root.resolve(name)).toString()
|
||||
|
||||
private suspend fun loaded(ws: KiloBackendWorkspace) {
|
||||
withTimeout(15_000) {
|
||||
ws.state.first { it is KiloWorkspaceState.Ready }
|
||||
@@ -112,8 +119,9 @@ class KiloBackendWorkspaceTest {
|
||||
val app = setup()
|
||||
connect(app)
|
||||
|
||||
val ws1 = app.workspaces.get("/test")
|
||||
val ws2 = app.workspaces.get("/test")
|
||||
val path = dir("same")
|
||||
val ws1 = app.workspaces.get(path)
|
||||
val ws2 = app.workspaces.get(path)
|
||||
// LLM note: get() starts background loading; settle it so teardown is not racing active HTTP calls in CI.
|
||||
loaded(ws1)
|
||||
assertTrue(ws1 === ws2)
|
||||
@@ -124,14 +132,16 @@ class KiloBackendWorkspaceTest {
|
||||
val app = setup()
|
||||
connect(app)
|
||||
|
||||
val ws1 = app.workspaces.get("/project-a")
|
||||
val ws2 = app.workspaces.get("/project-b")
|
||||
val first = dir("project-a")
|
||||
val second = dir("project-b")
|
||||
val ws1 = app.workspaces.get(first)
|
||||
val ws2 = app.workspaces.get(second)
|
||||
// LLM note: get() starts background loading; settle both loads before the scope-cancelling teardown.
|
||||
loaded(ws1)
|
||||
loaded(ws2)
|
||||
assertTrue(ws1 !== ws2)
|
||||
assertEquals("/project-a", ws1.directory)
|
||||
assertEquals("/project-b", ws2.directory)
|
||||
assertEquals(first, ws1.directory)
|
||||
assertEquals(second, ws2.directory)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -150,7 +160,7 @@ class KiloBackendWorkspaceTest {
|
||||
|
||||
// Manager should throw since app is disconnected
|
||||
assertFailsWith<IllegalStateException> {
|
||||
app.workspaces.get("/test/project")
|
||||
app.workspaces.get(project.toString())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,7 +197,7 @@ class KiloBackendWorkspaceTest {
|
||||
connect(app)
|
||||
|
||||
// get() creates workspace and starts loading immediately
|
||||
val ws = app.workspaces.get("/test")
|
||||
val ws = app.workspaces.get(dir("plain"))
|
||||
|
||||
withTimeout(15_000) {
|
||||
ws.state.first { it is KiloWorkspaceState.Ready }
|
||||
@@ -211,7 +221,7 @@ class KiloBackendWorkspaceTest {
|
||||
val err = ws.state.value as KiloWorkspaceState.Error
|
||||
assertTrue(err.message.contains("providers"))
|
||||
assertTrue(err.errors.any { it.resource == "providers" })
|
||||
assertTrue(log.messages.any { it.contains("Workspace error [/test/project]: Failed to load:") && it.contains("providers") })
|
||||
assertTrue(log.messages.any { it.contains("Workspace error [${project}]: Failed to load:") && it.contains("providers") })
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -298,6 +308,26 @@ class KiloBackendWorkspaceTest {
|
||||
assertEquals(0, mock.requestCount("/agent"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing directory transitions to Missing without fetching workspace data`() = runBlocking {
|
||||
val app = setup()
|
||||
connect(app)
|
||||
mock.resetCounts()
|
||||
val dir = Files.createTempDirectory("kilo-missing-workspace")
|
||||
Files.delete(dir)
|
||||
val ws = app.workspaces.get(dir.toString())
|
||||
|
||||
val state = withTimeout(15_000) {
|
||||
ws.state.first { it is KiloWorkspaceState.Missing }
|
||||
} as KiloWorkspaceState.Missing
|
||||
|
||||
assertEquals(dir.toString(), state.path)
|
||||
assertEquals(0, mock.requestCount("/agent"))
|
||||
assertEquals(0, mock.requestCount("/provider"))
|
||||
assertEquals(0, mock.requestCount("/command"))
|
||||
assertEquals(0, mock.requestCount("/skill"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `commands failure transitions to Error`() = runBlocking {
|
||||
mock.commandsStatus = 500
|
||||
@@ -446,7 +476,7 @@ class KiloBackendWorkspaceTest {
|
||||
@Test
|
||||
fun `workspace exposes sessions for its directory`() = runBlocking {
|
||||
mock.sessions = """[
|
||||
{"id":"ses_1","slug":"s","projectID":"p","directory":"/test/project","title":"T","version":"1","time":{"created":1,"updated":1}}
|
||||
{"id":"ses_1","slug":"s","projectID":"p","directory":"${project}","title":"T","version":"1","time":{"created":1,"updated":1}}
|
||||
]"""
|
||||
val app = setup()
|
||||
val ws = ready(app)
|
||||
@@ -460,7 +490,7 @@ class KiloBackendWorkspaceTest {
|
||||
@Test
|
||||
fun `workspace maps missing session timestamps to zero`() = runBlocking {
|
||||
mock.sessions = """[
|
||||
{"id":"ses_1","slug":"s","projectID":"p","directory":"/test/project","title":"T","version":"1","time":{"created":null,"updated":null}}
|
||||
{"id":"ses_1","slug":"s","projectID":"p","directory":"${project}","title":"T","version":"1","time":{"created":null,"updated":null}}
|
||||
]"""
|
||||
val app = setup()
|
||||
val ws = ready(app)
|
||||
@@ -473,14 +503,14 @@ class KiloBackendWorkspaceTest {
|
||||
|
||||
@Test
|
||||
fun `workspace creates session in its directory`() = runBlocking {
|
||||
mock.sessionCreate = """{"id":"ses_new","slug":"n","projectID":"p","directory":"/test/project","title":"New","version":"1","time":{"created":1,"updated":1}}"""
|
||||
mock.sessionCreate = """{"id":"ses_new","slug":"n","projectID":"p","directory":"${project}","title":"New","version":"1","time":{"created":1,"updated":1}}"""
|
||||
val app = setup()
|
||||
val ws = ready(app)
|
||||
loaded(ws)
|
||||
|
||||
val session = ws.createSession()
|
||||
assertEquals("ses_new", session.id)
|
||||
assertEquals("/test/project", session.directory)
|
||||
assertEquals(project.toString(), session.directory)
|
||||
}
|
||||
|
||||
// ------ Concurrency tests ------
|
||||
@@ -498,7 +528,7 @@ class KiloBackendWorkspaceTest {
|
||||
try {
|
||||
val results = (1..10).map {
|
||||
async(Dispatchers.Default) {
|
||||
manager.get("/same/dir")
|
||||
manager.get(dir("same-concurrent"))
|
||||
}
|
||||
}.awaitAll()
|
||||
|
||||
@@ -572,7 +602,7 @@ class KiloBackendWorkspaceTest {
|
||||
)
|
||||
withTimeout(15_000) { reload.await() }
|
||||
|
||||
val ws = app.workspaces.get("/test/project")
|
||||
val ws = app.workspaces.get(project.toString())
|
||||
assertTrue(ws !== initial)
|
||||
val state = withTimeout(15_000) {
|
||||
ws.state.first {
|
||||
@@ -646,4 +676,11 @@ class KiloBackendWorkspaceTest {
|
||||
{"name":"test-skill","description":"A test skill","location":"file:///test","content":"# Test"}
|
||||
]""".trimIndent()
|
||||
}
|
||||
|
||||
private fun delete(dir: Path) {
|
||||
if (!Files.exists(dir)) return
|
||||
Files.walk(dir).use { paths ->
|
||||
paths.sorted(Comparator.reverseOrder()).forEach { Files.deleteIfExists(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
@@ -2411,6 +2411,14 @@ class SessionController(
|
||||
)
|
||||
}
|
||||
|
||||
if (workspace.status == KiloWorkspaceStatusDto.MISSING) {
|
||||
return SessionControllerEvent.ConnectionChanged.ShowError(
|
||||
KiloBundle.message("session.connection.missing"),
|
||||
KiloBundle.message("session.connection.missing.detail", workspace.error ?: directory),
|
||||
"workspace",
|
||||
)
|
||||
}
|
||||
|
||||
if (app.status == KiloAppStatusDto.READY && workspace.status == KiloWorkspaceStatusDto.READY && app.warnings.isNotEmpty()) {
|
||||
return SessionControllerEvent.ConnectionChanged.ShowWarning(
|
||||
summary(app.warnings.size),
|
||||
|
||||
@@ -13,6 +13,8 @@ session.connection.downloading.version=Downloading Kilo Core v{0} ({1})… {2}%
|
||||
session.connection.error.app=Connection failed
|
||||
session.connection.error.workspace=Workspace loading failed
|
||||
session.connection.error.unknown=Unknown error
|
||||
session.connection.missing=Workspace folder missing
|
||||
session.connection.missing.detail=Kilo can''t load this session because the workspace folder no longer exists: {0}
|
||||
session.connection.retry=Try again
|
||||
session.connection.unsupported=Workspace not supported
|
||||
session.connection.unsupported.devcontainer=Kilo runs on your host machine, so it can't reach the files inside this Dev Container.
|
||||
|
||||
+19
@@ -195,6 +195,25 @@ class WorktreeControllerTest : BasePlatformTestCase() {
|
||||
assertTrue(failures.first().locked)
|
||||
}
|
||||
|
||||
fun `test refused nested remove keeps the row and surfaces the error`() {
|
||||
val item = WorktreeDto("/repo/.kilo/worktrees/feature-x", "feature-x", "feature/x", "/repo/.kilo/worktrees/feature-x")
|
||||
rpc.listed += item
|
||||
rpc.removeResult = { _, _, _ -> RemoveWorktreeResultDto(error = "Delete nested worktrees first:\n/repo/.kilo/worktrees/feature-x/.kilo/worktrees/nested") }
|
||||
val controller = controller()
|
||||
controller.reload()
|
||||
flush()
|
||||
|
||||
val failures = mutableListOf<RemoveWorktreeResultDto>()
|
||||
controller.remove(controller.model.getElementAt(0), onFailure = { failures.add(it) })
|
||||
flush()
|
||||
|
||||
assertEquals(1, controller.model.size)
|
||||
assertEquals("feature/x", controller.model.getElementAt(0).branch)
|
||||
assertNull(controller.progress(item.id))
|
||||
assertEquals(listOf(false), rpc.removeForces.toList())
|
||||
assertEquals("Delete nested worktrees first:\n/repo/.kilo/worktrees/feature-x/.kilo/worktrees/nested", failures.single().error)
|
||||
}
|
||||
|
||||
fun `test force remove passes the force flag and drops the row on success`() {
|
||||
val item = WorktreeDto("/repo/.kilo/worktrees/feature-x", "feature-x", "feature/x", "/repo/.kilo/worktrees/feature-x", locked = true)
|
||||
rpc.listed += item
|
||||
|
||||
+24
@@ -193,6 +193,30 @@ class ConnectionDelayTest : SessionControllerTestBase() {
|
||||
)
|
||||
}
|
||||
|
||||
fun `test missing workspace status shows missing folder message`() {
|
||||
appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY)
|
||||
projectRpc.state.value = workspaceReady()
|
||||
val m = controller(displayMs = 50)
|
||||
val events = collect(m)
|
||||
flush()
|
||||
events.clear()
|
||||
|
||||
projectRpc.state.value = KiloWorkspaceStateDto(
|
||||
status = KiloWorkspaceStatusDto.MISSING,
|
||||
error = "/repo/.kilo/worktrees/deleted",
|
||||
)
|
||||
pause(80)
|
||||
|
||||
val event = events.filterIsInstance<SessionControllerEvent.ConnectionChanged.ShowError>().single()
|
||||
assertEquals("Workspace folder missing", event.summary)
|
||||
assertEquals(
|
||||
"Kilo can't load this session because the workspace folder no longer exists: /repo/.kilo/worktrees/deleted",
|
||||
event.detail,
|
||||
)
|
||||
assertEquals("workspace", event.source)
|
||||
assertFalse(event.detail.orEmpty().contains("JetBrains Gateway"))
|
||||
}
|
||||
|
||||
fun `test ready hides visible delayed connection banner immediately`() {
|
||||
appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY)
|
||||
projectRpc.state.value = workspaceReady()
|
||||
|
||||
+1
@@ -8,6 +8,7 @@ enum class KiloWorkspaceStatusDto {
|
||||
LOADING,
|
||||
READY,
|
||||
UNSUPPORTED,
|
||||
MISSING,
|
||||
ERROR,
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ data class WorktreeDto(
|
||||
val main: Boolean = false, // primary working tree — not deletable
|
||||
val locked: Boolean = false, // git worktree lock — blocks a plain remove
|
||||
val lockReason: String? = null, // optional reason recorded when the tree was locked
|
||||
val prunable: Boolean = false, // git marks metadata stale because the directory is gone
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
||||
Reference in New Issue
Block a user