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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions Sources/KanbanCode/ContentView+Launch.swift
Original file line number Diff line number Diff line change
Expand Up @@ -153,9 +153,20 @@ extension ContentView {
)
}

// For agents without native --worktree support, create the worktree manually
var launchPath = projectPath
var manualWorktreeLink: WorktreeLink?
if let wtName = worktreeName, !wtName.isEmpty, !assistant.supportsWorktree {
let adapter = GitWorktreeAdapter()
let wt = try await adapter.createWorktree(repoRoot: projectPath, name: wtName)
launchPath = wt.path
manualWorktreeLink = WorktreeLink(path: wt.path, branch: wt.branch ?? wtName, isManual: true)
KanbanCodeLog.info("launch", "Created manual worktree for \(assistant.displayName): \(wt.path)")
}

let tmuxName = try await launcher.launch(
sessionName: predictedTmuxName,
projectPath: projectPath,
projectPath: launchPath,
prompt: prompt,
worktreeName: assistant.supportsWorktree ? worktreeName : nil,
shellOverride: shellOverride,
Expand Down Expand Up @@ -263,9 +274,12 @@ extension ContentView {
if sessionLink != nil { break }
}

// If worktree launch, try to extract branch from the session file immediately
// Resolve worktreeLink: manual worktrees are known immediately,
// native worktrees need extraction from the session file metadata
var worktreeLink: WorktreeLink?
if worktreeName != nil, let sl = sessionLink, let sp = sl.sessionPath {
if let mwl = manualWorktreeLink {
worktreeLink = mwl
} else if worktreeName != nil, let sl = sessionLink, let sp = sl.sessionPath {
worktreeLink = Self.extractWorktreeLink(sessionPath: sp, projectPath: projectPath)
}

Expand Down Expand Up @@ -463,7 +477,7 @@ extension ContentView {
if !keepWorktree {
// Extract parent project if projectPath is a worktree path
if let pp = forkProjectPath,
let range = pp.range(of: "/.claude/worktrees/") {
let range = pp.range(of: "/.claude/worktrees/") ?? pp.range(of: "/.worktrees/") {
forkProjectPath = String(pp[..<range.lowerBound])
}
// Always place the forked session in the correct project dir
Expand Down
2 changes: 2 additions & 0 deletions Sources/KanbanCode/ContentView+Worktree.swift
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,8 @@ extension ContentView {
let repoRoot: String
if let range = info.remotePath.range(of: "/.claude/worktrees/") {
repoRoot = String(info.remotePath[..<range.lowerBound])
} else if let range = info.remotePath.range(of: "/.worktrees/") {
repoRoot = String(info.remotePath[..<range.lowerBound])
} else {
repoRoot = (info.remotePath as NSString).deletingLastPathComponent
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/KanbanCode/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1048,7 +1048,7 @@ struct ContentView: View {
/// Offer worktree cleanup after archive/delete if applicable.
private func offerWorktreeCleanupIfNeeded(card: KanbanCodeCard?) {
guard let card, let wt = card.link.worktreeLink,
!wt.path.isEmpty, wt.path.contains("/.claude/worktrees/"),
!wt.path.isEmpty, wt.path.contains("/.claude/worktrees/") || wt.path.contains("/.worktrees/"),
canCleanupWorktree(for: card) else { return }
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(300))
Expand Down
4 changes: 2 additions & 2 deletions Sources/KanbanCode/LaunchConfirmationDialog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ struct LaunchConfirmationDialog: View {

// Checkboxes
VStack(alignment: .leading, spacing: 6) {
if !isResume && !hasExistingWorktree && assistant.supportsWorktree {
if !isResume && !hasExistingWorktree {
Toggle("Create worktree", isOn: isGitRepo ? $createWorktree : .constant(false))
.font(.app(.callout))
.disabled(!isGitRepo)
Expand Down Expand Up @@ -272,7 +272,7 @@ struct LaunchConfirmationDialog: View {
// MARK: - Computed

private var effectiveCreateWorktree: Bool {
!isResume && !hasExistingWorktree && createWorktree && isGitRepo && assistant.supportsWorktree
!isResume && !hasExistingWorktree && createWorktree && isGitRepo
}

private var effectiveRunRemotely: Bool {
Expand Down
4 changes: 2 additions & 2 deletions Sources/KanbanCodeCore/Adapters/ClaudeCode/JsonlParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -286,8 +286,8 @@ public enum JsonlParser {
/// Resolve a path to its likely git root.
/// Strips `.claude/worktrees/<name>` suffix since worktrees are inside the repo.
private static func resolveGitRoot(_ path: String) -> String {
// Pattern: /repo/.claude/worktrees/<name> → /repo
if let range = path.range(of: "/.claude/worktrees/") {
// Pattern: /repo/.claude/worktrees/<name> or /repo/.worktrees/<name> → /repo
if let range = path.range(of: "/.claude/worktrees/") ?? path.range(of: "/.worktrees/") {
return String(path[path.startIndex..<range.lowerBound])
}
return path
Expand Down
2 changes: 2 additions & 0 deletions Sources/KanbanCodeCore/Adapters/Git/GitWorktreeAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ public final class GitWorktreeAdapter: WorktreeManagerPort, @unchecked Sendable
effectiveRoot = repoRoot
} else if let range = path.range(of: "/.claude/worktrees/") {
effectiveRoot = String(path[..<range.lowerBound])
} else if let range = path.range(of: "/.worktrees/") {
effectiveRoot = String(path[..<range.lowerBound])
} else {
effectiveRoot = (path as NSString).deletingLastPathComponent
}
Expand Down
5 changes: 4 additions & 1 deletion Sources/KanbanCodeCore/Domain/Entities/Link.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,13 @@ public struct TmuxLink: Codable, Sendable, Equatable {
public struct WorktreeLink: Codable, Sendable, Equatable {
public var path: String
public var branch: String?
/// True when Kanban Code created this worktree manually (for agents without native --worktree support).
public var isManual: Bool?

public init(path: String, branch: String? = nil) {
public init(path: String, branch: String? = nil, isManual: Bool = false) {
self.path = path
self.branch = branch
self.isManual = isManual ? true : nil
}
}

Expand Down
2 changes: 1 addition & 1 deletion Sources/KanbanCodeCore/UseCases/BoardState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ public final class BoardState: @unchecked Sendable {

// Worktree match: card's worktree is at the git root (e.g. repo/.claude/worktrees/name)
// but the selected project is a subfolder of that repo (monorepo layout).
if let range = normalizedCard.range(of: "/.claude/worktrees/") {
if let range = normalizedCard.range(of: "/.claude/worktrees/") ?? normalizedCard.range(of: "/.worktrees/") {
let repoRoot = String(normalizedCard[..<range.lowerBound])
if normalizedSelected == repoRoot || normalizedSelected.hasPrefix(repoRoot + "/") {
return true
Expand Down
5 changes: 3 additions & 2 deletions Sources/KanbanCodeCore/UseCases/CardReconciler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ public enum CardReconciler {
// Handles both initial launch (worktreeLink == nil) and worktree switches
// (Claude called EnterWorktree → session moved to a different worktree).
if let pp = session.projectPath,
pp.contains("/.claude/worktrees/") {
pp.contains("/.claude/worktrees/") || pp.contains("/.worktrees/") {
let needsUpdate = link.worktreeLink == nil
|| (link.worktreeLink?.path != pp && link.sessionLink?.sessionId == session.id)
let shouldUpdate = needsUpdate && (link.isLaunching == true || link.worktreeLink != nil)
Expand All @@ -128,7 +128,7 @@ public enum CardReconciler {
}
}
// Fallback: extract from path if worktree not in snapshot yet
if branchName == nil, let range = pp.range(of: "/.claude/worktrees/") {
if branchName == nil, let range = pp.range(of: "/.claude/worktrees/") ?? pp.range(of: "/.worktrees/") {
let afterPrefix = String(pp[range.upperBound...])
branchName = afterPrefix.components(separatedBy: "/").first
}
Expand Down Expand Up @@ -585,5 +585,6 @@ public enum CardReconciler {
private static func isWorktreeUnder(sessionPath: String, projectRoot: String?) -> Bool {
guard let projectRoot else { return false }
return sessionPath.hasPrefix(projectRoot + "/.claude/worktrees/")
|| sessionPath.hasPrefix(projectRoot + "/.worktrees/")
}
}
92 changes: 92 additions & 0 deletions specs/sessions/manual-worktree-for-codex.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
Feature: Manual worktree creation for agents without native worktree support

Agents like Codex CLI and Gemini CLI don't have a `--worktree` flag.
Kanban Code should create git worktrees manually before launching these agents,
launch them cd'd into the worktree directory, track the worktree as "manual",
and clean it up when the card is archived/deleted.

Background:
Given a project at "/tmp/my-app" that is a git repository
And the user has Codex enabled as a coding assistant
And the project has a clean working tree on branch "main"

@unit
Scenario: Launch dialog shows "Create worktree" for Codex
Given a card with assistant "codex" and no existing worktree
When the launch confirmation dialog is shown
Then the "Create worktree" checkbox is visible
And the "Branch name" text field appears when the checkbox is enabled

@unit
Scenario: Launch dialog shows "Create worktree" for Gemini
Given a card with assistant "gemini" and no existing worktree
When the launch confirmation dialog is shown
Then the "Create worktree" checkbox is visible

@unit
Scenario: WorktreeLink tracks manual worktrees
Given a WorktreeLink with path "/tmp/my-app/.worktrees/feature-auth" and branch "feature-auth"
When isManual is set to true
Then encoding and decoding preserves the isManual flag

@integration
Scenario: Codex card launches in a manually-created worktree
Given a card with assistant "codex" and worktreeName "feature-auth"
And the user has "Create worktree" enabled
When the card is launched
Then a git worktree is created at "<projectPath>/.worktrees/feature-auth" on branch "feature-auth"
And the tmux session is launched with cd to the worktree directory
And the card's worktreeLink has path "<projectPath>/.worktrees/feature-auth"
And the card's worktreeLink has branch "feature-auth"
And the card's worktreeLink has isManual = true

@integration
Scenario: Codex card launches without worktree when checkbox is disabled
Given a card with assistant "codex" and worktreeName nil
And the user has "Create worktree" disabled
When the card is launched
Then no git worktree is created
And the tmux session is launched with cd to the project root
And the card's worktreeLink is nil

@integration
Scenario: Manual worktree is cleaned up on card archive
Given a card with assistant "codex" and a manual worktreeLink at "/tmp/my-app/.worktrees/feature-auth"
When the card is archived
Then the git worktree at "/tmp/my-app/.worktrees/feature-auth" is removed
And the worktreeLink is cleared from the card

@integration
Scenario: Manual worktree is cleaned up on card delete
Given a card with assistant "codex" and a manual worktreeLink at "/tmp/my-app/.worktrees/feature-auth"
When the card is deleted
Then the git worktree at "/tmp/my-app/.worktrees/feature-auth" is removed

@integration
Scenario: Codex resumes inside the worktree directory
Given a card with assistant "codex" and worktreeLink at "/tmp/my-app/.worktrees/feature-auth"
When the card is resumed
Then the resume command cd's into "/tmp/my-app/.worktrees/feature-auth"

@unit
Scenario: GitWorktreeAdapter creates worktree with custom base directory
Given a repo root at "/tmp/my-app"
When createWorktree is called with name "feature-auth"
Then the worktree is created at "/tmp/my-app/.worktrees/feature-auth"
And the branch is "feature-auth"

@unit
Scenario: Worktree cleanup handles both .worktrees/ and .claude/worktrees/ paths
Given a worktree path "/tmp/my-app/.worktrees/feature-auth"
When removeWorktree is called
Then the repo root is derived as "/tmp/my-app"
And git worktree remove is executed successfully

@unit
Scenario: effectiveCreateWorktree is true for Codex when conditions are met
Given assistant is "codex"
And isGitRepo is true
And createWorktree checkbox is true
And isResume is false
And hasExistingWorktree is false
Then effectiveCreateWorktree returns true