mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-30 17:14:40 +08:00
refactor(agent-manager): extract worktree importer (#8725)
* refactor(agent-manager): extract worktree importer * test(agent-manager): keep provider line cap
This commit is contained in:
@@ -12,7 +12,7 @@ import { GitStatsPoller, type WorktreePresenceResult } from "./GitStatsPoller"
|
||||
import { PRStatusBridge } from "./pr-status-bridge"
|
||||
import { GitOps } from "./GitOps"
|
||||
import { versionedName } from "./branch-name"
|
||||
import { normalizePath, classifyWorktreeError } from "./git-import"
|
||||
import { classifyWorktreeError } from "./git-import"
|
||||
import { SetupScriptService } from "./SetupScriptService"
|
||||
import { SetupScriptRunner } from "./SetupScriptRunner"
|
||||
import { copyEnvFiles } from "./env-copy"
|
||||
@@ -22,6 +22,7 @@ import { executeVscodeTask } from "./task-runner"
|
||||
import { forkSession } from "./fork-session"
|
||||
import { continueInWorktree } from "./continue-in-worktree"
|
||||
import { WorktreeDiffController } from "./worktree-diff-controller"
|
||||
import { WorktreeImporter } from "./worktree-importer"
|
||||
|
||||
import { buildKeybindingMap } from "./format-keybinding"
|
||||
import { resolveVersionModels, buildInitialMessages, type CreatedVersion } from "./multi-version"
|
||||
@@ -46,9 +47,9 @@ export class AgentManagerProvider implements Disposable {
|
||||
private worktrees: WorktreeManager | undefined
|
||||
private state: WorktreeStateManager | undefined
|
||||
private setupScript: SetupScriptService | undefined
|
||||
private importer: WorktreeImporter
|
||||
private terminalManager: SessionTerminalManager
|
||||
private stateReady: Promise<void> | undefined
|
||||
private importing = false
|
||||
private statsPoller: GitStatsPoller
|
||||
private prBridge!: PRStatusBridge
|
||||
private gitOps: GitOps
|
||||
@@ -70,6 +71,17 @@ export class AgentManagerProvider implements Disposable {
|
||||
(msg) => this.outputChannel.appendLine(`[SessionTerminal] ${msg}`),
|
||||
createTerminalHost(),
|
||||
)
|
||||
this.importer = new WorktreeImporter({
|
||||
manager: () => this.getWorktreeManager(),
|
||||
state: () => this.getStateManager(),
|
||||
post: (msg) => this.postToWebview(msg),
|
||||
push: () => this.pushState(),
|
||||
setup: (dir, branch, id) => this.runSetupScriptForWorktree(dir, branch, id),
|
||||
session: (dir, branch, id) => this.createSessionInWorktree(dir, branch, id),
|
||||
register: (sid, dir) => this.registerWorktreeSession(sid, dir),
|
||||
ready: (sid, result, id) => this.notifyWorktreeReady(sid, result, id),
|
||||
log: (...args) => this.log(...args),
|
||||
})
|
||||
const semaphore = new Semaphore(3)
|
||||
this.gitOps = new GitOps({ log: (...args) => this.log(...args), semaphore })
|
||||
this.diffs = new WorktreeDiffController({
|
||||
@@ -285,10 +297,12 @@ export class AgentManagerProvider implements Disposable {
|
||||
const persist = m.type === "agentManager.persistSession"
|
||||
void this.stateReady?.then(() => {
|
||||
const state = this.getStateManager()
|
||||
if (state)
|
||||
persist
|
||||
? !state.getSession(m.sessionId) && state.addSession(m.sessionId, null)
|
||||
: state.removeSession(m.sessionId)
|
||||
if (!state) return
|
||||
if (persist) {
|
||||
if (!state.getSession(m.sessionId)) state.addSession(m.sessionId, null)
|
||||
return
|
||||
}
|
||||
state.removeSession(m.sessionId)
|
||||
})
|
||||
return null
|
||||
}
|
||||
@@ -384,11 +398,6 @@ export class AgentManagerProvider implements Disposable {
|
||||
this.onRequestState()
|
||||
return null
|
||||
}
|
||||
|
||||
if (m.type === "agentManager.requestBranches") {
|
||||
void this.onRequestBranches()
|
||||
return null
|
||||
}
|
||||
if (m.type === "agentManager.setTabOrder") {
|
||||
this.state?.setTabOrder(m.key, m.order)
|
||||
return null
|
||||
@@ -414,24 +423,28 @@ export class AgentManagerProvider implements Disposable {
|
||||
}
|
||||
|
||||
private onImportMessage(m: AgentManagerInMessage): Record<string, unknown> | null | undefined {
|
||||
if (m.type === "agentManager.requestBranches") {
|
||||
void this.importer.branches()
|
||||
return null
|
||||
}
|
||||
if (m.type === "agentManager.requestExternalWorktrees") {
|
||||
void this.onRequestExternalWorktrees()
|
||||
void this.importer.external()
|
||||
return null
|
||||
}
|
||||
if (m.type === "agentManager.importFromBranch") {
|
||||
void this.onImportFromBranch(m.branch)
|
||||
void this.importer.branch(m.branch)
|
||||
return null
|
||||
}
|
||||
if (m.type === "agentManager.importFromPR") {
|
||||
void this.onImportFromPR(m.url)
|
||||
void this.importer.pr(m.url)
|
||||
return null
|
||||
}
|
||||
if (m.type === "agentManager.importExternalWorktree") {
|
||||
void this.onImportExternalWorktree(m.path, m.branch)
|
||||
void this.importer.path(m.path, m.branch)
|
||||
return null
|
||||
}
|
||||
if (m.type === "agentManager.importAllExternalWorktrees") {
|
||||
void this.onImportAllExternalWorktrees()
|
||||
void this.importer.all()
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -1064,344 +1077,6 @@ export class AgentManagerProvider implements Disposable {
|
||||
return null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private async onRequestBranches(): Promise<void> {
|
||||
const manager = this.getWorktreeManager()
|
||||
if (!manager) {
|
||||
this.postToWebview({ type: "agentManager.branches", branches: [], defaultBranch: "main" })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = await manager.listBranches()
|
||||
const checkedOut = await manager.checkedOutBranches()
|
||||
|
||||
// Include isCheckedOut flag on each branch — let the webview decide how to filter
|
||||
const branches = result.branches.map((b) => ({
|
||||
...b,
|
||||
isCheckedOut: checkedOut.has(b.name),
|
||||
}))
|
||||
|
||||
// Validate configured default branch still exists
|
||||
const state = this.getStateManager()
|
||||
const configured = state?.getDefaultBaseBranch()
|
||||
if (configured && !branches.some((b) => b.name === configured)) {
|
||||
this.clearStaleDefaultBaseBranch(state!, configured)
|
||||
}
|
||||
|
||||
this.postToWebview({
|
||||
type: "agentManager.branches",
|
||||
branches,
|
||||
defaultBranch: result.defaultBranch,
|
||||
})
|
||||
} catch (error) {
|
||||
this.log(`Failed to list branches: ${error}`)
|
||||
this.postToWebview({ type: "agentManager.branches", branches: [], defaultBranch: "main" })
|
||||
}
|
||||
}
|
||||
|
||||
private async onRequestExternalWorktrees(): Promise<void> {
|
||||
const manager = this.getWorktreeManager()
|
||||
const state = this.getStateManager()
|
||||
if (!manager || !state) {
|
||||
this.postToWebview({ type: "agentManager.externalWorktrees", worktrees: [] })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const managedPaths = new Set(state.getWorktrees().map((wt) => wt.path))
|
||||
const worktrees = await manager.listExternalWorktrees(managedPaths)
|
||||
this.postToWebview({ type: "agentManager.externalWorktrees", worktrees })
|
||||
} catch (error) {
|
||||
this.log(`Failed to list external worktrees: ${error}`)
|
||||
this.postToWebview({ type: "agentManager.externalWorktrees", worktrees: [] })
|
||||
}
|
||||
}
|
||||
|
||||
private async onImportFromBranch(branch: string): Promise<void> {
|
||||
const manager = this.getWorktreeManager()
|
||||
const state = this.getStateManager()
|
||||
if (!manager || !state) {
|
||||
this.postToWebview({ type: "agentManager.importResult", success: false, message: "Not a git repository" })
|
||||
return
|
||||
}
|
||||
if (this.importing) {
|
||||
this.postToWebview({
|
||||
type: "agentManager.importResult",
|
||||
success: false,
|
||||
message: "Another import is already in progress",
|
||||
})
|
||||
return
|
||||
}
|
||||
this.importing = true
|
||||
|
||||
try {
|
||||
this.postToWebview({
|
||||
type: "agentManager.worktreeSetup",
|
||||
status: "creating",
|
||||
message: "Creating worktree from branch...",
|
||||
})
|
||||
const result = await manager.createWorktree({ existingBranch: branch })
|
||||
const worktree = state.addWorktree({
|
||||
branch: result.branch,
|
||||
path: result.path,
|
||||
parentBranch: result.parentBranch,
|
||||
remote: result.remote,
|
||||
})
|
||||
this.pushState()
|
||||
|
||||
try {
|
||||
this.postToWebview({
|
||||
type: "agentManager.worktreeSetup",
|
||||
status: "creating",
|
||||
message: "Running setup script...",
|
||||
branch: result.branch,
|
||||
worktreeId: worktree.id,
|
||||
})
|
||||
await this.runSetupScriptForWorktree(result.path, result.branch, worktree.id)
|
||||
|
||||
const session = await this.createSessionInWorktree(result.path, result.branch, worktree.id)
|
||||
if (!session) throw new Error("Failed to create session")
|
||||
|
||||
state.addSession(session.id, worktree.id)
|
||||
this.registerWorktreeSession(session.id, result.path)
|
||||
this.notifyWorktreeReady(session.id, result, worktree.id)
|
||||
this.postToWebview({ type: "agentManager.importResult", success: true, message: `Opened branch ${branch}` })
|
||||
this.log(`Imported branch ${branch} as worktree ${worktree.id}`)
|
||||
} catch (inner) {
|
||||
state.removeWorktree(worktree.id)
|
||||
await manager.removeWorktree(result.path)
|
||||
this.pushState()
|
||||
throw inner
|
||||
}
|
||||
} catch (error) {
|
||||
const raw = error instanceof Error ? error.message : String(error)
|
||||
const msg =
|
||||
raw.includes("already used by worktree") || raw.includes("already checked out")
|
||||
? `Branch "${branch}" is already checked out in another worktree`
|
||||
: raw
|
||||
const code = classifyWorktreeError(msg)
|
||||
this.postToWebview({ type: "agentManager.worktreeSetup", status: "error", message: msg, errorCode: code })
|
||||
this.postToWebview({ type: "agentManager.importResult", success: false, message: msg, errorCode: code })
|
||||
} finally {
|
||||
this.importing = false
|
||||
}
|
||||
}
|
||||
|
||||
private async onImportFromPR(url: string): Promise<void> {
|
||||
const manager = this.getWorktreeManager()
|
||||
const state = this.getStateManager()
|
||||
if (!manager || !state) {
|
||||
this.postToWebview({ type: "agentManager.importResult", success: false, message: "Not a git repository" })
|
||||
return
|
||||
}
|
||||
|
||||
if (this.importing) {
|
||||
this.postToWebview({
|
||||
type: "agentManager.importResult",
|
||||
success: false,
|
||||
message: "Another import is already in progress",
|
||||
})
|
||||
return
|
||||
}
|
||||
this.importing = true
|
||||
|
||||
try {
|
||||
this.postToWebview({ type: "agentManager.worktreeSetup", status: "creating", message: "Resolving PR..." })
|
||||
const result = await manager.createFromPR(url)
|
||||
const worktree = state.addWorktree({
|
||||
branch: result.branch,
|
||||
path: result.path,
|
||||
parentBranch: result.parentBranch,
|
||||
remote: result.remote,
|
||||
})
|
||||
this.pushState()
|
||||
|
||||
try {
|
||||
this.postToWebview({
|
||||
type: "agentManager.worktreeSetup",
|
||||
status: "creating",
|
||||
message: "Setting up worktree...",
|
||||
branch: result.branch,
|
||||
worktreeId: worktree.id,
|
||||
})
|
||||
await this.runSetupScriptForWorktree(result.path, result.branch, worktree.id)
|
||||
|
||||
const session = await this.createSessionInWorktree(result.path, result.branch, worktree.id)
|
||||
if (!session) throw new Error("Failed to create session")
|
||||
|
||||
state.addSession(session.id, worktree.id)
|
||||
this.registerWorktreeSession(session.id, result.path)
|
||||
this.notifyWorktreeReady(session.id, result, worktree.id)
|
||||
this.postToWebview({
|
||||
type: "agentManager.importResult",
|
||||
success: true,
|
||||
message: `Opened PR branch ${result.branch}`,
|
||||
})
|
||||
this.log(`Imported PR ${url} as worktree ${worktree.id}`)
|
||||
} catch (inner) {
|
||||
state.removeWorktree(worktree.id)
|
||||
await manager.removeWorktree(result.path)
|
||||
this.pushState()
|
||||
throw inner
|
||||
}
|
||||
} catch (error) {
|
||||
const raw = error instanceof Error ? error.message : String(error)
|
||||
const msg =
|
||||
raw.includes("already used by worktree") || raw.includes("already checked out")
|
||||
? "This PR's branch is already checked out in another worktree"
|
||||
: raw
|
||||
const code = classifyWorktreeError(msg)
|
||||
this.postToWebview({ type: "agentManager.worktreeSetup", status: "error", message: msg, errorCode: code })
|
||||
this.postToWebview({ type: "agentManager.importResult", success: false, message: msg, errorCode: code })
|
||||
} finally {
|
||||
this.importing = false
|
||||
}
|
||||
}
|
||||
|
||||
private async onImportExternalWorktree(wtPath: string, branch: string): Promise<void> {
|
||||
const state = this.getStateManager()
|
||||
const manager = this.getWorktreeManager()
|
||||
if (!state || !manager) {
|
||||
this.postToWebview({ type: "agentManager.importResult", success: false, message: "State not initialized" })
|
||||
return
|
||||
}
|
||||
|
||||
if (this.importing) {
|
||||
this.postToWebview({
|
||||
type: "agentManager.importResult",
|
||||
success: false,
|
||||
message: "Another import is already in progress",
|
||||
})
|
||||
return
|
||||
}
|
||||
this.importing = true
|
||||
|
||||
let worktree: ReturnType<typeof state.addWorktree> | undefined
|
||||
try {
|
||||
const externals = await manager.listExternalWorktrees(new Set(state.getWorktrees().map((wt) => wt.path)))
|
||||
if (!externals.some((e) => normalizePath(e.path) === normalizePath(wtPath))) {
|
||||
this.postToWebview({
|
||||
type: "agentManager.importResult",
|
||||
success: false,
|
||||
message: "Path is not a valid worktree for this repository",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const base = await manager.resolveBaseBranch()
|
||||
worktree = state.addWorktree({ branch, path: wtPath, parentBranch: base.branch, remote: base.remote })
|
||||
this.pushState()
|
||||
|
||||
const session = await this.createSessionInWorktree(wtPath, branch, worktree.id)
|
||||
if (!session) {
|
||||
state.removeWorktree(worktree.id)
|
||||
this.pushState()
|
||||
this.postToWebview({ type: "agentManager.importResult", success: false, message: "Failed to create session" })
|
||||
return
|
||||
}
|
||||
|
||||
state.addSession(session.id, worktree.id)
|
||||
this.registerWorktreeSession(session.id, wtPath)
|
||||
this.pushState()
|
||||
this.postToWebview({
|
||||
type: "agentManager.worktreeSetup",
|
||||
status: "ready",
|
||||
message: "Worktree imported",
|
||||
sessionId: session.id,
|
||||
branch,
|
||||
worktreeId: worktree.id,
|
||||
})
|
||||
this.postToWebview({
|
||||
type: "agentManager.sessionMeta",
|
||||
sessionId: session.id,
|
||||
mode: "worktree",
|
||||
branch,
|
||||
path: wtPath,
|
||||
parentBranch: base.branch,
|
||||
})
|
||||
this.postToWebview({ type: "agentManager.importResult", success: true, message: `Imported ${branch}` })
|
||||
this.log(`Imported external worktree ${wtPath} (${branch})`)
|
||||
} catch (error) {
|
||||
if (worktree) {
|
||||
state.removeWorktree(worktree.id)
|
||||
this.pushState()
|
||||
}
|
||||
const msg = error instanceof Error ? error.message : String(error)
|
||||
this.postToWebview({ type: "agentManager.importResult", success: false, message: msg })
|
||||
} finally {
|
||||
this.importing = false
|
||||
}
|
||||
}
|
||||
|
||||
private async onImportAllExternalWorktrees(): Promise<void> {
|
||||
if (this.importing) {
|
||||
this.postToWebview({
|
||||
type: "agentManager.importResult",
|
||||
success: false,
|
||||
message: "Another import is already in progress",
|
||||
})
|
||||
return
|
||||
}
|
||||
const manager = this.getWorktreeManager()
|
||||
const state = this.getStateManager()
|
||||
if (!manager || !state) {
|
||||
this.postToWebview({ type: "agentManager.importResult", success: false, message: "Not a git repository" })
|
||||
return
|
||||
}
|
||||
this.importing = true
|
||||
|
||||
try {
|
||||
const managedPaths = new Set(state.getWorktrees().map((wt) => wt.path))
|
||||
const externals = await manager.listExternalWorktrees(managedPaths)
|
||||
if (externals.length === 0) {
|
||||
this.postToWebview({
|
||||
type: "agentManager.importResult",
|
||||
success: true,
|
||||
message: "No external worktrees to import",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
let imported = 0
|
||||
const base = await manager.resolveBaseBranch()
|
||||
for (const ext of externals) {
|
||||
try {
|
||||
const worktree = state.addWorktree({
|
||||
branch: ext.branch,
|
||||
path: ext.path,
|
||||
parentBranch: base.branch,
|
||||
remote: base.remote,
|
||||
})
|
||||
const session = await this.createSessionInWorktree(ext.path, ext.branch, worktree.id)
|
||||
if (session) {
|
||||
state.addSession(session.id, worktree.id)
|
||||
this.registerWorktreeSession(session.id, ext.path)
|
||||
imported++
|
||||
} else {
|
||||
state.removeWorktree(worktree.id)
|
||||
}
|
||||
} catch (error) {
|
||||
this.log(`Failed to import external worktree ${ext.path}: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
this.pushState()
|
||||
this.postToWebview({
|
||||
type: "agentManager.importResult",
|
||||
success: true,
|
||||
message: `Imported ${imported} worktree${imported !== 1 ? "s" : ""}`,
|
||||
})
|
||||
this.log(`Imported ${imported}/${externals.length} external worktrees`)
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error)
|
||||
this.postToWebview({ type: "agentManager.importResult", success: false, message: msg })
|
||||
} finally {
|
||||
this.importing = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Keybindings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
import type { Session } from "@kilocode/sdk/v2/client"
|
||||
import type { AgentManagerOutMessage } from "./types"
|
||||
import type { WorktreeManager, CreateWorktreeResult } from "./WorktreeManager"
|
||||
import type { WorktreeStateManager } from "./WorktreeStateManager"
|
||||
import { classifyWorktreeError, normalizePath } from "./git-import"
|
||||
|
||||
type Worktree = ReturnType<WorktreeStateManager["addWorktree"]>
|
||||
|
||||
export interface WorktreeImporterHost {
|
||||
manager(): WorktreeManager | undefined
|
||||
state(): WorktreeStateManager | undefined
|
||||
post(msg: AgentManagerOutMessage): void
|
||||
push(): void
|
||||
setup(path: string, branch?: string, worktreeId?: string): Promise<void>
|
||||
session(path: string, branch: string, worktreeId?: string): Promise<Session | null>
|
||||
register(sessionId: string, directory: string): void
|
||||
ready(sessionId: string, result: CreateWorktreeResult, worktreeId?: string): void
|
||||
log(...args: unknown[]): void
|
||||
}
|
||||
|
||||
export class WorktreeImporter {
|
||||
private importing = false
|
||||
|
||||
constructor(private readonly host: WorktreeImporterHost) {}
|
||||
|
||||
async branches(): Promise<void> {
|
||||
const manager = this.host.manager()
|
||||
if (!manager) {
|
||||
this.host.post({ type: "agentManager.branches", branches: [], defaultBranch: "main" })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await manager.listBranches()
|
||||
const checked = await manager.checkedOutBranches()
|
||||
const branches = result.branches.map((branch) => ({
|
||||
...branch,
|
||||
isCheckedOut: checked.has(branch.name),
|
||||
}))
|
||||
|
||||
const state = this.host.state()
|
||||
const configured = state?.getDefaultBaseBranch()
|
||||
if (state && configured && !branches.some((branch) => branch.name === configured)) {
|
||||
this.host.log(`Default base branch "${configured}" no longer exists, clearing`)
|
||||
state.setDefaultBaseBranch(undefined)
|
||||
this.host.push()
|
||||
}
|
||||
|
||||
this.host.post({
|
||||
type: "agentManager.branches",
|
||||
branches,
|
||||
defaultBranch: result.defaultBranch,
|
||||
})
|
||||
} catch (error) {
|
||||
this.host.log(`Failed to list branches: ${error}`)
|
||||
this.host.post({ type: "agentManager.branches", branches: [], defaultBranch: "main" })
|
||||
}
|
||||
}
|
||||
|
||||
async external(): Promise<void> {
|
||||
const manager = this.host.manager()
|
||||
const state = this.host.state()
|
||||
if (!manager || !state) {
|
||||
this.host.post({ type: "agentManager.externalWorktrees", worktrees: [] })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const paths = new Set(state.getWorktrees().map((worktree) => worktree.path))
|
||||
const worktrees = await manager.listExternalWorktrees(paths)
|
||||
this.host.post({ type: "agentManager.externalWorktrees", worktrees })
|
||||
} catch (error) {
|
||||
this.host.log(`Failed to list external worktrees: ${error}`)
|
||||
this.host.post({ type: "agentManager.externalWorktrees", worktrees: [] })
|
||||
}
|
||||
}
|
||||
|
||||
async branch(branch: string): Promise<void> {
|
||||
const manager = this.host.manager()
|
||||
const state = this.host.state()
|
||||
if (!manager || !state) {
|
||||
this.host.post({ type: "agentManager.importResult", success: false, message: "Not a git repository" })
|
||||
return
|
||||
}
|
||||
if (this.busy()) return
|
||||
|
||||
this.importing = true
|
||||
try {
|
||||
this.host.post({
|
||||
type: "agentManager.worktreeSetup",
|
||||
status: "creating",
|
||||
message: "Creating worktree from branch...",
|
||||
})
|
||||
const result = await manager.createWorktree({ existingBranch: branch })
|
||||
const worktree = state.addWorktree({
|
||||
branch: result.branch,
|
||||
path: result.path,
|
||||
parentBranch: result.parentBranch,
|
||||
remote: result.remote,
|
||||
})
|
||||
this.host.push()
|
||||
|
||||
try {
|
||||
this.host.post({
|
||||
type: "agentManager.worktreeSetup",
|
||||
status: "creating",
|
||||
message: "Running setup script...",
|
||||
branch: result.branch,
|
||||
worktreeId: worktree.id,
|
||||
})
|
||||
await this.host.setup(result.path, result.branch, worktree.id)
|
||||
|
||||
const session = await this.host.session(result.path, result.branch, worktree.id)
|
||||
if (!session) throw new Error("Failed to create session")
|
||||
|
||||
state.addSession(session.id, worktree.id)
|
||||
this.host.register(session.id, result.path)
|
||||
this.host.ready(session.id, result, worktree.id)
|
||||
this.host.post({ type: "agentManager.importResult", success: true, message: `Opened branch ${branch}` })
|
||||
this.host.log(`Imported branch ${branch} as worktree ${worktree.id}`)
|
||||
} catch (error) {
|
||||
state.removeWorktree(worktree.id)
|
||||
await manager.removeWorktree(result.path)
|
||||
this.host.push()
|
||||
throw error
|
||||
}
|
||||
} catch (error) {
|
||||
this.importError(error, `Branch "${branch}" is already checked out in another worktree`)
|
||||
} finally {
|
||||
this.importing = false
|
||||
}
|
||||
}
|
||||
|
||||
async pr(url: string): Promise<void> {
|
||||
const manager = this.host.manager()
|
||||
const state = this.host.state()
|
||||
if (!manager || !state) {
|
||||
this.host.post({ type: "agentManager.importResult", success: false, message: "Not a git repository" })
|
||||
return
|
||||
}
|
||||
if (this.busy()) return
|
||||
|
||||
this.importing = true
|
||||
try {
|
||||
this.host.post({ type: "agentManager.worktreeSetup", status: "creating", message: "Resolving PR..." })
|
||||
const result = await manager.createFromPR(url)
|
||||
const worktree = state.addWorktree({
|
||||
branch: result.branch,
|
||||
path: result.path,
|
||||
parentBranch: result.parentBranch,
|
||||
remote: result.remote,
|
||||
})
|
||||
this.host.push()
|
||||
|
||||
try {
|
||||
this.host.post({
|
||||
type: "agentManager.worktreeSetup",
|
||||
status: "creating",
|
||||
message: "Setting up worktree...",
|
||||
branch: result.branch,
|
||||
worktreeId: worktree.id,
|
||||
})
|
||||
await this.host.setup(result.path, result.branch, worktree.id)
|
||||
|
||||
const session = await this.host.session(result.path, result.branch, worktree.id)
|
||||
if (!session) throw new Error("Failed to create session")
|
||||
|
||||
state.addSession(session.id, worktree.id)
|
||||
this.host.register(session.id, result.path)
|
||||
this.host.ready(session.id, result, worktree.id)
|
||||
this.host.post({
|
||||
type: "agentManager.importResult",
|
||||
success: true,
|
||||
message: `Opened PR branch ${result.branch}`,
|
||||
})
|
||||
this.host.log(`Imported PR ${url} as worktree ${worktree.id}`)
|
||||
} catch (error) {
|
||||
state.removeWorktree(worktree.id)
|
||||
await manager.removeWorktree(result.path)
|
||||
this.host.push()
|
||||
throw error
|
||||
}
|
||||
} catch (error) {
|
||||
this.importError(error, "This PR's branch is already checked out in another worktree")
|
||||
} finally {
|
||||
this.importing = false
|
||||
}
|
||||
}
|
||||
|
||||
async path(path: string, branch: string): Promise<void> {
|
||||
const state = this.host.state()
|
||||
const manager = this.host.manager()
|
||||
if (!state || !manager) {
|
||||
this.host.post({ type: "agentManager.importResult", success: false, message: "State not initialized" })
|
||||
return
|
||||
}
|
||||
if (this.busy()) return
|
||||
|
||||
this.importing = true
|
||||
let worktree: Worktree | undefined
|
||||
try {
|
||||
const paths = new Set(state.getWorktrees().map((worktree) => worktree.path))
|
||||
const externals = await manager.listExternalWorktrees(paths)
|
||||
if (!externals.some((worktree) => normalizePath(worktree.path) === normalizePath(path))) {
|
||||
this.host.post({
|
||||
type: "agentManager.importResult",
|
||||
success: false,
|
||||
message: "Path is not a valid worktree for this repository",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const base = await manager.resolveBaseBranch()
|
||||
worktree = state.addWorktree({ branch, path, parentBranch: base.branch, remote: base.remote })
|
||||
this.host.push()
|
||||
|
||||
const session = await this.host.session(path, branch, worktree.id)
|
||||
if (!session) {
|
||||
state.removeWorktree(worktree.id)
|
||||
this.host.push()
|
||||
this.host.post({ type: "agentManager.importResult", success: false, message: "Failed to create session" })
|
||||
return
|
||||
}
|
||||
|
||||
state.addSession(session.id, worktree.id)
|
||||
this.host.register(session.id, path)
|
||||
this.host.push()
|
||||
this.host.post({
|
||||
type: "agentManager.worktreeSetup",
|
||||
status: "ready",
|
||||
message: "Worktree imported",
|
||||
sessionId: session.id,
|
||||
branch,
|
||||
worktreeId: worktree.id,
|
||||
})
|
||||
this.host.post({
|
||||
type: "agentManager.sessionMeta",
|
||||
sessionId: session.id,
|
||||
mode: "worktree",
|
||||
branch,
|
||||
path,
|
||||
parentBranch: base.branch,
|
||||
})
|
||||
this.host.post({ type: "agentManager.importResult", success: true, message: `Imported ${branch}` })
|
||||
this.host.log(`Imported external worktree ${path} (${branch})`)
|
||||
} catch (error) {
|
||||
if (worktree) {
|
||||
state.removeWorktree(worktree.id)
|
||||
this.host.push()
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
this.host.post({ type: "agentManager.importResult", success: false, message })
|
||||
} finally {
|
||||
this.importing = false
|
||||
}
|
||||
}
|
||||
|
||||
async all(): Promise<void> {
|
||||
if (this.busy()) return
|
||||
|
||||
const manager = this.host.manager()
|
||||
const state = this.host.state()
|
||||
if (!manager || !state) {
|
||||
this.host.post({ type: "agentManager.importResult", success: false, message: "Not a git repository" })
|
||||
return
|
||||
}
|
||||
this.importing = true
|
||||
|
||||
try {
|
||||
const paths = new Set(state.getWorktrees().map((worktree) => worktree.path))
|
||||
const externals = await manager.listExternalWorktrees(paths)
|
||||
if (externals.length === 0) {
|
||||
this.host.post({
|
||||
type: "agentManager.importResult",
|
||||
success: true,
|
||||
message: "No external worktrees to import",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const imported: string[] = []
|
||||
const base = await manager.resolveBaseBranch()
|
||||
for (const external of externals) {
|
||||
try {
|
||||
const worktree = state.addWorktree({
|
||||
branch: external.branch,
|
||||
path: external.path,
|
||||
parentBranch: base.branch,
|
||||
remote: base.remote,
|
||||
})
|
||||
const session = await this.host.session(external.path, external.branch, worktree.id)
|
||||
if (session) {
|
||||
state.addSession(session.id, worktree.id)
|
||||
this.host.register(session.id, external.path)
|
||||
imported.push(worktree.id)
|
||||
continue
|
||||
}
|
||||
state.removeWorktree(worktree.id)
|
||||
} catch (error) {
|
||||
this.host.log(`Failed to import external worktree ${external.path}: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
this.host.push()
|
||||
this.host.post({
|
||||
type: "agentManager.importResult",
|
||||
success: true,
|
||||
message: `Imported ${imported.length} worktree${imported.length !== 1 ? "s" : ""}`,
|
||||
})
|
||||
this.host.log(`Imported ${imported.length}/${externals.length} external worktrees`)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
this.host.post({ type: "agentManager.importResult", success: false, message })
|
||||
} finally {
|
||||
this.importing = false
|
||||
}
|
||||
}
|
||||
|
||||
private busy(): boolean {
|
||||
if (!this.importing) return false
|
||||
this.host.post({
|
||||
type: "agentManager.importResult",
|
||||
success: false,
|
||||
message: "Another import is already in progress",
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
private importError(error: unknown, duplicate: string): void {
|
||||
const raw = error instanceof Error ? error.message : String(error)
|
||||
const message = raw.includes("already used by worktree") || raw.includes("already checked out") ? duplicate : raw
|
||||
const code = classifyWorktreeError(message)
|
||||
this.host.post({ type: "agentManager.worktreeSetup", status: "error", message, errorCode: code })
|
||||
this.host.post({ type: "agentManager.importResult", success: false, message, errorCode: code })
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@ const TSX_FILES = [
|
||||
const TSX_FILE = TSX_FILES[0]!
|
||||
const PROVIDER_FILE = path.join(ROOT, "src/agent-manager/AgentManagerProvider.ts")
|
||||
const DIFF_CONTROLLER_FILE = path.join(ROOT, "src/agent-manager/worktree-diff-controller.ts")
|
||||
const IMPORTER_FILE = path.join(ROOT, "src/agent-manager/worktree-importer.ts")
|
||||
const SETUP_SCRIPT_RUNNER_FILE = path.join(ROOT, "src/agent-manager/SetupScriptRunner.ts")
|
||||
|
||||
function readAllCss(): string {
|
||||
@@ -177,6 +178,10 @@ describe("Agent Manager Provider — onMessage routing", () => {
|
||||
return fs.readFileSync(DIFF_CONTROLLER_FILE, "utf-8")
|
||||
}
|
||||
|
||||
function importer(): string {
|
||||
return fs.readFileSync(IMPORTER_FILE, "utf-8")
|
||||
}
|
||||
|
||||
// -- onMessage dispatches all expected message types -----------------------
|
||||
|
||||
it("provider routing handles all documented agentManager.* message types", () => {
|
||||
@@ -220,6 +225,7 @@ describe("Agent Manager Provider — onMessage routing", () => {
|
||||
const text = body("onMessage")
|
||||
expect(text).toContain("onWorktreeMessage")
|
||||
expect(text).toContain("onSessionMessage")
|
||||
expect(text).toContain("onImportMessage")
|
||||
expect(text).toContain("onDiffMessage")
|
||||
expect(text).not.toContain("agentManager.requestState")
|
||||
})
|
||||
@@ -314,7 +320,6 @@ describe("Agent Manager Provider — onMessage routing", () => {
|
||||
* loading skeletons forever.
|
||||
*/
|
||||
it("requestState handler calls pushEmptyState when this.state is falsy", () => {
|
||||
// onStateMessage delegates to onRequestState; verify the actual handler
|
||||
const text = body("onRequestState")
|
||||
expect(text, "must call pushEmptyState when state is absent").toContain("pushEmptyState")
|
||||
expect(text, "must guard on this.state being falsy").toMatch(/!this\.state/)
|
||||
@@ -335,6 +340,16 @@ describe("Agent Manager Provider — onMessage routing", () => {
|
||||
expect(text).toContain("shouldStopDiffPolling")
|
||||
expect(providerText).toContain("this.diffs")
|
||||
})
|
||||
|
||||
it("worktree import behavior lives in the cohesive importer", () => {
|
||||
const text = importer()
|
||||
const providerText = body("onImportMessage")
|
||||
expect(text).toContain("class WorktreeImporter")
|
||||
expect(text).toContain("createFromPR")
|
||||
expect(text).toContain("listExternalWorktrees")
|
||||
expect(text).toContain("createWorktree")
|
||||
expect(providerText).toContain("this.importer")
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -559,7 +574,7 @@ const VSCODE_ALLOWED: Record<string, { note: string }> = {
|
||||
const MAX_LINES: Record<string, { maxLines: number; note: string }> = {
|
||||
"AgentManagerProvider.ts": {
|
||||
maxLines: 2000,
|
||||
note: "worktree diff orchestration lives in WorktreeDiffController; lower this after the next cohesive extraction",
|
||||
note: "diff and import workflows are extracted into cohesive domain services; extract more orchestration next",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user