Merge pull request #13440 from Kilo-Org/bold-island

feat(jetbrains): add From PR and From Branch tabs to New Worktree dialog
This commit is contained in:
Kirill Kalishev
2026-08-26 15:54:01 -04:00
committed by GitHub
16 changed files with 1137 additions and 196 deletions
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Detect a worktree's pull request reliably in Agent Manager. Imported PRs — including PRs from forks — hand-made worktrees, and locally renamed branches now show their PR badge, the current repository row gets one too, and a freshly imported PR no longer waits out the status poll. Imported PR branches also get proper git tracking, so `git push` and `git pull` work in the new worktree.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Stop the New Worktree dialog from flashing the previous tab's content when switching between New, From PR, and From Branch.
@@ -3,6 +3,7 @@ package ai.kilocode.backend.rpc
import ai.kilocode.backend.app.KiloBackendAppService
import ai.kilocode.log.KiloLog
import ai.kilocode.rpc.KiloWorktreeRpcApi
import ai.kilocode.rpc.parsePrUrl
import ai.kilocode.rpc.dto.BranchStatusDto
import ai.kilocode.rpc.dto.CreateWorktreeRequestDto
import ai.kilocode.rpc.dto.CreateWorktreeResultDto
@@ -72,6 +73,7 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi {
private val bases = ConcurrentHashMap<String, Timed<String>>()
private val prs = ConcurrentHashMap<String, Timed<WorktreePrListDto>>()
private val branches = ConcurrentHashMap<String, Timed<BranchStatusDto>>()
private val resolver = PrResolver(gh = ::runGh, git = ::runGit)
private val ghLock = Any()
@Volatile
private var ghProbe: Timed<GhAvailability>? = null
@@ -153,8 +155,7 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi {
val res = runGit(root, "worktree", "list", "--porcelain")
if (!res.ok) return@withContext WorktreeStatsListDto()
val items = managedWorktrees(parseWorktreeList(res.stdout))
val main = items.firstOrNull { it.main }
val fallback = main?.branch?.takeIf { it.isNotBlank() && it != "(detached)" } ?: "HEAD"
val fallback = baseBranch(items) ?: "HEAD"
WorktreeStatsListDto(parallel(items.filter { !it.main }) { item -> stats(item, fallback) })
}
@@ -170,18 +171,17 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi {
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 items = managedWorktrees(parseWorktreeList(res.stdout)).filter { !it.main && it.branch != "(detached)" }
val all = managedWorktrees(parseWorktreeList(res.stdout))
val items = prTargets(all)
val base = baseBranch(all)
var status = GhAvailability.OK
val data = parallel(items) { item ->
if (status != GhAvailability.OK) return@parallel null
val out = runGh(Path.of(item.path).normalize(), "pr", "view", item.branch, "--json", "number,state,isDraft,url,title")
if (!out.ok) {
// prError only ever returns UNAUTH or OK; a missing gh/git binary is already caught
// by the upfront ghAvailable() check before this loop runs.
if (prError(out.stderr) == GhAvailability.UNAUTH) status = GhAvailability.UNAUTH
return@parallel null
}
parsePr(item.path, out.stdout)
val lookup = resolver.resolve(item.path, item.branch, base)
// The resolver only ever reports UNAUTH or OK; a missing gh/git binary is already
// caught by the upfront ghAvailable() check before this loop runs.
if (lookup.availability != GhAvailability.OK) status = lookup.availability
lookup.pr
}.filterNotNull()
val dto = WorktreePrListDto(status, if (status == GhAvailability.OK) data else emptyList())
prs[directory] = Timed(System.currentTimeMillis(), dto)
@@ -195,15 +195,18 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi {
val branch = runGit(root, "branch", "--show-current").stdout.trim()
val worktree = isLinkedWorktree(root)
val availability = ghAvailable(root)
val pr = if (availability == GhAvailability.OK && branch.isNotBlank()) {
val out = runGh(root, "pr", "view", branch, "--json", "number,state,isDraft,url,title,headRefName")
// Only accept a PR whose head branch matches the current branch. Guards against gh
// resolving a PR via upstream/remote configuration that isn't for this branch.
if (out.ok && parsePrHeadRef(out.stdout) == branch) parsePr(directory, out.stdout) else null
val lookup = if (availability == GhAvailability.OK && branch.isNotBlank()) {
resolver.resolve(directory, branch, baseBranch(root))
} else {
null
PrLookup()
}
val dto = BranchStatusDto(branch = branch, worktree = worktree, availability = availability, pr = pr)
val dto = BranchStatusDto(
branch = branch,
worktree = worktree,
// A PR lookup that hits an auth failure must not be reported as a branch without a PR.
availability = if (availability == GhAvailability.OK) lookup.availability else availability,
pr = lookup.pr,
)
branches[directory] = Timed(System.currentTimeMillis(), dto)
dto
}
@@ -285,6 +288,23 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi {
return Path.of(main.path).normalize()
}
/** Branch checked out in the main working tree of the repo containing [root]. */
private fun baseBranch(root: Path): String? {
val res = runGit(root, "worktree", "list", "--porcelain")
if (!res.ok) return null
return baseBranch(parseWorktreeList(res.stdout))
}
/**
* Drops the PR and branch caches so the next poll reflects a mutation immediately. Entries are
* keyed by the requesting directory and a mutation can change any repository the backend has
* answered for, so clear wholesale rather than by key.
*/
private fun invalidate() {
prs.clear()
branches.clear()
}
override suspend fun create(directory: String, request: CreateWorktreeRequestDto): CreateWorktreeResultDto =
withContext(Dispatchers.IO) {
val base = Path.of(directory).normalize()
@@ -303,18 +323,18 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi {
GhAvailability.UNAUTH -> return@withContext CreateWorktreeResultDto(error = "GitHub CLI (gh) is not authorized")
GhAvailability.OK -> Unit
}
val view = runGh(base, "pr", "view", ref.number.toString(), "--repo", "${ref.owner}/${ref.repo}", "--json", "headRefName,title")
val fields = "headRefName,title,isCrossRepository,headRepositoryOwner"
val view = runGh(base, "pr", "view", ref.number.toString(), "--repo", "${ref.owner}/${ref.repo}", "--json", fields)
if (!view.ok) {
LOG.warn("pr import view failed: url=$url exit=${view.exit} stderr=${view.stderr.trim()}")
return@withContext CreateWorktreeResultDto(error = view.stderr.ifBlank { "gh pr view failed" })
}
val branch = parsePrHeadRef(view.stdout).ifBlank { "pr-${ref.number}" }
// The pull ref works for both same-repo and fork PRs without adding a fork remote; the
// leading '+' force-updates a stale local branch from a previous import attempt.
val fetch = runGit(base, "fetch", "origin", "+refs/pull/${ref.number}/head:$branch")
if (!fetch.ok) {
LOG.warn("pr import fetch failed: url=$url exit=${fetch.exit} stderr=${fetch.stderr.trim()}")
return@withContext CreateWorktreeResultDto(error = fetch.stderr.ifBlank { "git fetch failed" })
val head = parsePrHead(view.stdout)
val branch = prBranchName(head, ref.number)
val failure = fetchPrBranch({ args -> runGit(base, args) }, ref.number, head, branch)
if (failure != null) {
LOG.warn("pr import fetch failed: url=$url exit=${failure.exit} stderr=${failure.stderr.trim()}")
return@withContext CreateWorktreeResultDto(error = failure.stderr.ifBlank { "Failed to check out the pull request branch" })
}
addWorktree(base, branch, existing = true, baseRef = null)
}
@@ -347,6 +367,7 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi {
return CreateWorktreeResultDto(error = res.stderr.ifBlank { "git worktree add failed" })
}
LOG.info("worktree created: branch=$branch dir=$dir")
invalidate()
val path = dir.toRealPath().toString()
val list = runGit(base, "worktree", "list", "--porcelain")
val items = if (list.ok) managedWorktrees(parseWorktreeList(list.stdout)) else emptyList()
@@ -393,7 +414,7 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi {
// 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, "", "")
CmdOut(0, "", "")
} else {
runGit(base, "worktree", "remove", "--force", target.path)
}
@@ -411,6 +432,7 @@ 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)"}")
invalidate()
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()}")
@@ -488,35 +510,35 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi {
}
}
private data class GitResult(val exit: Int, val stdout: String, val stderr: String) {
val ok get() = exit == 0
}
private data class Timed<T>(val time: Long, val value: T)
private fun runGit(base: Path, vararg args: String): GitResult {
private fun runGit(base: Path, vararg args: String): CmdOut = runGit(base, args.toList())
private fun runGit(base: Path, args: List<String>): CmdOut {
return try {
val cmd = GeneralCommandLine(listOf("git") + args).withWorkDirectory(base.toFile())
val out = CapturingProcessHandler(cmd).runProcess(30_000)
GitResult(if (out.isTimeout) -1 else out.exitCode, out.stdout, out.stderr)
CmdOut(if (out.isTimeout) -1 else out.exitCode, out.stdout, out.stderr)
} catch (e: Exception) {
GitResult(-1, "", e.message ?: "git failed")
CmdOut(-1, "", e.message ?: "git failed")
}
}
private fun runGh(base: Path, vararg args: String): GitResult {
private fun runGh(base: Path, vararg args: String): CmdOut = runGh(base, args.toList())
private fun runGh(base: Path, args: List<String>): CmdOut {
return try {
val cmd = GeneralCommandLine(listOf("gh") + args)
.withWorkDirectory(base.toFile())
.withParentEnvironmentType(ParentEnvironmentType.CONSOLE)
val out = CapturingProcessHandler(cmd).runProcess(30_000)
GitResult(if (out.isTimeout) -1 else out.exitCode, out.stdout, out.stderr)
CmdOut(if (out.isTimeout) -1 else out.exitCode, out.stdout, out.stderr)
} catch (e: Exception) {
GitResult(-1, "", e.message ?: "gh failed")
CmdOut(-1, "", e.message ?: "gh failed")
}
}
private fun add(base: Path, args: List<String>): GitResult {
private fun add(base: Path, args: List<String>): CmdOut {
val first = runGit(base, *args.toTypedArray())
if (first.ok || !stale(first.stderr)) return first
val prune = runGit(base, "worktree", "prune")
@@ -613,13 +635,6 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi {
value
}
private fun prError(stderr: String): GhAvailability {
val text = stderr.lowercase()
if (text.contains("not logged") || text.contains("gh auth login") || text.contains("authentication")) return GhAvailability.UNAUTH
if (text.contains("not found") || text.contains("no pull requests found")) return GhAvailability.OK
return GhAvailability.OK
}
private fun snippet(text: String): String {
return text.trim().replace(Regex("\\s+"), " ").take(180)
}
@@ -650,21 +665,72 @@ internal fun parsePr(path: String, raw: String): WorktreePrDto? {
return WorktreePrDto(path, number, state, url, title)
}
internal data class PrRef(val owner: String, val repo: String, val number: Int)
/** Head of a pull request being imported. */
internal data class PrHead(val ref: String = "", val cross: Boolean = false, val owner: String = "")
private val PR_URL = Regex("github\\.com[/:]([^/]+)/([^/]+?)(?:\\.git)?/pull/(\\d+)")
/** Parses `https://github.com/<owner>/<repo>/pull/<n>` (and ssh-style hosts) into its parts. */
internal fun parsePrUrl(url: String): PrRef? {
val match = PR_URL.find(url.trim()) ?: return null
val number = match.groupValues[3].toIntOrNull() ?: return null
return PrRef(match.groupValues[1], match.groupValues[2], number)
/** Reads the head branch and its repository out of a `gh pr view --json` payload. */
internal fun parsePrHead(raw: String): PrHead {
val obj = runCatching { json.parseToJsonElement(raw) as? JsonObject }.getOrNull() ?: return PrHead()
val ref = obj["headRefName"]?.jsonPrimitive?.content?.trim().orEmpty()
val cross = obj["isCrossRepository"]?.jsonPrimitive?.booleanOrNull == true
val owner = (obj["headRepositoryOwner"] as? JsonObject)?.get("login")?.jsonPrimitive?.content?.trim().orEmpty()
return PrHead(ref, cross, owner)
}
/** Reads `headRefName` out of a `gh pr view --json` payload. */
internal fun parsePrHeadRef(raw: String): String {
val obj = runCatching { json.parseToJsonElement(raw) as? JsonObject }.getOrNull() ?: return ""
return obj["headRefName"]?.jsonPrimitive?.content?.trim().orEmpty()
/**
* Local branch name for an imported PR. Fork PRs are prefixed with their owner so two PRs sharing a
* head branch name — `patch-1` is common — can be imported side by side.
*/
internal fun prBranchName(head: PrHead, number: Int): String {
if (head.ref.isBlank()) return "pr-$number"
val owner = head.owner.lowercase()
return if (head.cross && owner.isNotEmpty()) "$owner/${head.ref}" else head.ref
}
/**
* Fetches the PR head into [branch] and records which PR it belongs to, mirroring `gh pr checkout`:
* a same-repo PR gets an ordinary upstream (so `git push`/`git pull` work in the imported worktree),
* while a fork PR is tracked through `refs/pull/<number>/head`, which `gh` resolves back to the PR
* by number. [run] executes git in the repository. Returns the failing command, or null on success.
*/
internal fun fetchPrBranch(run: (List<String>) -> CmdOut, number: Int, head: PrHead, branch: String): CmdOut? {
val pull = "refs/pull/$number/head"
// A fork head lives in a repository we may have no remote for. The pull ref reaches it without
// adding one, and '+' force-updates a stale branch left by an earlier import attempt.
if (head.cross || head.ref.isBlank()) {
val fetch = run(listOf("fetch", "origin", "+$pull:$branch"))
if (!fetch.ok) return fetch
recordPrBranch(run, branch, pull)
return null
}
val tracking = "refs/remotes/origin/${head.ref}"
val direct = run(listOf("fetch", "origin", "+refs/heads/${head.ref}:$tracking"))
if (!direct.ok) {
// The head branch is gone — merged PR, or the author deleted it — but the pull ref survives.
val fallback = run(listOf("fetch", "origin", "+$pull:$tracking"))
if (!fallback.ok) return fallback
}
val point = run(listOf("branch", "--force", branch, tracking))
if (!point.ok) return point
recordPrBranch(run, branch, if (direct.ok) "refs/heads/${head.ref}" else pull)
return null
}
/**
* Records the branch's remote and merge ref. This is what lets a PR be recognised later without
* guessing from the branch name, so a failure only degrades PR detection to slower lookups and must
* never fail the import.
*/
private fun recordPrBranch(run: (List<String>) -> CmdOut, branch: String, merge: String) {
listOf(
listOf("config", "branch.$branch.remote", "origin"),
listOf("config", "branch.$branch.merge", merge),
).forEach { args ->
val res = run(args)
if (!res.ok) {
KiloWorktreeRpcApiImpl.LOG.warn("pr import config failed: args=$args exit=${res.exit} stderr=${res.stderr.trim()}")
}
}
}
private val json = Json { prettyPrint = true; ignoreUnknownKeys = true }
@@ -737,6 +803,20 @@ internal fun managedWorktrees(items: List<WorktreeDto>): List<WorktreeDto> {
}
}
/**
* 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
* have no checkout left.
*/
internal fun prTargets(items: List<WorktreeDto>): List<WorktreeDto> {
return items.filter { !it.prunable && it.branch != "(detached)" }
}
/** Branch checked out in the main working tree, or null when it is missing or detached. */
internal fun baseBranch(items: List<WorktreeDto>): String? {
return items.firstOrNull { it.main }?.branch?.takeIf { it.isNotBlank() && it != "(detached)" }
}
internal fun overlayWorktreeNames(items: List<WorktreeDto>, names: Map<String, String>): List<WorktreeDto> {
if (names.isEmpty()) return items
return items.map { item ->
@@ -0,0 +1,102 @@
package ai.kilocode.backend.rpc
import ai.kilocode.rpc.dto.GhAvailability
import ai.kilocode.rpc.dto.WorktreePrDto
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.nio.file.Path
/** Result of running a `git`/`gh` command. */
internal data class CmdOut(val exit: Int, val stdout: String, val stderr: String) {
val ok get() = exit == 0
}
/** PR for one checkout, plus the gh availability observed while resolving it. */
internal data class PrLookup(val pr: WorktreePrDto? = null, val availability: GhAvailability = GhAvailability.OK)
internal const val PR_FIELDS = "number,state,isDraft,url,title"
/**
* Resolves the pull request a checkout belongs to. A worktree can reach a PR in several ways —
* Kilo's PR import, `gh pr checkout`, a hand-made `git worktree add`, a branch renamed locally, a
* fork PR — so identity is resolved by branch config or head commit rather than by branch name
* alone, in increasing order of cost:
*
* 1. `gh pr view` with no selector. The only form that honours `branch.<name>.merge`, so it
* resolves `refs/pull/N/head` branches by PR number and fork PRs through the push remote.
* 2. `gh pr view <branch>`. Matches same-repo branches pushed to origin, no branch config needed.
* Cannot match a fork PR: gh compares against `owner:branch` for cross-repository heads.
* 3. `gh pr list --search "<HEAD sha>"`, accepting only an exact `headRefOid` match.
*
* Commands are injected so the strategy ladder is testable without `gh` or network access.
*/
internal class PrResolver(
private val gh: (Path, List<String>) -> CmdOut,
private val git: (Path, List<String>) -> CmdOut,
) {
/**
* Resolves the PR for the checkout at [path] on [branch]. [base] is the repository's base
* branch; a PR headed by it is not worth a search query, so strategy 3 is skipped there.
*/
fun resolve(path: String, branch: String, base: String?): PrLookup {
val dir = Path.of(path).normalize()
view(dir, path, null)?.let { return it }
view(dir, path, branch)?.let { return it }
if (branch == base) return PrLookup()
return search(dir, path) ?: PrLookup()
}
/** Null means "no PR here, keep looking"; a value is terminal (a PR, or gh being unusable). */
private fun view(dir: Path, path: String, branch: String?): PrLookup? {
val args = buildList {
add("pr")
add("view")
branch?.let { add(it) }
add("--json")
add(PR_FIELDS)
}
val out = gh(dir, args)
if (!out.ok) return unusable(out.stderr)
return parsePr(path, out.stdout)?.let { PrLookup(it) }
}
private fun search(dir: Path, path: String): PrLookup? {
val head = git(dir, listOf("rev-parse", "HEAD")).stdout.trim()
if (head.isEmpty()) return null
val out = gh(
dir,
listOf("pr", "list", "--state", "all", "--search", "$head is:pr", "--limit", "5", "--json", "$PR_FIELDS,headRefOid"),
)
if (!out.ok) return unusable(out.stderr)
val items = runCatching { json.parseToJsonElement(out.stdout) as? JsonArray }.getOrNull() ?: return null
for (item in items) {
val obj = item as? JsonObject ?: continue
// The search matches commit mentions too, so only an exact head match is our PR.
if (obj["headRefOid"]?.jsonPrimitive?.content != head) continue
parsePr(path, obj.toString())?.let { return PrLookup(it) }
}
return null
}
private fun unusable(stderr: String): PrLookup? {
val status = prError(stderr)
return if (status == GhAvailability.OK) null else PrLookup(availability = status)
}
}
/**
* Classifies a failing `gh pr` command. A missing PR is the normal case, so anything that is not a
* recognised authorization failure counts as OK — a missing `gh` binary is caught by the upfront
* availability probe instead.
*/
internal fun prError(stderr: String): GhAvailability {
val text = stderr.lowercase()
if (text.contains("not logged") || text.contains("gh auth login") || text.contains("authentication")) {
return GhAvailability.UNAUTH
}
return GhAvailability.OK
}
private val json = Json { ignoreUnknownKeys = true }
@@ -1,5 +1,6 @@
package ai.kilocode.backend.rpc
import ai.kilocode.rpc.parsePrUrl
import ai.kilocode.rpc.dto.CreateWorktreeRequestDto
import ai.kilocode.rpc.dto.GhAvailability
import ai.kilocode.rpc.dto.GhState
@@ -22,11 +23,13 @@ import kotlin.test.assertTrue
class KiloWorktreeRpcApiImplTest {
private val repo: Path = Files.createTempDirectory("kilo-worktree")
private val remote: Path = Files.createTempDirectory("kilo-origin")
private val api = KiloWorktreeRpcApiImpl()
@AfterTest
fun tearDown() {
delete(repo)
delete(remote)
}
@Test
@@ -593,9 +596,118 @@ class KiloWorktreeRpcApiImplTest {
}
@Test
fun `parsePrHeadRef reads headRefName`() {
assertEquals("feature/login", parsePrHeadRef("""{"headRefName":"feature/login","title":"x"}"""))
assertEquals("", parsePrHeadRef("not json"))
fun `parsePrHead reads head branch and repository`() {
val same = parsePrHead("""{"headRefName":"feature/login","title":"x","isCrossRepository":false}""")
assertEquals("feature/login", same.ref)
assertFalse(same.cross)
val fork = parsePrHead(
"""{"headRefName":"patch-1","isCrossRepository":true,"headRepositoryOwner":{"login":"Contributor"}}""",
)
assertEquals("patch-1", fork.ref)
assertTrue(fork.cross)
assertEquals("Contributor", fork.owner)
assertEquals(PrHead(), parsePrHead("not json"))
}
@Test
fun `prBranchName prefixes fork heads and falls back to the pr number`() {
assertEquals("feature/login", prBranchName(PrHead("feature/login"), 7))
assertEquals("contributor/patch-1", prBranchName(PrHead("patch-1", cross = true, owner = "Contributor"), 7))
// A cross-repo PR whose owner gh did not report still needs a usable branch name.
assertEquals("patch-1", prBranchName(PrHead("patch-1", cross = true), 7))
assertEquals("pr-7", prBranchName(PrHead(), 7))
}
@Test
fun `prTargets keeps the main tree and drops detached and prunable entries`() {
val items = listOf(
WorktreeDto("/repo", "repo", "main", "/repo", main = true),
WorktreeDto("/repo/.kilo/worktrees/a", "a", "feature/a", "/repo/.kilo/worktrees/a"),
WorktreeDto("/repo/.kilo/worktrees/detached", "detached", "(detached)", "/repo/.kilo/worktrees/detached"),
WorktreeDto("/repo/.kilo/worktrees/gone", "gone", "feature/gone", "/repo/.kilo/worktrees/gone", prunable = true),
)
assertEquals(listOf("/repo", "/repo/.kilo/worktrees/a"), prTargets(items).map { it.path })
}
@Test
fun `baseBranch reads the main tree branch and ignores a detached one`() {
val main = WorktreeDto("/repo", "repo", "main", "/repo", main = true)
val linked = WorktreeDto("/repo/.kilo/worktrees/a", "a", "feature/a", "/repo/.kilo/worktrees/a")
assertEquals("main", baseBranch(listOf(main, linked)))
assertNull(baseBranch(listOf(main.copy(branch = "(detached)"), linked)))
assertNull(baseBranch(listOf(linked)))
}
@Test
fun `fetchPrBranch tracks the head branch for a same-repo pull request`() {
initRepo()
val origin = originWith(pull = 7, head = "feature/login")
val failure = fetchPrBranch(runner(repo), 7, PrHead("feature/login"), "feature/login")
assertNull(failure, "same-repo import should succeed")
assertEquals("origin", config("branch.feature/login.remote"))
assertEquals("refs/heads/feature/login", config("branch.feature/login.merge"))
assertEquals(
head(origin, "refs/heads/feature/login"),
head(repo, "refs/heads/feature/login"),
"local branch should point at the fetched head",
)
}
@Test
fun `fetchPrBranch falls back to the pull ref when the head branch is gone`() {
initRepo()
val origin = originWith(pull = 7, head = "feature/login")
git(origin, "update-ref", "-d", "refs/heads/feature/login")
val failure = fetchPrBranch(runner(repo), 7, PrHead("feature/login"), "feature/login")
assertNull(failure, "import should fall back to the pull ref")
assertEquals("refs/pull/7/head", config("branch.feature/login.merge"))
assertEquals(head(origin, "refs/pull/7/head"), head(repo, "refs/heads/feature/login"))
}
@Test
fun `fetchPrBranch tracks the pull ref for a fork pull request`() {
initRepo()
val origin = originWith(pull = 7, head = "patch-1")
// A fork head is not on origin at all; only the pull ref can reach it.
git(origin, "update-ref", "-d", "refs/heads/patch-1")
val fork = PrHead("patch-1", cross = true, owner = "contributor")
val failure = fetchPrBranch(runner(repo), 7, fork, prBranchName(fork, 7))
assertNull(failure, "fork import should succeed")
assertEquals("origin", config("branch.contributor/patch-1.remote"))
assertEquals("refs/pull/7/head", config("branch.contributor/patch-1.merge"))
assertEquals(head(origin, "refs/pull/7/head"), head(repo, "refs/heads/contributor/patch-1"))
}
@Test
fun `fetchPrBranch force updates a branch left by an earlier import`() {
initRepo()
val origin = originWith(pull = 7, head = "feature/login")
git(repo, "branch", "feature/login")
val failure = fetchPrBranch(runner(repo), 7, PrHead("feature/login"), "feature/login")
assertNull(failure, "re-import should refresh the stale branch")
assertEquals(head(origin, "refs/heads/feature/login"), head(repo, "refs/heads/feature/login"))
}
@Test
fun `fetchPrBranch reports the failing command`() {
initRepo()
val failure = fetchPrBranch(runner(repo), 7, PrHead("feature/login"), "feature/login")
assertNotNull(failure, "a repo without origin cannot fetch a pull request")
assertFalse(failure.ok)
}
@Test
@@ -752,6 +864,39 @@ class KiloWorktreeRpcApiImplTest {
git(repo, "commit", "-m", "init")
}
/**
* Builds an "origin" repository holding [head] plus a `refs/pull/<pull>/head` ref pointing at it,
* the shape GitHub exposes for a pull request, and registers it as [repo]'s origin.
*/
private fun originWith(pull: Int, head: String): Path {
git(remote, "init")
git(remote, "config", "user.email", "test@kilo.ai")
git(remote, "config", "user.name", "Kilo Test")
Files.writeString(remote.resolve("README.md"), "origin")
git(remote, "add", "README.md")
git(remote, "commit", "-m", "init")
val base = output(remote, "branch", "--show-current").trim()
git(remote, "checkout", "-b", head)
Files.writeString(remote.resolve("pr.txt"), "pr work\n")
git(remote, "add", "pr.txt")
git(remote, "commit", "-m", "pr work")
git(remote, "update-ref", "refs/pull/$pull/head", "refs/heads/$head")
// Leave the PR head unchecked out so tests can delete it to emulate a deleted branch.
git(remote, "checkout", base)
git(repo, "remote", "add", "origin", remote.toString())
return remote
}
private fun runner(dir: Path): (List<String>) -> CmdOut = { args ->
val cmd = GeneralCommandLine(listOf("git") + args).withWorkDirectory(dir.toFile())
val out = CapturingProcessHandler(cmd).runProcess(30_000)
CmdOut(if (out.isTimeout) -1 else out.exitCode, out.stdout, out.stderr)
}
private fun config(key: String): String = output(repo, "config", "--get", key).trim()
private fun head(dir: Path, ref: String): String = output(dir, "rev-parse", ref).trim()
private fun git(dir: Path, vararg args: String) {
val cmd = GeneralCommandLine(listOf("git") + args).withWorkDirectory(dir.toFile())
val out = CapturingProcessHandler(cmd).runProcess(30_000)
@@ -0,0 +1,120 @@
package ai.kilocode.backend.rpc
import ai.kilocode.rpc.dto.GhAvailability
import ai.kilocode.rpc.dto.GhState
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class PrResolverTest {
private val path = "/repo/.kilo/worktrees/feature-x"
private val calls = mutableListOf<List<String>>()
@Test
fun `resolves through branch config without falling back`() {
val resolver = resolver(view = { pr(7, "OPEN") })
val lookup = resolver.resolve(path, "feature/x", base = "main")
val pull = assertNotNull(lookup.pr)
assertEquals(7, pull.number)
assertEquals(path, pull.path)
assertEquals(GhState.OPEN, pull.state)
// The config-driven form answered, so the branch selector and the search never run.
assertEquals(listOf(listOf("pr", "view", "--json", PR_FIELDS)), calls)
}
@Test
fun `falls back to the branch selector when config resolves nothing`() {
val resolver = resolver(view = { args -> if (args.contains("feature/x")) pr(8, "DRAFT") else missing() })
val lookup = resolver.resolve(path, "feature/x", base = "main")
assertEquals(8, assertNotNull(lookup.pr).number)
assertEquals(GhState.DRAFT, lookup.pr?.state)
assertEquals(2, calls.size, "the head search should not run once the branch selector answered")
}
@Test
fun `falls back to searching the head commit`() {
val resolver = resolver(
view = { missing() },
list = { ok("""[{"number":9,"state":"MERGED","isDraft":false,"url":"https://pr/9","title":"Fork work","headRefOid":"$SHA"}]""") },
)
val lookup = resolver.resolve(path, "renamed-locally", base = "main")
val pull = assertNotNull(lookup.pr, "an exact head match should resolve the PR")
assertEquals(9, pull.number)
assertEquals(GhState.MERGED, pull.state)
assertTrue(calls.any { it.contains("$SHA is:pr") }, "the search should use the head sha")
}
@Test
fun `rejects a search hit whose head commit differs`() {
val resolver = resolver(
view = { missing() },
// The GitHub search also matches PRs that merely mention the commit.
list = { ok("""[{"number":9,"state":"OPEN","isDraft":false,"url":"https://pr/9","headRefOid":"deadbeef"}]""") },
)
assertNull(resolver.resolve(path, "renamed-locally", base = "main").pr)
}
@Test
fun `skips the head search for the base branch`() {
val resolver = resolver(view = { missing() }, list = { throw IllegalStateException("must not search") })
assertNull(resolver.resolve("/repo", "main", base = "main").pr)
assertEquals(2, calls.size, "only the two view forms should run for the base branch")
}
@Test
fun `reports an authorization failure instead of a missing pull request`() {
val resolver = resolver(view = { CmdOut(1, "", "gh auth login required") })
val lookup = resolver.resolve(path, "feature/x", base = "main")
assertNull(lookup.pr)
assertEquals(GhAvailability.UNAUTH, lookup.availability)
assertEquals(1, calls.size, "an unusable gh must stop the ladder immediately")
}
@Test
fun `treats a missing pull request as a clean result`() {
val resolver = resolver(view = { missing() }, list = { ok("[]") })
val lookup = resolver.resolve(path, "feature/x", base = "main")
assertNull(lookup.pr)
assertEquals(GhAvailability.OK, lookup.availability)
}
private fun resolver(
view: (List<String>) -> CmdOut,
list: (List<String>) -> CmdOut = { ok("[]") },
): PrResolver = PrResolver(
gh = { _, args ->
calls.add(args)
if (args.getOrNull(1) == "list") list(args) else view(args)
},
git = { _, args ->
calls.add(args)
assertEquals(listOf("rev-parse", "HEAD"), args)
ok("$SHA\n")
},
)
private fun pr(number: Int, state: String): CmdOut =
ok("""{"number":$number,"state":"$state","isDraft":${state == "DRAFT"},"url":"https://pr/$number","title":"Work"}""")
private fun ok(stdout: String) = CmdOut(0, stdout, "")
private fun missing() = CmdOut(1, "", "no pull requests found for branch \"feature/x\"")
private companion object {
const val SHA = "1111111111111111111111111111111111111111"
}
}
@@ -1,8 +1,11 @@
package ai.kilocode.client.agentManager
import ai.kilocode.client.KiloNotifications
import ai.kilocode.client.agentManager.worktree.CreateFailure
import ai.kilocode.client.agentManager.worktree.CreateKind
import ai.kilocode.client.agentManager.worktree.NewWorktreeDialog
import ai.kilocode.client.agentManager.worktree.NewWorktreeHandle
import ai.kilocode.client.agentManager.worktree.NewWorktreePlan
import ai.kilocode.client.agentManager.worktree.GhBanner
import ai.kilocode.client.agentManager.worktree.WorktreeController
import ai.kilocode.client.agentManager.worktree.WorktreeDataKeys
@@ -24,6 +27,7 @@ import ai.kilocode.client.diff.diffParams
import ai.kilocode.client.diff.ensureDiffEditorKind
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.SessionActivityKind
import ai.kilocode.client.telemetry.Telemetry
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.list.ActiveList
import ai.kilocode.client.ui.list.ActiveListBadge
@@ -133,6 +137,13 @@ class AgentManagerPanel(
if (list.select(key)) list.focusList()
item(key)?.takeIf { controller.progress(it.id) == null }?.let { open(it, focus = false) }
}
// A fresh worktree changes what git reports, so bypass the refresh throttle instead of
// leaving the new row without its stats and PR badge until the next poll.
controller.onCreated = {
project?.service<WorktreeStatusService>()?.refreshStats()
project?.service<WorktreeStatusService>()?.refreshPr(force = true)
}
controller.onReload = { sync() }
controller.onCreateFailure = { err -> notifyCreateFailed(err) }
controller.onMoveFailure = { err -> notifyMoveFailed(err) }
controller.onRemoveSuccess = { item, index -> onRemoved(item, index) }
@@ -183,7 +194,17 @@ class AgentManagerPanel(
if (!handle.showAndGet()) return
val plan = handle.result() ?: return
onCreate()
controller.create(plan.branch, plan.base, prompt = plan.prompt)
when (plan) {
is NewWorktreePlan.Create -> controller.create(plan.branch, plan.base, prompt = plan.prompt)
is NewWorktreePlan.Branch -> {
Telemetry.send("Worktree Import Submitted", mapOf("kind" to "branch"))
controller.importBranch(plan.branch)
}
is NewWorktreePlan.Pr -> {
Telemetry.send("Worktree Import Submitted", mapOf("kind" to "pr"))
controller.importPr(plan.url)
}
}
}
internal fun move(sessionId: String?, directory: String) = controller.move(sessionId, directory)
@@ -241,7 +262,7 @@ class AgentManagerPanel(
/** The PR URL for [item], or null when it has none or is not in a stable, openable state. */
private fun prUrl(item: WorktreeDto?): String? {
if (item == null || item.main) return null
if (item == null) return null
if (controller.progress(item.id) != null) return null
return prs[normalizeWorktreePath(item.path)]?.url
}
@@ -322,8 +343,13 @@ class AgentManagerPanel(
return controller.model.getElementAt(index.coerceIn(0, size - 1))
}
private fun notifyCreateFailed(err: String?) {
KiloNotifications.error(project, KiloBundle.message("worktree.create.failed.title"), err)
private fun notifyCreateFailed(failure: CreateFailure) {
val title = when (failure.kind) {
CreateKind.CREATE -> KiloBundle.message("worktree.create.failed.title")
CreateKind.BRANCH -> KiloBundle.message("worktree.import.branch.failed.title", failure.branch)
CreateKind.PR -> KiloBundle.message("worktree.import.pr.failed.title")
}
KiloNotifications.error(project, title, failure.error)
}
private fun notifyMoveFailed(err: String?) {
@@ -374,7 +400,8 @@ class AgentManagerPanel(
progress = null,
kind = controller.kind(item.path),
stats = null,
pr = null,
// The main checkout can sit on a PR branch just like a worktree can.
pr = prs[normalizeWorktreePath(item.path)],
current = true,
)
}
@@ -434,6 +461,8 @@ class AgentManagerPanel(
override fun dispose() {
controller.onSelect = null
controller.onCreated = null
controller.onReload = null
controller.onCreateFailure = null
controller.onMoveFailure = null
controller.onRemoveSuccess = null
@@ -0,0 +1,127 @@
package ai.kilocode.client.agentManager.worktree
import ai.kilocode.client.session.ui.prompt.PromptFuzzyRanker
import com.intellij.openapi.ui.ComboBox
import com.intellij.ui.DocumentAdapter
import java.awt.Dimension
import java.awt.event.FocusAdapter
import java.awt.event.FocusEvent
import javax.swing.ComboBoxModel
import javax.swing.DefaultComboBoxModel
import javax.swing.JTextField
import javax.swing.event.DocumentEvent
import javax.swing.plaf.basic.BasicComboBoxUI
import javax.swing.plaf.basic.BasicComboPopup
internal class BranchPicker(branches: List<String>, private val default: String = "") :
ComboBox<String>(model(branches, default)) {
private val branches = ordered(branches, default)
private val set = this.branches.toSet()
private var syncing = false
val empty: Boolean get() = branches.isEmpty()
init {
isEditable = true
if (default.isNotBlank()) selectedItem = default
wire()
}
override fun getPreferredSize(): Dimension {
ensureEditor()
return super.getPreferredSize()
}
fun resolve(): String? {
val value = text()
if (value.isEmpty()) {
val fallback = default.trim()
if (fallback.isNotEmpty()) set(fallback)
return fallback.takeIf { it.isNotEmpty() }
}
if (value in set) return value
val idx = match(value) ?: return value
val target = branches[idx]
set(target)
return target
}
fun known(value: String?): Boolean = value == null || value in set
fun focusText() {
field()?.apply {
requestFocusInWindow()
selectAll()
}
}
private fun wire() {
val field = field() ?: return
field.document.addDocumentListener(object : DocumentAdapter() {
override fun textChanged(e: DocumentEvent) {
if (!syncing) sync(field.text, popup = true)
}
})
field.addFocusListener(object : FocusAdapter() {
override fun focusLost(e: FocusEvent) {
restore()
}
})
}
private fun restore() {
if (default.isBlank() || text().isNotEmpty()) return
set(default)
}
private fun sync(text: String, popup: Boolean) {
val value = text.trim()
if (value.isEmpty()) return
if (popup && isShowing && !isPopupVisible) isPopupVisible = true
val idx = match(value) ?: return
val list = popupList() ?: return
if (list.selectedIndex != idx) list.selectedIndex = idx
list.ensureIndexIsVisible(idx)
}
private fun match(text: String): Int? {
val rank = PromptFuzzyRanker(text)
return branches.withIndex().mapNotNull { item ->
rank.score(item.value, emptyList())?.let { score -> item.index to score }
}.maxByOrNull { it.second }?.first
}
private fun popupList() = (getAccessibleContext()?.getAccessibleChild(0) as? BasicComboPopup)?.list
private fun field() = editor.editorComponent as? JTextField
private fun text() = field()?.text?.trim() ?: editor.item?.toString()?.trim().orEmpty()
private fun ensureEditor() {
if (!isEditable) return
val ui = ui as? BasicComboBoxUI ?: return
val comp = editor.editorComponent ?: return
if (components.none { it === comp }) ui.addEditor()
}
private fun set(value: String) {
syncing = true
try {
selectedItem = value
field()?.text = value
} finally {
syncing = false
}
}
}
private fun ordered(branches: List<String>, default: String): List<String> {
val ordered = LinkedHashSet<String>()
if (default.isNotBlank()) ordered.add(default)
ordered.addAll(branches)
return ordered.toList()
}
private fun model(branches: List<String>, default: String): ComboBoxModel<String> {
return DefaultComboBoxModel(ordered(branches, default).toTypedArray())
}
@@ -10,22 +10,29 @@ import ai.kilocode.client.session.ui.model.ModelPicker
import ai.kilocode.client.session.ui.model.modelItems
import ai.kilocode.client.session.ui.prompt.KiloPromptCompletionProvider
import ai.kilocode.client.session.ui.prompt.MentionAction
import ai.kilocode.client.session.ui.prompt.PromptFuzzyRanker
import ai.kilocode.client.session.ui.prompt.PromptPanel
import ai.kilocode.client.session.ui.prompt.SlashAction
import ai.kilocode.client.settings.base.BaseContentPanel
import ai.kilocode.client.settings.base.SettingsRows
import ai.kilocode.client.settings.base.SettingsStackedRow
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.layout.Stack
import ai.kilocode.rpc.dto.ModelsWorkspaceDto
import ai.kilocode.rpc.parsePrUrl
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.application.ModalityState
import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project
import com.intellij.openapi.ui.ComboBox
import com.intellij.openapi.ui.DialogWrapper
import com.intellij.ui.DocumentAdapter
import com.intellij.ui.components.JBTextField
import com.intellij.ui.tabs.JBTabs
import com.intellij.ui.tabs.JBTabsFactory
import com.intellij.ui.tabs.JBTabsPosition
import com.intellij.ui.tabs.TabInfo
import com.intellij.ui.tabs.TabsListener
import com.intellij.util.ui.FormBuilder
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.components.BorderLayoutPanel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -33,19 +40,16 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import java.awt.Component
import java.awt.GridBagConstraints
import java.awt.event.FocusAdapter
import java.awt.event.FocusEvent
import javax.swing.ComboBoxModel
import javax.swing.DefaultComboBoxModel
import javax.swing.JComponent
import javax.swing.JTextField
import javax.swing.event.DocumentEvent
import javax.swing.plaf.basic.BasicComboPopup
private const val NAME_COLUMNS = 67
/** What the user confirmed in the New Worktree dialog. */
data class NewWorktreePlan(val branch: String, val base: String?, val prompt: PendingPrompt?)
sealed interface NewWorktreePlan {
data class Create(val branch: String, val base: String?, val prompt: PendingPrompt?) : NewWorktreePlan
data class Branch(val branch: String) : NewWorktreePlan
data class Pr(val url: String) : NewWorktreePlan
}
/**
* The New Worktree dialog as seen by its caller: show it, then read what the user confirmed.
@@ -57,10 +61,16 @@ interface NewWorktreeHandle {
}
/**
* New Worktree dialog with parity to the VS Code Agent Manager dialog: a worktree name (top), an
* initial prompt with the same mode / model / reasoning pickers as the chat prompt (center), and the
* branch name + base branch (bottom). Creating a worktree starts a session automatically with the
* prompt.
* New Worktree dialog with parity to the VS Code Agent Manager dialog, split into three tabs:
*
* - **New** creates a branch: a worktree name (top), an initial prompt with the same mode / model /
* reasoning pickers as the chat prompt (center), and the branch name + base branch (bottom).
* Creating a worktree starts a session automatically with the prompt.
* - **From PR** checks out a GitHub pull request by URL.
* - **From Branch** checks out a local branch that no worktree holds yet.
*
* Both import tabs carry no initial prompt, so the worktree opens with an empty session. The
* selected tab alone decides which input the OK button acts on.
*
* The dialog performs no worktree work itself — it records the confirmed [result] and closes; the
* panel then drives the controller, so no view switch or worktree work runs while the modal dialog
@@ -103,13 +113,12 @@ internal class NewWorktreeDialog(
showEnhance = false,
)
private val branch = JBTextField(suggestedName)
private val bases = baseBranches(branches, defaultBase)
private val baseSet = bases.toSet()
private val base = ComboBox(baseModel(bases)).apply {
isEditable = true
selectedItem = defaultBase
private val base = BranchPicker(branches, defaultBase)
private val url = JBTextField().apply {
emptyText.text = KiloBundle.message("worktree.import.pr.placeholder")
}
private var syncing = false
private val pick = BranchPicker(branches)
private var tab = DialogTab.NEW
private var plan: NewWorktreePlan? = null
@@ -128,25 +137,25 @@ internal class NewWorktreeDialog(
private var center: JComponent? = null
init {
wireBase()
if (pick.empty) pick.isEnabled = false
title = KiloBundle.message("worktree.configure.title")
init()
setOKButtonText(KiloBundle.message("worktree.dialog.create"))
}
override fun createCenterPanel(): JComponent = content().also { center = it }
override fun createCenterPanel(): JComponent = tabs().also { center = it }
/** The built content, so tests can drive the real Swing tree before the dialog is shown. */
internal fun centerComponent(): JComponent = center ?: error("center panel not built")
override fun result(): NewWorktreePlan? = plan
override fun getPreferredFocusedComponent(): JComponent = prompt.defaultFocusedComponent
override fun getPreferredFocusedComponent(): JComponent = focus()
// Versioned: DialogWrapper persists the size per key, so a stale entry would keep the old width.
override fun getDimensionServiceKey(): String = "ai.kilocode.NewWorktreeDialog.v2"
override fun getDimensionServiceKey(): String = "ai.kilocode.NewWorktreeDialog.v3"
override fun doOKAction() = submitCreate()
override fun doOKAction() = submit()
override fun dispose() {
disposed = true
@@ -154,7 +163,50 @@ internal class NewWorktreeDialog(
super.dispose()
}
private fun content(): JComponent {
internal fun submit() {
setErrorText(null)
when (tab) {
DialogTab.PR -> submitPr()
DialogTab.BRANCH -> submitBranch()
DialogTab.NEW -> submitCreate()
}
}
private fun tabs(): JComponent {
val fresh = TabInfo(newContent()).setText(KiloBundle.message("worktree.dialog.tab.new"))
val pr = TabInfo(prContent()).setText(KiloBundle.message("worktree.dialog.tab.pr"))
val local = TabInfo(branchContent()).setText(KiloBundle.message("worktree.dialog.tab.branch"))
val tabs: JBTabs = JBTabsFactory.createTabs(project, disposable).apply {
presentation.setSingleRow(true)
presentation.setTabsPosition(JBTabsPosition.top)
presentation.showBorder = false
addTab(fresh).setPreferredFocusableComponent(prompt.defaultFocusedComponent)
addTab(pr).setPreferredFocusableComponent(url)
addTab(local).setPreferredFocusableComponent(pick)
addListener(object : TabsListener {
override fun beforeSelectionChanged(oldSelection: TabInfo?, newSelection: TabInfo?) {
// JBTabs defers removing the old body while focus settles, and that body keeps
// its previous bounds. Hide it before layout so stale content cannot paint over
// the newly selected tab.
newSelection?.component?.isVisible = true
oldSelection?.component?.isVisible = false
}
override fun selectionChanged(oldSelection: TabInfo?, newSelection: TabInfo?) {
tab = when {
newSelection === pr -> DialogTab.PR
newSelection === local -> DialogTab.BRANCH
else -> DialogTab.NEW
}
setOKButtonText(KiloBundle.message(if (tab == DialogTab.NEW) "worktree.dialog.create" else "worktree.dialog.import"))
ui { focus().requestFocusInWindow() }
}
}, disposable)
}
return tabs.component
}
private fun newContent(): JComponent {
wirePickers()
loadModels()
return Stack.vertical(gap = UiStyle.Gap.pad())
@@ -164,6 +216,27 @@ internal class NewWorktreeDialog(
.apply { border = JBUI.Borders.empty(UiStyle.Gap.sm()) }
}
private fun prContent(): JComponent = importContent(SettingsStackedRow(
KiloBundle.message("worktree.import.pr.section"),
description = KiloBundle.message("worktree.import.pr.description"),
value = url,
))
private fun branchContent(): JComponent = importContent(SettingsStackedRow(
KiloBundle.message("worktree.import.branch.section"),
description = KiloBundle.message(if (pick.empty) "worktree.import.branch.empty" else "worktree.import.branch.description"),
value = pick,
))
private fun importContent(row: JComponent): JComponent {
val body = BaseContentPanel().apply {
border = JBUI.Borders.empty(UiStyle.Gap.pad(), UiStyle.Gap.sm(), UiStyle.Gap.pad(), UiStyle.Gap.sm())
}
body.next(SettingsRows().row(row))
// Pinned to the top: the import forms are shorter than the New tab, which sizes the dialog.
return BorderLayoutPanel().apply { addToTop(body) }
}
// A FormBuilder that stretches every field to the full width, so the base-branch combo matches
// the name field and prompt above it.
private fun fields(): JComponent = object : FormBuilder() {
@@ -229,99 +302,60 @@ internal class NewWorktreeDialog(
)
}
private fun wireBase() {
val field = baseField() ?: return
field.document.addDocumentListener(object : DocumentAdapter() {
override fun textChanged(e: DocumentEvent) {
if (!syncing) syncBase(field.text, popup = true)
}
})
field.addFocusListener(object : FocusAdapter() {
override fun focusLost(e: FocusEvent) {
restoreBase()
}
})
}
private fun restoreBase() {
if (baseText().isNotEmpty() || defaultBase.isBlank()) return
setBase(defaultBase)
}
private fun syncBase(text: String, popup: Boolean) {
val value = text.trim()
if (value.isEmpty()) return
if (popup && base.isShowing && !base.isPopupVisible) {
base.isPopupVisible = true
}
val idx = matchBase(value) ?: return
val list = popupList() ?: return
if (list.selectedIndex != idx) list.selectedIndex = idx
list.ensureIndexIsVisible(idx)
}
private fun matchBase(text: String): Int? {
val rank = PromptFuzzyRanker(text)
return bases.withIndex().mapNotNull { item ->
rank.score(item.value, emptyList())?.let { score -> item.index to score }
}.maxByOrNull { it.second }?.first
}
private fun popupList() = (base.accessibleContext?.getAccessibleChild(0) as? BasicComboPopup)?.list
private fun baseField() = base.editor.editorComponent as? JTextField
private fun baseText() = baseField()?.text?.trim()
?: base.editor.item?.toString()?.trim().orEmpty()
private fun setBase(value: String) {
syncing = true
try {
base.selectedItem = value
baseField()?.text = value
} finally {
syncing = false
}
}
private fun resolvedBase(): String? {
val value = baseText()
if (value.isEmpty()) {
val fallback = defaultBase.trim()
if (fallback.isNotEmpty()) setBase(fallback)
return fallback.takeIf { it.isNotEmpty() }
}
if (value in baseSet) return value
val idx = matchBase(value) ?: return value
val target = bases[idx]
setBase(target)
return target
}
private fun validBase(value: String?): Boolean {
if (value == null || value in baseSet) return true
if (base.known(value)) return true
KiloNotifications.error(
project,
KiloBundle.message("worktree.configure.base.invalid.title"),
KiloBundle.message("worktree.configure.base.invalid.content", value),
KiloBundle.message("worktree.configure.base.invalid.content", value.orEmpty()),
)
baseField()?.apply {
requestFocusInWindow()
selectAll()
}
syncBase(value, popup = true)
base.focusText()
return false
}
private fun submitCreate(text: String = prompt.text()) {
val explicit = branch.text.trim()
val resolved = explicit.ifEmpty { name.text.trim() }.ifEmpty { suggestedName }
val target = resolvedBase()
val target = base.resolve()
if (!validBase(target)) return
plan = NewWorktreePlan(resolved, target, pending(text))
plan = NewWorktreePlan.Create(resolved, target, pending(text))
close(OK_EXIT_CODE)
}
private fun submitPr() {
val value = url.text.trim()
if (value.isEmpty()) {
setErrorText(KiloBundle.message("worktree.import.pr.required"), url)
url.requestFocusInWindow()
return
}
if (parsePrUrl(value) == null) {
setErrorText(KiloBundle.message("worktree.import.pr.invalid"), url)
url.requestFocusInWindow()
url.selectAll()
return
}
plan = NewWorktreePlan.Pr(value)
close(OK_EXIT_CODE)
}
private fun submitBranch() {
val target = pick.resolve()
if (target == null || !pick.known(target)) {
setErrorText(KiloBundle.message("worktree.import.branch.invalid"), pick)
pick.focusText()
return
}
plan = NewWorktreePlan.Branch(target)
close(OK_EXIT_CODE)
}
private fun focus(): JComponent = when (tab) {
DialogTab.PR -> url
DialogTab.BRANCH -> pick
DialogTab.NEW -> prompt.defaultFocusedComponent
}
/** Bundles the typed prompt with the picked mode / model / reasoning, or null when empty. */
private fun pending(text: String): PendingPrompt? {
val body = text.trim()
@@ -361,16 +395,7 @@ internal class NewWorktreeDialog(
spec.available,
)
private fun baseBranches(branches: List<String>, default: String): List<String> {
val ordered = LinkedHashSet<String>()
if (default.isNotBlank()) ordered.add(default)
ordered.addAll(branches)
return ordered.toList()
}
private fun baseModel(branches: List<String>): ComboBoxModel<String> {
return DefaultComboBoxModel(branches.toTypedArray())
}
private fun variantTitle(value: String): String = value.replaceFirstChar { it.titlecase() }
private enum class DialogTab { NEW, PR, BRANCH }
}
@@ -20,6 +20,10 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
enum class CreateKind { CREATE, BRANCH, PR }
data class CreateFailure(val error: String?, val kind: CreateKind, val branch: String)
/**
* Owns the worktree list model and drives the [KiloWorktreeService] off the EDT. Model mutations
* are marshalled back onto the EDT via [edt]. Mirrors the History stack's controller shape.
@@ -37,7 +41,17 @@ class WorktreeController(
private val tasks = LinkedHashMap<String, String>()
private val moves = LinkedHashSet<String>()
var onSelect: ((String) -> Unit)? = null
var onCreateFailure: ((String?) -> Unit)? = null
/** Fired on the EDT once a worktree exists, so callers can refresh state git just changed. */
var onCreated: ((WorktreeDto) -> Unit)? = null
/**
* Fired on the EDT after a reload settled. Replacing the model only notifies list listeners when
* the rows actually changed, so a repo whose sole worktree is the main one would otherwise never
* render its [current] row.
*/
var onReload: (() -> Unit)? = null
var onCreateFailure: ((CreateFailure) -> Unit)? = null
var onMoveFailure: ((String?) -> Unit)? = null
var onRemoveSuccess: ((WorktreeDto, Int) -> Unit)? = null
var onActivityChanged: (() -> Unit)? = null
@@ -97,6 +111,7 @@ class WorktreeController(
val worktreeBranches = rows.mapTo(HashSet()) { it.branch }
branches = branchInfo.branches.filter { it !in worktreeBranches }
known = branchInfo.branches.toMutableSet().apply { addAll(rows.map { it.branch }) }
onReload?.invoke()
telemetry("Worktree List Loaded", mapOf("count" to extra.size.toString()))
}
}
@@ -109,13 +124,19 @@ class WorktreeController(
fun quickCreate() = create(suggestName(), defaultBranch)
/** Imports a worktree that checks out an existing local branch. */
fun importBranch(branch: String) = create(branch, base = null, existingBranch = true)
fun importBranch(branch: String) = create(branch, base = null, existingBranch = true, kind = CreateKind.BRANCH)
/**
* Creates a worktree. When [prompt] is set, it is stashed for the worktree's first session so the
* editor auto-sends it once it opens with its picked mode/model (see [PendingWorktreePrompt]).
*/
fun create(branch: String, base: String?, existingBranch: Boolean = false, prompt: PendingPrompt? = null) {
fun create(
branch: String,
base: String?,
existingBranch: Boolean = false,
prompt: PendingPrompt? = null,
kind: CreateKind = CreateKind.CREATE,
) {
val id = "pending:$branch:${System.nanoTime()}"
val temp = WorktreeDto(id, branch, branch, id)
edt {
@@ -126,7 +147,7 @@ class WorktreeController(
}
cs.launch {
val result = service.create(directory, CreateWorktreeRequestDto(branch, base, existingBranch))
finishCreate(temp, branch, prompt, result)
finishCreate(temp, branch, prompt, result, kind)
}
}
@@ -141,7 +162,7 @@ class WorktreeController(
}
cs.launch {
val result = service.importPr(directory, url)
finishCreate(temp, "pr", null, result)
finishCreate(temp, "pr", null, result, CreateKind.PR)
}
}
@@ -150,6 +171,7 @@ class WorktreeController(
branch: String,
prompt: PendingPrompt?,
result: CreateWorktreeResultDto,
kind: CreateKind,
) {
val created = result.worktree
edt {
@@ -161,12 +183,13 @@ class WorktreeController(
cache().put(created)
prompt?.let { service<PendingWorktreePrompt>().put(created.path, it) }
onSelect?.invoke(created.id)
onCreated?.invoke(created)
telemetry("Worktree Created", mapOf("branch" to branch))
return@edt
}
if (idx >= 0) model.remove(temp)
telemetry("Worktree Create Failed", mapOf("branch" to branch))
onCreateFailure?.invoke(result.error)
onCreateFailure?.invoke(CreateFailure(result.error, kind, branch))
}
}
@@ -249,6 +272,7 @@ class WorktreeController(
// open; the tab's identity stays the worktree path alone.
event.session?.let { service<PendingWorktreeSession>().put(worktree.path, it) }
onSelect?.invoke(worktree.id)
onCreated?.invoke(worktree)
telemetry(
"Continue in Worktree",
mapOf("surface" to "sidebar", "session" to (sessionId != null).toString()),
@@ -416,13 +416,25 @@ worktree.configure.base.invalid.content=Select an existing base branch before cr
worktree.dialog.name.placeholder=Worktree name (optional)
worktree.dialog.prompt.placeholder=Describe what you want to start working on ({0} to create)
worktree.dialog.create=Create Worktree
worktree.dialog.import=Import Worktree
worktree.dialog.tab.new=New
worktree.dialog.tab.pr=From PR
worktree.dialog.tab.branch=From Branch
worktree.progress.creating=Creating worktree…
worktree.progress.capturing=Capturing changes…
worktree.progress.transferring=Transferring changes…
worktree.progress.starting=Starting session…
worktree.move.failed.title=Failed to move to worktree
worktree.import.pr.section=Pull Request
worktree.import.pr.description=Paste a GitHub pull request URL. Kilo will fetch the PR head and open it in a worktree.
worktree.import.pr.placeholder=https://github.com/owner/repo/pull/123
worktree.import.pr.required=Enter a pull request URL.
worktree.import.pr.invalid=Enter a valid GitHub pull request URL.
worktree.import.pr.failed.title=Couldn''t import pull request
worktree.import.branch.section=Branch
worktree.import.branch.description=Choose an existing local branch that is not already checked out in another worktree.
worktree.import.branch.invalid=Select an existing branch before importing.
worktree.import.branch.empty=No local branches are available to import.
worktree.import.branch.failed.title=Couldn''t import branch "{0}"
worktree.stats.diff.tooltip={0} additions, {1} deletions
worktree.stats.ahead.tooltip=Commits ahead of base branch
@@ -134,7 +134,7 @@ class AgentManagerPanelTest : BasePlatformTestCase() {
fun `test configure creates the worktree only after the dialog closes`() {
val order = mutableListOf<String>()
val plan = NewWorktreePlan("feature/y", "main", PendingPrompt("build it"))
val plan = NewWorktreePlan.Create("feature/y", "main", PendingPrompt("build it"))
val controller = WorktreeController(service, "/test", coroutines.scope)
val panel = edt {
AgentManagerPanel(testRootDisposable, controller, project, dialog = { _, _ -> FakeWorktreeDialog(plan, order) })
@@ -166,6 +166,36 @@ class AgentManagerPanelTest : BasePlatformTestCase() {
assertTrue(rpc.creates.isEmpty())
}
fun `test configure imports an existing branch`() {
val order = mutableListOf<String>()
val plan = NewWorktreePlan.Branch("feature/x")
val controller = WorktreeController(service, "/test", coroutines.scope)
val panel = edt {
AgentManagerPanel(testRootDisposable, controller, project, dialog = { _, _ -> FakeWorktreeDialog(plan, order) })
}
edt { panel.configure() }
flush()
val req = rpc.creates.single()
assertEquals("feature/x", req.branch)
assertTrue("branch import checks out an existing branch", req.existingBranch)
}
fun `test configure imports a pull request`() {
val order = mutableListOf<String>()
val plan = NewWorktreePlan.Pr("https://github.com/o/r/pull/7")
val controller = WorktreeController(service, "/test", coroutines.scope)
val panel = edt {
AgentManagerPanel(testRootDisposable, controller, project, dialog = { _, _ -> FakeWorktreeDialog(plan, order) })
}
edt { panel.configure() }
flush()
assertEquals(listOf("https://github.com/o/r/pull/7"), rpc.prImports.toList())
}
fun `test panel hides worktree search field`() {
val controller = WorktreeController(service, "/test", coroutines.scope)
val panel = edt { AgentManagerPanel(testRootDisposable, controller) }
@@ -649,6 +679,43 @@ class AgentManagerPanelTest : BasePlatformTestCase() {
assertTrue(edt { panel.canShowRename(item) })
}
fun `test current row renders without any linked worktrees`() {
rpc.listed += main()
val controller = WorktreeController(service, project.basePath!!, coroutines.scope)
val panel = edt { AgentManagerPanel(testRootDisposable, controller, project) }
edt { controller.reload() }
flush()
// Replacing an empty model with an empty list notifies nobody, so the row has to come from
// the reload itself.
assertEquals(1, rows(panel))
assertEquals("main", row(panel, 0).title)
}
fun `test current row shows the pr badge for the main checkout`() {
val main = main()
rpc.listed += main
rpc.prResult = WorktreePrListDto(
GhAvailability.OK,
listOf(WorktreePrDto(main.path, 12, GhState.OPEN, "https://example.test/pr/12", "Main work")),
)
val timers = TestUiTimers()
ApplicationManager.getApplication().replaceService(KiloWorktreeService::class.java, service, testRootDisposable)
project.replaceService(WorktreeStatusService::class.java, WorktreeStatusService(project, coroutines.scope, timers), testRootDisposable)
val controller = WorktreeController(service, project.basePath!!, coroutines.scope)
val panel = edt { AgentManagerPanel(testRootDisposable, controller, project) }
edt { controller.reload() }
timers.advanceBy(300)
waitUntil { rows(panel) > 0 && row(panel, 0).metrics != null }
// The current row keeps the branch as its title; the PR arrives as a badge beside it.
val current = row(panel, 0)
assertEquals("main", current.title)
assertEquals("#12", current.metrics?.pr?.text)
assertTrue(edt { panel.canOpenPr(main) })
}
fun `test pr title replaces row name and tooltip reveals custom name`() {
val path = "${project.basePath!!}/.kilo/worktrees/feature-x"
val item = WorktreeDto(path, "Feature Label", "feature/x", path)
@@ -875,6 +942,11 @@ class AgentManagerPanelTest : BasePlatformTestCase() {
return edt { list.model.getElementAt(idx) as ActiveListItem }
}
private fun rows(panel: AgentManagerPanel): Int {
val list = edt { UIUtil.findComponentOfType(panel, JBList::class.java)!! }
return edt { list.model.size }
}
private fun components(root: Component): List<Component> {
val out = mutableListOf<Component>()
fun visit(item: Component) {
@@ -1,6 +1,8 @@
package ai.kilocode.client.agentManager
import ai.kilocode.client.agentManager.worktree.WorktreeIcons
import ai.kilocode.client.agentManager.worktree.CreateFailure
import ai.kilocode.client.agentManager.worktree.CreateKind
import ai.kilocode.client.agentManager.worktree.KiloWorktreeService
import ai.kilocode.client.agentManager.worktree.WorktreeController
import ai.kilocode.client.agentManager.worktree.PendingPrompt
@@ -121,10 +123,35 @@ class WorktreeControllerTest : BasePlatformTestCase() {
assertFalse(controller.isPending(controller.model.getElementAt(0).id))
}
fun `test created worktrees are announced once they exist`() {
val controller = controller()
val created = mutableListOf<WorktreeDto>()
controller.onCreated = { created.add(it) }
ApplicationManager.getApplication().invokeAndWait { controller.create("feature/y", null) }
// Nothing exists on disk while the create is still pending.
assertEquals(emptyList<WorktreeDto>(), created)
flush()
assertEquals(listOf("feature/y"), created.map { it.branch })
}
fun `test a failed create announces nothing`() {
rpc.createResult = { CreateWorktreeResultDto(error = "boom") }
val controller = controller()
val created = mutableListOf<WorktreeDto>()
controller.onCreated = { created.add(it) }
ApplicationManager.getApplication().invokeAndWait { controller.create("feature/y", null) }
flush()
assertEquals(emptyList<WorktreeDto>(), created)
}
fun `test create failure removes placeholder and reports the error`() {
rpc.createResult = { CreateWorktreeResultDto(error = "boom") }
val controller = controller()
val failures = mutableListOf<String?>()
val failures = mutableListOf<CreateFailure>()
controller.onCreateFailure = { failures.add(it) }
ApplicationManager.getApplication().invokeAndWait { controller.create("feature/y", null) }
@@ -134,7 +161,7 @@ class WorktreeControllerTest : BasePlatformTestCase() {
assertEquals(0, controller.model.size)
assertFalse(controller.isPending(id))
assertEquals(listOf("boom"), failures)
assertEquals(listOf(CreateFailure("boom", CreateKind.CREATE, "feature/y")), failures)
}
fun `test reload preserves pending worktrees`() {
@@ -21,6 +21,9 @@ import com.intellij.openapi.ui.ComboBox
import com.intellij.openapi.util.Disposer
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.ui.components.JBPanel
import com.intellij.ui.components.JBTextField
import com.intellij.ui.tabs.JBTabs
import com.intellij.ui.tabs.TabInfo
import com.intellij.util.ui.UIUtil
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
@@ -31,6 +34,7 @@ import java.awt.Component
import java.awt.Container
import java.awt.event.FocusEvent
import javax.swing.JTextField
import javax.swing.plaf.basic.BasicComboBoxUI
import javax.swing.plaf.basic.BasicComboPopup
class NewWorktreeDialogTest : BasePlatformTestCase() {
@@ -144,6 +148,21 @@ class NewWorktreeDialogTest : BasePlatformTestCase() {
}
}
fun `test base picker survives a dropped editor during layout`() {
open()
edt {
val picker = combo() as BranchPicker
val ui = picker.ui as BasicComboBoxUI
ui.removeEditor()
picker.preferredSize
val comp = picker.editor.editorComponent
assertTrue(picker.components.any { it === comp })
}
}
fun `test creating with empty base branch falls back to default`() {
open()
flushUntil { edt { model().selectionKeyForTest() != null } }
@@ -184,6 +203,114 @@ class NewWorktreeDialogTest : BasePlatformTestCase() {
assertNull(plan())
}
fun `test importing a pr url produces a pr plan`() {
open()
selectPr()
edt {
url().text = "https://github.com/o/r/pull/7"
submit()
}
assertEquals(NewWorktreePlan.Pr("https://github.com/o/r/pull/7"), taken())
}
fun `test blank pr url does not import`() {
open()
selectPr()
edt { submit() }
assertNull(plan())
}
fun `test non-pr url does not import`() {
open()
selectPr()
edt {
url().text = "https://github.com/o/r/issues/7"
submit()
}
assertNull(plan())
}
fun `test picking a branch produces a branch plan`() {
open(branches = listOf("main", "feature/x"))
selectBranch()
edt {
pickField().text = "feature/x"
submit()
}
assertEquals(NewWorktreePlan.Branch("feature/x"), taken())
}
fun `test importing a fuzzy branch resolves to the real branch`() {
open(branches = listOf("main", "feature/refactor-ui"))
selectBranch()
edt {
pickField().text = "refui"
submit()
}
assertEquals(NewWorktreePlan.Branch("feature/refactor-ui"), taken())
}
fun `test importing an unknown branch does not import`() {
open(branches = listOf("main", "feature/x"))
selectBranch()
edt {
pickField().text = "zzzzzz"
submit()
}
assertNull(plan())
}
fun `test the new tab creates while the pr tab imports`() {
open()
edt { assertEquals(3, tabs().tabs.size) }
selectPr()
edt {
url().text = "https://github.com/o/r/pull/7"
submit()
}
assertEquals(NewWorktreePlan.Pr("https://github.com/o/r/pull/7"), taken())
}
fun `test the deselected tab stops painting`() {
open()
val fresh = newTab()
selectPr()
assertFalse(edt { fresh.isVisible })
assertTrue(edt { prTab().isVisible })
}
fun `test reselecting a tab shows it again`() {
open()
selectPr()
select(0)
assertTrue(edt { newTab().isVisible })
assertFalse(edt { prTab().isVisible })
}
fun `test an empty branch list disables the branch picker`() {
open(branches = emptyList())
selectBranch()
edt {
assertFalse(pick().isEnabled)
submit()
}
assertNull(plan())
}
private fun open(branches: List<String> = listOf("main")) {
dialog = edt {
NewWorktreeDialog(
@@ -201,10 +328,13 @@ class NewWorktreeDialogTest : BasePlatformTestCase() {
private fun plan(): NewWorktreePlan? = edt { requireNotNull(dialog).result() }
/** Reads the plan after a confirming submit, then forgets the dialog: closing already disposed it. */
private fun taken(): NewWorktreePlan = requireNotNull(plan()).also { dialog = null }
/** Waits for the dialog to accept a create, then forgets it: closing already disposed it. */
private fun submitted(): NewWorktreePlan {
private fun submitted(): NewWorktreePlan.Create {
flushUntil { plan() != null }
return requireNotNull(plan()).also { dialog = null }
return (requireNotNull(plan()) as NewWorktreePlan.Create).also { dialog = null }
}
private fun workspace(): ModelsWorkspaceDto {
@@ -231,15 +361,39 @@ class NewWorktreeDialogTest : BasePlatformTestCase() {
private fun reasoning(): ReasoningPicker = prompt().reasoning
private fun prompt(): PromptPanel = descendants(root()).filterIsInstance<PromptPanel>().single()
private fun prompt(): PromptPanel = descendants(newTab()).filterIsInstance<PromptPanel>().single()
private fun combo(): ComboBox<*> = descendants(root()).filterIsInstance<ComboBox<*>>().single()
private fun combo(): ComboBox<*> = descendants(newTab()).filterIsInstance<ComboBox<*>>().single()
private fun field(): JTextField = combo().editor.editorComponent as JTextField
private fun popup(): BasicComboPopup = combo().accessibleContext.getAccessibleChild(0) as BasicComboPopup
private fun root(): Component = requireNotNull(dialog).centerComponent()
private fun tabs(): JBTabs = requireNotNull(dialog).centerComponent() as JBTabs
private fun newTab(): Component = tabs().tabs[0].component
private fun prTab(): Component = tabs().tabs[1].component
private fun branchTab(): Component = tabs().tabs[2].component
private fun selectPr() = select(1)
private fun selectBranch() = select(2)
private fun select(index: Int) = edt {
val info: TabInfo = tabs().tabs[index]
tabs().select(info, false)
UIUtil.dispatchAllInvocationEvents()
}
private fun url(): JBTextField = descendants(prTab()).filterIsInstance<JBTextField>().single()
private fun pick(): ComboBox<*> = descendants(branchTab()).filterIsInstance<ComboBox<*>>().single()
private fun pickField(): JTextField = pick().editor.editorComponent as JTextField
private fun submit() = requireNotNull(dialog).submit()
private fun descendants(root: Component): List<Component> {
val out = mutableListOf<Component>()
@@ -63,7 +63,9 @@ interface KiloWorktreeRpcApi : RemoteApi<Unit> {
/**
* Imports a worktree from a GitHub pull request [url]. Resolves the PR's head branch via `gh`,
* fetches it (adding a fork remote for cross-repo PRs), then checks it out into a new worktree.
* fetches it, records the branch's remote and merge ref so the PR stays identifiable, then
* checks it out into a new worktree. Fork PRs are fetched through `refs/pull/<number>/head` and
* get an owner-prefixed local branch; no fork remote is added.
*/
suspend fun importPr(directory: String, url: String): CreateWorktreeResultDto
@@ -0,0 +1,12 @@
package ai.kilocode.rpc
data class PrRef(val owner: String, val repo: String, val number: Int)
private val PR_URL = Regex("github\\.com[/:]([^/]+)/([^/]+?)(?:\\.git)?/pull/(\\d+)")
/** Parses `https://github.com/<owner>/<repo>/pull/<n>` (and ssh-style hosts) into its parts. */
fun parsePrUrl(url: String): PrRef? {
val match = PR_URL.find(url.trim()) ?: return null
val number = match.groupValues[3].toIntOrNull() ?: return null
return PrRef(match.groupValues[1], match.groupValues[2], number)
}