diff --git a/idx0/Services/Git/WorktreeService.swift b/idx0/Services/Git/WorktreeService.swift index 34a8231..6ebbc8d 100644 --- a/idx0/Services/Git/WorktreeService.swift +++ b/idx0/Services/Git/WorktreeService.swift @@ -1,166 +1,271 @@ import Foundation enum WorktreeServiceError: LocalizedError { - case invalidFolder - case invalidBranchName - case invalidWorktreePath - case worktreeNotFound - case worktreeDirty - case createFailed(String) - - var errorDescription: String? { - switch self { - case .invalidFolder: - return "The selected folder is invalid." - case .invalidBranchName: - return "Branch name cannot be empty." - case .invalidWorktreePath: - return "The selected worktree path is invalid." - case .worktreeNotFound: - return "That worktree does not belong to the selected repository." - case .worktreeDirty: - return "Worktree has local changes. Clean it before deletion." - case .createFailed(let message): - return message - } + case invalidFolder + case invalidBranchName + case invalidWorktreePath + case worktreeNotFound + case worktreeDirty + case createFailed(String) + + var errorDescription: String? { + switch self { + case .invalidFolder: + "The selected folder is invalid." + case .invalidBranchName: + "Branch name cannot be empty." + case .invalidWorktreePath: + "The selected worktree path is invalid." + case .worktreeNotFound: + "That worktree does not belong to the selected repository." + case .worktreeDirty: + "Worktree has local changes. Clean it before deletion." + case let .createFailed(message): + message } + } } protocol WorktreeServiceProtocol { - func validateRepo(path: String) async throws -> GitRepoInfo - func createWorktree(repoPath: String, branchName: String?, sessionTitle: String?) async throws -> WorktreeInfo - func attachExistingWorktree(repoPath: String, worktreePath: String) async throws -> WorktreeInfo - func listWorktrees(repoPath: String) async throws -> [WorktreeInfo] - func inspectWorktree(repoPath: String, worktreePath: String) async throws -> WorktreeState - func deleteWorktreeIfClean(repoPath: String, worktreePath: String) async throws + func validateRepo(path: String) async throws -> GitRepoInfo + func createWorktree(repoPath: String, branchName: String?, sessionTitle: String?) async throws -> WorktreeInfo + func attachExistingWorktree(repoPath: String, worktreePath: String) async throws -> WorktreeInfo + func listWorktrees(repoPath: String) async throws -> [WorktreeInfo] + func inspectWorktree(repoPath: String, worktreePath: String) async throws -> WorktreeState + func deleteWorktreeIfClean(repoPath: String, worktreePath: String) async throws } struct WorktreeService: WorktreeServiceProtocol { - private let gitService: GitServiceProtocol - private let paths: FileSystemPaths - - init(gitService: GitServiceProtocol, paths: FileSystemPaths) { - self.gitService = gitService - self.paths = paths - } - - func validateRepo(path: String) async throws -> GitRepoInfo { - try await gitService.repoInfo(for: path) - } - - func listWorktrees(repoPath: String) async throws -> [WorktreeInfo] { - try await gitService.listWorktrees(repoPath: repoPath) - } - - func attachExistingWorktree(repoPath: String, worktreePath: String) async throws -> WorktreeInfo { - let info = try await gitService.repoInfo(for: repoPath) - let normalizedPath = URL(fileURLWithPath: worktreePath).standardizedFileURL.path - - var isDirectory: ObjCBool = false - guard FileManager.default.fileExists(atPath: normalizedPath, isDirectory: &isDirectory), - isDirectory.boolValue else { - throw WorktreeServiceError.invalidWorktreePath - } - - let worktrees = try await gitService.listWorktrees(repoPath: info.topLevelPath) - guard let match = worktrees.first(where: { - URL(fileURLWithPath: $0.worktreePath).standardizedFileURL.path == normalizedPath - }) else { - throw WorktreeServiceError.worktreeNotFound - } - - return WorktreeInfo( - repoPath: info.topLevelPath, - worktreePath: match.worktreePath, - branchName: match.branchName - ) - } - - func createWorktree(repoPath: String, branchName: String?, sessionTitle: String?) async throws -> WorktreeInfo { - let info = try await gitService.repoInfo(for: repoPath) - - let resolvedBranch: String - if let branchName, - !branchName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - resolvedBranch = branchName.trimmingCharacters(in: .whitespacesAndNewlines) - } else { - resolvedBranch = BranchNameGenerator.generate( - sessionTitle: sessionTitle, - repoName: info.repoName - ) - } - - guard !resolvedBranch.isEmpty else { - throw WorktreeServiceError.invalidBranchName - } - - let worktreePath = uniqueWorktreePath(repoName: info.repoName, branchName: resolvedBranch) - - do { - return try await gitService.createWorktree( - repoPath: info.topLevelPath, - branchName: resolvedBranch, - worktreePath: worktreePath - ) - } catch { - throw WorktreeServiceError.createFailed(error.localizedDescription) - } - } - - func inspectWorktree(repoPath: String, worktreePath: String) async throws -> WorktreeState { - let info = try await gitService.repoInfo(for: repoPath) - let normalizedPath = URL(fileURLWithPath: worktreePath).standardizedFileURL.path - - var isDirectory: ObjCBool = false - guard FileManager.default.fileExists(atPath: normalizedPath, isDirectory: &isDirectory), isDirectory.boolValue else { - return .missingOnDisk - } - - let worktrees = try await gitService.listWorktrees(repoPath: info.topLevelPath) - guard worktrees.contains(where: { - URL(fileURLWithPath: $0.worktreePath).standardizedFileURL.path == normalizedPath - }) else { - throw WorktreeServiceError.worktreeNotFound - } - - let status = try await gitService.statusPorcelain(path: normalizedPath) - return status.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? .clean : .dirty - } - - func deleteWorktreeIfClean(repoPath: String, worktreePath: String) async throws { - let state = try await inspectWorktree(repoPath: repoPath, worktreePath: worktreePath) - switch state { - case .clean: - let info = try await gitService.repoInfo(for: repoPath) - try await gitService.removeWorktree( - repoPath: info.topLevelPath, - worktreePath: URL(fileURLWithPath: worktreePath).standardizedFileURL.path - ) - case .dirty: - throw WorktreeServiceError.worktreeDirty - case .missingOnDisk: - throw WorktreeServiceError.invalidWorktreePath - default: - throw WorktreeServiceError.worktreeNotFound - } - } - - private func uniqueWorktreePath(repoName: String, branchName: String) -> String { - let safeRepo = BranchNameGenerator.slugify(repoName) - let safeBranch = BranchNameGenerator.slugify(branchName) - let root = paths.worktreesDirectory - .appendingPathComponent(safeRepo, isDirectory: true) - - try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - - var candidate = root.appendingPathComponent(safeBranch, isDirectory: true) - var suffix = 2 - - while FileManager.default.fileExists(atPath: candidate.path) { - candidate = root.appendingPathComponent("\(safeBranch)-\(suffix)", isDirectory: true) - suffix += 1 - } - - return candidate.path + private let gitService: GitServiceProtocol + private let paths: FileSystemPaths + private let fileManager: FileManager + private let worktreeNameGenerator: () -> String + + init( + gitService: GitServiceProtocol, + paths: FileSystemPaths, + fileManager: FileManager = .default, + worktreeNameGenerator: @escaping () -> String = { + String(UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(12)).lowercased() + } + ) { + self.gitService = gitService + self.paths = paths + self.fileManager = fileManager + self.worktreeNameGenerator = worktreeNameGenerator + } + + func validateRepo(path: String) async throws -> GitRepoInfo { + try await gitService.repoInfo(for: path) + } + + func listWorktrees(repoPath: String) async throws -> [WorktreeInfo] { + try await gitService.listWorktrees(repoPath: repoPath) + } + + func attachExistingWorktree(repoPath: String, worktreePath: String) async throws -> WorktreeInfo { + let info = try await gitService.repoInfo(for: repoPath) + let normalizedPath = URL(fileURLWithPath: worktreePath).standardizedFileURL.path + + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: normalizedPath, isDirectory: &isDirectory), + isDirectory.boolValue + else { + throw WorktreeServiceError.invalidWorktreePath + } + + let worktrees = try await gitService.listWorktrees(repoPath: info.topLevelPath) + guard let match = worktrees.first(where: { + URL(fileURLWithPath: $0.worktreePath).standardizedFileURL.path == normalizedPath + }) else { + throw WorktreeServiceError.worktreeNotFound + } + + return WorktreeInfo( + repoPath: info.topLevelPath, + worktreePath: match.worktreePath, + branchName: match.branchName + ) + } + + func createWorktree(repoPath: String, branchName: String?, sessionTitle: String?) async throws -> WorktreeInfo { + let info = try await gitService.repoInfo(for: repoPath) + + let resolvedBranch: String = if let branchName, + !branchName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + branchName.trimmingCharacters(in: .whitespacesAndNewlines) + } else { + BranchNameGenerator.generate( + sessionTitle: sessionTitle, + repoName: info.repoName + ) + } + + guard !resolvedBranch.isEmpty else { + throw WorktreeServiceError.invalidBranchName + } + + ensureIDX0ExcludedInLocalRepo(repoTopLevelPath: info.topLevelPath) + + let worktreePath: String + do { + worktreePath = try uniqueWorktreePath(repoTopLevelPath: info.topLevelPath) + } catch { + throw WorktreeServiceError.createFailed("Unable to prepare workspace worktree directory: \(error.localizedDescription)") + } + + do { + return try await gitService.createWorktree( + repoPath: info.topLevelPath, + branchName: resolvedBranch, + worktreePath: worktreePath + ) + } catch { + throw WorktreeServiceError.createFailed(error.localizedDescription) + } + } + + func inspectWorktree(repoPath: String, worktreePath: String) async throws -> WorktreeState { + let info = try await gitService.repoInfo(for: repoPath) + let normalizedPath = URL(fileURLWithPath: worktreePath).standardizedFileURL.path + + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: normalizedPath, isDirectory: &isDirectory), isDirectory.boolValue else { + return .missingOnDisk + } + + let worktrees = try await gitService.listWorktrees(repoPath: info.topLevelPath) + guard worktrees.contains(where: { + URL(fileURLWithPath: $0.worktreePath).standardizedFileURL.path == normalizedPath + }) else { + throw WorktreeServiceError.worktreeNotFound + } + + let status = try await gitService.statusPorcelain(path: normalizedPath) + return status.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? .clean : .dirty + } + + func deleteWorktreeIfClean(repoPath: String, worktreePath: String) async throws { + let state = try await inspectWorktree(repoPath: repoPath, worktreePath: worktreePath) + switch state { + case .clean: + let info = try await gitService.repoInfo(for: repoPath) + try await gitService.removeWorktree( + repoPath: info.topLevelPath, + worktreePath: URL(fileURLWithPath: worktreePath).standardizedFileURL.path + ) + case .dirty: + throw WorktreeServiceError.worktreeDirty + case .missingOnDisk: + throw WorktreeServiceError.invalidWorktreePath + default: + throw WorktreeServiceError.worktreeNotFound + } + } + + private func uniqueWorktreePath(repoTopLevelPath: String) throws -> String { + let root = try workspaceWorktreesDirectoryURL(repoTopLevelPath: repoTopLevelPath) + let baseName = "wt-\(sanitizedWorktreeToken(worktreeNameGenerator()))" + var candidate = root.appendingPathComponent(baseName, isDirectory: true) + var suffix = 2 + + while fileManager.fileExists(atPath: candidate.path) { + candidate = root.appendingPathComponent("\(baseName)-\(suffix)", isDirectory: true) + suffix += 1 + } + + return candidate.path + } + + private func workspaceWorktreesDirectoryURL(repoTopLevelPath: String) throws -> URL { + let root = URL(fileURLWithPath: repoTopLevelPath) + .standardizedFileURL + .appendingPathComponent(".idx0", isDirectory: true) + .appendingPathComponent("worktrees", isDirectory: true) + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + return root + } + + private func sanitizedWorktreeToken(_ rawValue: String) -> String { + let token = rawValue.lowercased().filter { character in + ("a" ... "z").contains(character) || ("0" ... "9").contains(character) + } + if !token.isEmpty { + return String(token.prefix(12)) + } + return String(UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(12)).lowercased() + } + + private func ensureIDX0ExcludedInLocalRepo(repoTopLevelPath: String) { + guard let excludeFileURL = localGitExcludeURL(repoTopLevelPath: repoTopLevelPath) else { + return + } + + do { + try fileManager.createDirectory( + at: excludeFileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + + let existing = (try? String(contentsOf: excludeFileURL, encoding: .utf8)) ?? "" + let entries = existing + .components(separatedBy: .newlines) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + guard !entries.contains(".idx0/") else { return } + + var updated = existing + if !updated.isEmpty, !updated.hasSuffix("\n") { + updated.append("\n") + } + updated.append(".idx0/\n") + try updated.write(to: excludeFileURL, atomically: true, encoding: .utf8) + } catch { + Logger.error("Failed to update local git exclude for .idx0/: \(error.localizedDescription)") + } + } + + private func localGitExcludeURL(repoTopLevelPath: String) -> URL? { + guard let gitDirectory = gitDirectoryURL(repoTopLevelPath: repoTopLevelPath) else { + return nil + } + return gitDirectory + .appendingPathComponent("info", isDirectory: true) + .appendingPathComponent("exclude", isDirectory: false) + } + + private func gitDirectoryURL(repoTopLevelPath: String) -> URL? { + let repoURL = URL(fileURLWithPath: repoTopLevelPath).standardizedFileURL + let dotGitURL = repoURL.appendingPathComponent(".git", isDirectory: false) + + var isDirectory: ObjCBool = false + guard fileManager.fileExists(atPath: dotGitURL.path, isDirectory: &isDirectory) else { + return nil + } + + if isDirectory.boolValue { + return dotGitURL + } + + guard let dotGitContents = try? String(contentsOf: dotGitURL, encoding: .utf8), + let directiveLine = dotGitContents + .components(separatedBy: .newlines) + .first(where: { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) + else { + return nil + } + + let trimmed = directiveLine.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.lowercased().hasPrefix("gitdir:") else { return nil } + + let rawPath = String(trimmed.dropFirst("gitdir:".count)).trimmingCharacters(in: .whitespacesAndNewlines) + guard !rawPath.isEmpty else { return nil } + + let resolved: URL = if rawPath.hasPrefix("/") { + URL(fileURLWithPath: rawPath, isDirectory: true) + } else { + repoURL.appendingPathComponent(rawPath, isDirectory: true) } + return resolved.standardizedFileURL + } } diff --git a/idx0/Services/Session/SessionService+Lifecycle.swift b/idx0/Services/Session/SessionService+Lifecycle.swift index 8fb08ab..f767cf3 100644 --- a/idx0/Services/Session/SessionService+Lifecycle.swift +++ b/idx0/Services/Session/SessionService+Lifecycle.swift @@ -41,22 +41,32 @@ extension SessionService { var worktreePath: String? var isWorktreeBacked = false var createdWorktree: WorktreeInfo? + let normalizedExistingWorktreePath = normalizePath(request.existingWorktreePath) + let explicitlyRequestedWorktree = request.createWorktree || normalizedExistingWorktreePath != nil if let normalizedRepo { branchName = request.branchName?.trimmingCharacters(in: .whitespacesAndNewlines) if branchName?.isEmpty == true { branchName = nil } - if request.createWorktree { - let repoInfo = try await worktreeService.validateRepo(path: normalizedRepo) - repoPath = repoInfo.topLevelPath - let worktree: WorktreeInfo = if let existingWorktreePath = normalizePath(request.existingWorktreePath) { + let repoInfo = try? await worktreeService.validateRepo(path: normalizedRepo) + let shouldCreateWorktree = explicitlyRequestedWorktree + || (settings.defaultCreateWorktreeForRepoSessions && repoInfo != nil) + + if shouldCreateWorktree { + let resolvedRepoInfo: GitRepoInfo = if let repoInfo { + repoInfo + } else { + try await worktreeService.validateRepo(path: normalizedRepo) + } + repoPath = resolvedRepoInfo.topLevelPath + let worktree: WorktreeInfo = if let normalizedExistingWorktreePath { try await worktreeService.attachExistingWorktree( - repoPath: repoInfo.topLevelPath, - worktreePath: existingWorktreePath + repoPath: resolvedRepoInfo.topLevelPath, + worktreePath: normalizedExistingWorktreePath ) } else { try await worktreeService.createWorktree( - repoPath: repoInfo.topLevelPath, + repoPath: resolvedRepoInfo.topLevelPath, branchName: branchName, sessionTitle: request.title ) @@ -66,7 +76,7 @@ extension SessionService { isWorktreeBacked = true createdWorktree = worktree } else { - if let repoInfo = try? await worktreeService.validateRepo(path: normalizedRepo) { + if let repoInfo { repoPath = repoInfo.topLevelPath if branchName == nil { branchName = repoInfo.currentBranch diff --git a/idx0/UI/Settings/Inline/InlineSessionSettings.swift b/idx0/UI/Settings/Inline/InlineSessionSettings.swift index de8486e..a8b0227 100644 --- a/idx0/UI/Settings/Inline/InlineSessionSettings.swift +++ b/idx0/UI/Settings/Inline/InlineSessionSettings.swift @@ -3,76 +3,76 @@ import SwiftUI // MARK: - Sessions struct InlineSessionSettings: View { - @ObservedObject var sessionService: SessionService - @ObservedObject var workflowService: WorkflowService - @Environment(\.themeColors) private var tc + @ObservedObject var sessionService: SessionService + @ObservedObject var workflowService: WorkflowService + @Environment(\.themeColors) private var tc - var body: some View { - VStack(alignment: .leading, spacing: 0) { - SettingSectionHeader(title: "Behavior") + var body: some View { + VStack(alignment: .leading, spacing: 0) { + SettingSectionHeader(title: "Behavior") - SettingRowView(label: "New Session", caption: "Controls what happens when you press \u{2318}T. Quick creates an instant terminal, Structured opens the full setup dialog.") { - ThemedPicker( - options: NewSessionBehavior.allCases.map { ($0.displayLabel, $0) }, - selection: enumBinding(\.newSessionBehavior) - ) - } - - SettingToggleRow( - label: "Create Worktree By Default", - caption: "Automatically enable git worktree creation when opening repo-backed sessions.", - isOn: binding(\.defaultCreateWorktreeForRepoSessions) - ) + SettingRowView(label: "New Session", caption: "Controls what happens when you press \u{2318}T. Quick creates an instant terminal, Structured opens the full setup dialog.") { + ThemedPicker( + options: NewSessionBehavior.allCases.map { ($0.displayLabel, $0) }, + selection: enumBinding(\.newSessionBehavior) + ) + } - SettingRowView(label: "Restore on Relaunch", caption: "What to bring back when IDX0 restarts.") { - ThemedPicker( - options: RestoreBehavior.allCases.map { ($0.displayLabel, $0) }, - selection: enumBinding(\.restoreBehavior) - ) - } + SettingToggleRow( + label: "Create Worktree By Default", + caption: "When enabled, repo-backed sessions always create a worktree.", + isOn: binding(\.defaultCreateWorktreeForRepoSessions) + ) - SettingToggleRow( - label: "Cleanup On Close", - caption: "When enabled, tile layouts are cleared when a session is closed. When disabled, open tiles are restored on next launch.", - isOn: binding(\.cleanupOnClose) - ) + SettingRowView(label: "Restore on Relaunch", caption: "What to bring back when IDX0 restarts.") { + ThemedPicker( + options: RestoreBehavior.allCases.map { ($0.displayLabel, $0) }, + selection: enumBinding(\.restoreBehavior) + ) + } - if sessionService.settings.appMode.showsVibeFeatures { - SettingDivider() - SettingSectionHeader(title: "Vibe Tools") + SettingToggleRow( + label: "Cleanup On Close", + caption: "When enabled, tile layouts are cleared when a session is closed. When disabled, open tiles are restored on next launch.", + isOn: binding(\.cleanupOnClose) + ) - SettingRowView(label: "Default Tool", caption: "The agentic CLI to auto-launch in new sessions.") { - ThemedPicker( - options: [("None", "none")] + workflowService.vibeTools.map { - ($0.isInstalled ? $0.displayName : "\($0.displayName) (N/A)", $0.id) - }, - selection: Binding( - get: { sessionService.settings.defaultVibeToolID ?? "none" }, - set: { value in sessionService.saveSettings { $0.defaultVibeToolID = value == "none" ? nil : value } } - ) - ) - } + if sessionService.settings.appMode.showsVibeFeatures { + SettingDivider() + SettingSectionHeader(title: "Vibe Tools") - SettingToggleRow( - label: "Auto-Launch on \u{2318}N", - caption: nil, - isOn: binding(\.autoLaunchDefaultVibeToolOnCmdN) - ) - } + SettingRowView(label: "Default Tool", caption: "The agentic CLI to auto-launch in new sessions.") { + ThemedPicker( + options: [("None", "none")] + workflowService.vibeTools.map { + ($0.isInstalled ? $0.displayName : "\($0.displayName) (N/A)", $0.id) + }, + selection: Binding( + get: { sessionService.settings.defaultVibeToolID ?? "none" }, + set: { value in sessionService.saveSettings { $0.defaultVibeToolID = value == "none" ? nil : value } } + ) + ) } - } - private func binding(_ keyPath: WritableKeyPath) -> Binding { - Binding( - get: { sessionService.settings[keyPath: keyPath] }, - set: { value in sessionService.saveSettings { $0[keyPath: keyPath] = value } } + SettingToggleRow( + label: "Auto-Launch on \u{2318}N", + caption: nil, + isOn: binding(\.autoLaunchDefaultVibeToolOnCmdN) ) + } } + } - private func enumBinding(_ keyPath: WritableKeyPath) -> Binding { - Binding( - get: { sessionService.settings[keyPath: keyPath] }, - set: { value in sessionService.saveSettings { $0[keyPath: keyPath] = value } } - ) - } + private func binding(_ keyPath: WritableKeyPath) -> Binding { + Binding( + get: { sessionService.settings[keyPath: keyPath] }, + set: { value in sessionService.saveSettings { $0[keyPath: keyPath] = value } } + ) + } + + private func enumBinding(_ keyPath: WritableKeyPath) -> Binding { + Binding( + get: { sessionService.settings[keyPath: keyPath] }, + set: { value in sessionService.saveSettings { $0[keyPath: keyPath] = value } } + ) + } } diff --git a/idx0/UI/Settings/Tabs/SessionSettingsTab.swift b/idx0/UI/Settings/Tabs/SessionSettingsTab.swift index 7212c3c..c6cf42e 100644 --- a/idx0/UI/Settings/Tabs/SessionSettingsTab.swift +++ b/idx0/UI/Settings/Tabs/SessionSettingsTab.swift @@ -3,94 +3,94 @@ import SwiftUI // MARK: - Sessions Tab struct SessionSettingsTab: View { - @ObservedObject var sessionService: SessionService - @ObservedObject var workflowService: WorkflowService + @ObservedObject var sessionService: SessionService + @ObservedObject var workflowService: WorkflowService - var body: some View { - Form { - Section("Behavior") { - Picker("New Session Behavior", selection: enumBinding(\.newSessionBehavior)) { - ForEach(NewSessionBehavior.allCases, id: \.self) { behavior in - Text(behavior.displayLabel).tag(behavior) - } - } - Text("What happens when you press \u{2318}T") - .font(.caption) - .foregroundStyle(.tertiary) - - VStack(alignment: .leading, spacing: 4) { - Toggle( - "Create Worktree By Default For Repo Sessions", - isOn: binding(\.defaultCreateWorktreeForRepoSessions) - ) - Text("Automatically enable worktree creation when a git repo is selected") - .font(.caption) - .foregroundStyle(.tertiary) - } + var body: some View { + Form { + Section("Behavior") { + Picker("New Session Behavior", selection: enumBinding(\.newSessionBehavior)) { + ForEach(NewSessionBehavior.allCases, id: \.self) { behavior in + Text(behavior.displayLabel).tag(behavior) + } + } + Text("What happens when you press \u{2318}T") + .font(.caption) + .foregroundStyle(.tertiary) - Picker("Restore Behavior", selection: enumBinding(\.restoreBehavior)) { - ForEach(RestoreBehavior.allCases, id: \.self) { behavior in - Text(behavior.displayLabel).tag(behavior) - } - } - Text("What to restore when IDX0 relaunches") - .font(.caption) - .foregroundStyle(.tertiary) + VStack(alignment: .leading, spacing: 4) { + Toggle( + "Create Worktree By Default For Repo Sessions", + isOn: binding(\.defaultCreateWorktreeForRepoSessions) + ) + Text("When enabled, repo-backed sessions always create a worktree") + .font(.caption) + .foregroundStyle(.tertiary) + } - VStack(alignment: .leading, spacing: 4) { - Toggle( - "Cleanup On Close", - isOn: binding(\.cleanupOnClose) - ) - Text("When off, open tiles are restored next launch. When on, tile layouts are cleared on app close.") - .font(.caption) - .foregroundStyle(.tertiary) - } - } + Picker("Restore Behavior", selection: enumBinding(\.restoreBehavior)) { + ForEach(RestoreBehavior.allCases, id: \.self) { behavior in + Text(behavior.displayLabel).tag(behavior) + } + } + Text("What to restore when IDX0 relaunches") + .font(.caption) + .foregroundStyle(.tertiary) - if sessionService.settings.appMode.showsVibeFeatures { - Section("Vibe Tools") { - Picker( - "Default Vibe Tool", - selection: Binding( - get: { sessionService.settings.defaultVibeToolID ?? "none" }, - set: { value in sessionService.saveSettings { $0.defaultVibeToolID = value == "none" ? nil : value } } - ) - ) { - Text("None").tag("none") - ForEach(workflowService.vibeTools, id: \.id) { tool in - Text(tool.isInstalled ? tool.displayName : "\(tool.displayName) (Not Installed)") - .tag(tool.id) - } - } + VStack(alignment: .leading, spacing: 4) { + Toggle( + "Cleanup On Close", + isOn: binding(\.cleanupOnClose) + ) + Text("When off, open tiles are restored next launch. When on, tile layouts are cleared on app close.") + .font(.caption) + .foregroundStyle(.tertiary) + } + } - VStack(alignment: .leading, spacing: 4) { - Toggle( - "Cmd+N Auto-Launch Default Vibe Tool", - isOn: binding(\.autoLaunchDefaultVibeToolOnCmdN) - ) - Text("Automatically start the default vibe tool when creating a session with \u{2318}N") - .font(.caption) - .foregroundStyle(.tertiary) - } - } + if sessionService.settings.appMode.showsVibeFeatures { + Section("Vibe Tools") { + Picker( + "Default Vibe Tool", + selection: Binding( + get: { sessionService.settings.defaultVibeToolID ?? "none" }, + set: { value in sessionService.saveSettings { $0.defaultVibeToolID = value == "none" ? nil : value } } + ) + ) { + Text("None").tag("none") + ForEach(workflowService.vibeTools, id: \.id) { tool in + Text(tool.isInstalled ? tool.displayName : "\(tool.displayName) (Not Installed)") + .tag(tool.id) } + } + + VStack(alignment: .leading, spacing: 4) { + Toggle( + "Cmd+N Auto-Launch Default Vibe Tool", + isOn: binding(\.autoLaunchDefaultVibeToolOnCmdN) + ) + Text("Automatically start the default vibe tool when creating a session with \u{2318}N") + .font(.caption) + .foregroundStyle(.tertiary) + } } - .formStyle(.grouped) - .padding(10) + } } + .formStyle(.grouped) + .padding(10) + } - private func binding(_ keyPath: WritableKeyPath) -> Binding { - Binding( - get: { sessionService.settings[keyPath: keyPath] }, - set: { value in sessionService.saveSettings { $0[keyPath: keyPath] = value } } - ) - } + private func binding(_ keyPath: WritableKeyPath) -> Binding { + Binding( + get: { sessionService.settings[keyPath: keyPath] }, + set: { value in sessionService.saveSettings { $0[keyPath: keyPath] = value } } + ) + } - private func enumBinding(_ keyPath: WritableKeyPath) -> Binding { - Binding( - get: { sessionService.settings[keyPath: keyPath] }, - set: { value in sessionService.saveSettings { $0[keyPath: keyPath] = value } } - ) - } + private func enumBinding(_ keyPath: WritableKeyPath) -> Binding { + Binding( + get: { sessionService.settings[keyPath: keyPath] }, + set: { value in sessionService.saveSettings { $0[keyPath: keyPath] = value } } + ) + } } diff --git a/idx0/UI/Sheets/NewSessionSheet.swift b/idx0/UI/Sheets/NewSessionSheet.swift index b08364e..5977be7 100644 --- a/idx0/UI/Sheets/NewSessionSheet.swift +++ b/idx0/UI/Sheets/NewSessionSheet.swift @@ -2,461 +2,482 @@ import AppKit import SwiftUI struct NewSessionSheet: View { - @EnvironmentObject private var coordinator: AppCoordinator - @EnvironmentObject private var sessionService: SessionService - @EnvironmentObject private var workflowService: WorkflowService - - let preset: NewSessionPreset - - @State private var title = "" - @State private var folderPath = "" - @State private var createWorktree = false - @State private var branchName = "" - @State private var repoBranchMode: RepoBranchMode = .current - @State private var useExistingWorktree = false - @State private var existingWorktreePath = "" - @State private var shellPath = "" - @State private var launchMode: LaunchMode = .plainShell - @State private var selectedToolID = "" - @State private var sandboxProfile: SandboxProfile = .fullAccess - @State private var networkPolicy: NetworkPolicy = .inherited - @State private var isCreating = false - @State private var isCheckingRepo = false - @State private var folderIsGitRepo = false - @State private var showGitSection = false - @State private var showSafetySection = false - @State private var showVibeSection = false - @State private var showAdvanced = false - @State private var repoCheckToken = UUID() - @State private var errorMessage: String? - - private var showsVibeFeatures: Bool { - sessionService.settings.appMode.showsVibeFeatures - } - - var body: some View { - VStack(alignment: .leading, spacing: 14) { - Text("Create Session") - .font(.title3.weight(.semibold)) - - // MARK: - Tier 1: Always visible - - TextField("Session name (optional)", text: $title) - - VStack(alignment: .leading, spacing: 6) { - Text("Folder") + @EnvironmentObject private var coordinator: AppCoordinator + @EnvironmentObject private var sessionService: SessionService + @EnvironmentObject private var workflowService: WorkflowService + + let preset: NewSessionPreset + + @State private var title = "" + @State private var folderPath = "" + @State private var createWorktree = false + @State private var branchName = "" + @State private var repoBranchMode: RepoBranchMode = .current + @State private var useExistingWorktree = false + @State private var existingWorktreePath = "" + @State private var shellPath = "" + @State private var launchMode: LaunchMode = .plainShell + @State private var selectedToolID = "" + @State private var sandboxProfile: SandboxProfile = .fullAccess + @State private var networkPolicy: NetworkPolicy = .inherited + @State private var isCreating = false + @State private var isCheckingRepo = false + @State private var folderIsGitRepo = false + @State private var showGitSection = false + @State private var showSafetySection = false + @State private var showVibeSection = false + @State private var showAdvanced = false + @State private var repoCheckToken = UUID() + @State private var errorMessage: String? + + private var showsVibeFeatures: Bool { + sessionService.settings.appMode.showsVibeFeatures + } + + private var settingForcesWorktree: Bool { + sessionService.settings.defaultCreateWorktreeForRepoSessions && folderIsGitRepo + } + + private var effectiveCreateWorktree: Bool { + settingForcesWorktree || createWorktree + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Text("Create Session") + .font(.title3.weight(.semibold)) + + // MARK: - Tier 1: Always visible + + TextField("Session name (optional)", text: $title) + + VStack(alignment: .leading, spacing: 6) { + Text("Folder") + .font(.caption) + .foregroundStyle(.secondary) + HStack { + TextField("Optional project folder", text: $folderPath) + Button("Choose\u{2026}") { + chooseFolder() + } + } + } + + // MARK: - Git & Worktree Section (auto-revealed when git repo detected) + + if folderIsGitRepo || preset == .worktree { + sectionCard { + VStack(alignment: .leading, spacing: 10) { + sectionToggleHeader( + icon: "arrow.triangle.branch", + title: "Git & Worktree", + isExpanded: $showGitSection + ) + + if showGitSection { + Toggle("Create worktree", isOn: $createWorktree) + .disabled(settingForcesWorktree) + + if settingForcesWorktree { + Text("Worktree creation is enforced. Disable it in Settings > Sessions.") + .font(.caption) + .foregroundStyle(.secondary) + } + + if effectiveCreateWorktree { + if isCheckingRepo { + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text("Checking repository\u{2026}") + .font(.caption) + .foregroundStyle(.secondary) + } + } else if folderIsGitRepo { + Picker("Worktree mode", selection: $useExistingWorktree) { + Text("Create New").tag(false) + Text("Attach Existing").tag(true) + } + .pickerStyle(.segmented) + + if useExistingWorktree { + VStack(alignment: .leading, spacing: 6) { + Text("Existing worktree") + .font(.caption) + .foregroundStyle(.secondary) + HStack { + TextField("Worktree path", text: $existingWorktreePath) + Button("Choose\u{2026}") { chooseExistingWorktree() } + } + } + } else { + TextField("Branch name (optional)", text: $branchName) + } + } else { + Text("Select a Git repository folder to enable worktree options.") .font(.caption) .foregroundStyle(.secondary) - HStack { - TextField("Optional project folder", text: $folderPath) - Button("Choose\u{2026}") { - chooseFolder() - } } - } - - // MARK: - Git & Worktree Section (auto-revealed when git repo detected) - - if folderIsGitRepo || preset == .worktree { - sectionCard { - VStack(alignment: .leading, spacing: 10) { - sectionToggleHeader( - icon: "arrow.triangle.branch", - title: "Git & Worktree", - isExpanded: $showGitSection - ) - - if showGitSection { - Toggle("Create worktree", isOn: $createWorktree) - - if createWorktree { - if isCheckingRepo { - HStack(spacing: 8) { - ProgressView().controlSize(.small) - Text("Checking repository\u{2026}") - .font(.caption) - .foregroundStyle(.secondary) - } - } else if folderIsGitRepo { - Picker("Worktree mode", selection: $useExistingWorktree) { - Text("Create New").tag(false) - Text("Attach Existing").tag(true) - } - .pickerStyle(.segmented) - - if useExistingWorktree { - VStack(alignment: .leading, spacing: 6) { - Text("Existing worktree") - .font(.caption) - .foregroundStyle(.secondary) - HStack { - TextField("Worktree path", text: $existingWorktreePath) - Button("Choose\u{2026}") { chooseExistingWorktree() } - } - } - } else { - TextField("Branch name (optional)", text: $branchName) - } - } else { - Text("Select a Git repository folder to enable worktree options.") - .font(.caption) - .foregroundStyle(.secondary) - } - } - - if !createWorktree, folderIsGitRepo { - VStack(alignment: .leading, spacing: 6) { - Text("Branch mode") - .font(.caption) - .foregroundStyle(.secondary) - Picker("Branch mode", selection: $repoBranchMode) { - Text("Use Current").tag(RepoBranchMode.current) - Text("Set Manually").tag(RepoBranchMode.custom) - } - .pickerStyle(.segmented) - - if repoBranchMode == .custom { - TextField("Branch name", text: $branchName) - } - } - } - } - } - } - .animation(.easeInOut(duration: 0.2), value: showGitSection) - } - - // MARK: - Safety Section (collapsed by default) - - sectionCard { - VStack(alignment: .leading, spacing: 10) { - sectionToggleHeader( - icon: "shield", - title: "Safety", - isExpanded: $showSafetySection - ) - - if showSafetySection { - Picker("Sandbox profile", selection: $sandboxProfile) { - ForEach(SandboxProfile.allCases, id: \.self) { profile in - Text(profile.displayLabel).tag(profile) - } - } - - Picker("Network policy", selection: $networkPolicy) { - ForEach(NetworkPolicy.allCases, id: \.self) { policy in - Text(policy.displayLabel).tag(policy) - } - } - .pickerStyle(.segmented) - } - } - } - .animation(.easeInOut(duration: 0.2), value: showSafetySection) - - // MARK: - Vibe Tool Section (hidden in terminal mode) - - if showsVibeFeatures { - sectionCard { - VStack(alignment: .leading, spacing: 10) { - sectionToggleHeader( - icon: "wand.and.stars", - title: "Vibe Tool", - isExpanded: $showVibeSection - ) - - if showVibeSection { - Picker("Launch mode", selection: $launchMode) { - Text("Plain Shell").tag(LaunchMode.plainShell) - Text("Auto-Start Tool").tag(LaunchMode.autoTool) - } - .pickerStyle(.segmented) - - if launchMode == .autoTool { - Picker("Tool", selection: $selectedToolID) { - ForEach(workflowService.vibeTools, id: \.id) { tool in - Text(tool.isInstalled ? tool.displayName : "\(tool.displayName) (Not Installed)") - .tag(tool.id) - } - } - .disabled(workflowService.vibeTools.isEmpty) - } - } - } - } - .animation(.easeInOut(duration: 0.2), value: showVibeSection) - } + } - // MARK: - Advanced - - DisclosureGroup("Advanced", isExpanded: $showAdvanced) { - TextField("Shell path (optional)", text: $shellPath) - .textFieldStyle(.roundedBorder) - .padding(.top, 6) - } - - if let errorMessage { - Text(errorMessage) - .foregroundStyle(.red) + if !effectiveCreateWorktree, folderIsGitRepo { + VStack(alignment: .leading, spacing: 6) { + Text("Branch mode") .font(.caption) - } - - HStack { - Spacer() - Button("Cancel") { - coordinator.showingNewSessionSheet = false - } - .keyboardShortcut(.cancelAction) - - Button(isCreating ? "Creating\u{2026}" : "Create Session") { - createSession() + .foregroundStyle(.secondary) + Picker("Branch mode", selection: $repoBranchMode) { + Text("Use Current").tag(RepoBranchMode.current) + Text("Set Manually").tag(RepoBranchMode.custom) + } + .pickerStyle(.segmented) + + if repoBranchMode == .custom { + TextField("Branch name", text: $branchName) + } } - .keyboardShortcut(.defaultAction) - .disabled(isCreateDisabled) + } } + } } - .padding(18) - .onAppear { - configurePresetDefaults() - refreshRepoStatus() - workflowService.refreshVibeTools() - if selectedToolID.isEmpty { - selectedToolID = sessionService.settings.defaultVibeToolID ?? workflowService.vibeTools.first?.id ?? "" + .animation(.easeInOut(duration: 0.2), value: showGitSection) + } + + // MARK: - Safety Section (collapsed by default) + + sectionCard { + VStack(alignment: .leading, spacing: 10) { + sectionToggleHeader( + icon: "shield", + title: "Safety", + isExpanded: $showSafetySection + ) + + if showSafetySection { + Picker("Sandbox profile", selection: $sandboxProfile) { + ForEach(SandboxProfile.allCases, id: \.self) { profile in + Text(profile.displayLabel).tag(profile) + } } - } - .onChange(of: folderPath) { - refreshRepoStatus() - } - .onChange(of: folderIsGitRepo) { _, isRepo in - if isRepo { - showGitSection = true + + Picker("Network policy", selection: $networkPolicy) { + ForEach(NetworkPolicy.allCases, id: \.self) { policy in + Text(policy.displayLabel).tag(policy) + } } + .pickerStyle(.segmented) + } } - .onChange(of: createWorktree) { - if createWorktree { - refreshRepoStatus() - prefillBranchIfNeeded() - } else { - useExistingWorktree = false - existingWorktreePath = "" - if repoBranchMode == .current { - branchName = "" + } + .animation(.easeInOut(duration: 0.2), value: showSafetySection) + + // MARK: - Vibe Tool Section (hidden in terminal mode) + + if showsVibeFeatures { + sectionCard { + VStack(alignment: .leading, spacing: 10) { + sectionToggleHeader( + icon: "wand.and.stars", + title: "Vibe Tool", + isExpanded: $showVibeSection + ) + + if showVibeSection { + Picker("Launch mode", selection: $launchMode) { + Text("Plain Shell").tag(LaunchMode.plainShell) + Text("Auto-Start Tool").tag(LaunchMode.autoTool) + } + .pickerStyle(.segmented) + + if launchMode == .autoTool { + Picker("Tool", selection: $selectedToolID) { + ForEach(workflowService.vibeTools, id: \.id) { tool in + Text(tool.isInstalled ? tool.displayName : "\(tool.displayName) (Not Installed)") + .tag(tool.id) + } } + .disabled(workflowService.vibeTools.isEmpty) + } } + } } - .onChange(of: useExistingWorktree) { - if useExistingWorktree { - branchName = "" - } else { - prefillBranchIfNeeded() - } + .animation(.easeInOut(duration: 0.2), value: showVibeSection) + } + + // MARK: - Advanced + + DisclosureGroup("Advanced", isExpanded: $showAdvanced) { + TextField("Shell path (optional)", text: $shellPath) + .textFieldStyle(.roundedBorder) + .padding(.top, 6) + } + + if let errorMessage { + Text(errorMessage) + .foregroundStyle(.red) + .font(.caption) + } + + HStack { + Spacer() + Button("Cancel") { + coordinator.showingNewSessionSheet = false } - .onChange(of: repoBranchMode) { - if repoBranchMode == .current && !createWorktree { - branchName = "" - } + .keyboardShortcut(.cancelAction) + + Button(isCreating ? "Creating\u{2026}" : "Create Session") { + createSession() } + .keyboardShortcut(.defaultAction) + .disabled(isCreateDisabled) + } } - - // MARK: - Section Card Components - - @ViewBuilder - private func sectionCard(@ViewBuilder content: () -> Content) -> some View { - content() - .padding(12) - .background(.white.opacity(0.03), in: RoundedRectangle(cornerRadius: 8)) + .padding(18) + .onAppear { + configurePresetDefaults() + refreshRepoStatus() + workflowService.refreshVibeTools() + if selectedToolID.isEmpty { + selectedToolID = sessionService.settings.defaultVibeToolID ?? workflowService.vibeTools.first?.id ?? "" + } } - - @ViewBuilder - private func sectionToggleHeader(icon: String, title: String, isExpanded: Binding) -> some View { - Button { - withAnimation(.easeInOut(duration: 0.2)) { - isExpanded.wrappedValue.toggle() - } - } label: { - HStack(spacing: 6) { - Image(systemName: icon) - .font(.system(size: 10, weight: .medium)) - .foregroundStyle(.white.opacity(0.4)) - .frame(width: 16) - - Text(title) - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(.white.opacity(0.7)) - - Spacer() - - Image(systemName: isExpanded.wrappedValue ? "chevron.up" : "chevron.down") - .font(.system(size: 8, weight: .semibold)) - .foregroundStyle(.white.opacity(0.3)) - } - } - .buttonStyle(.plain) + .onChange(of: folderPath) { + refreshRepoStatus() } - - // MARK: - Logic - - private var isCreateDisabled: Bool { - if isCreating || isCheckingRepo { - return true - } - if createWorktree && !folderIsGitRepo { - return true - } - if createWorktree && useExistingWorktree && existingWorktreePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - return true - } - if !createWorktree && - folderIsGitRepo && - repoBranchMode == .custom && - branchName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - return true + .onChange(of: folderIsGitRepo) { _, isRepo in + if isRepo { + showGitSection = true + if sessionService.settings.defaultCreateWorktreeForRepoSessions { + createWorktree = true } - if launchMode == .autoTool { - guard !selectedToolID.isEmpty else { return true } - let installed = workflowService.vibeTools.first(where: { $0.id == selectedToolID })?.isInstalled ?? false - if !installed { return true } - } - return false + } } - - private func configurePresetDefaults() { - createWorktree = sessionService.settings.defaultCreateWorktreeForRepoSessions - sandboxProfile = sessionService.settings.defaultSandboxProfile - networkPolicy = sessionService.settings.defaultNetworkPolicy - if preset == .quick { - folderPath = "" - createWorktree = false - } else if preset == .repo { - createWorktree = false - } else if preset == .worktree { - createWorktree = true - showGitSection = true + .onChange(of: createWorktree) { + if createWorktree { + refreshRepoStatus() + prefillBranchIfNeeded() + } else { + useExistingWorktree = false + existingWorktreePath = "" + if repoBranchMode == .current { + branchName = "" } + } } - - private func chooseFolder() { - let panel = NSOpenPanel() - panel.canChooseDirectories = true - panel.canChooseFiles = false - panel.allowsMultipleSelection = false - panel.canCreateDirectories = false - if panel.runModal() == .OK { - folderPath = panel.url?.path ?? "" - prefillBranchIfNeeded() - } + .onChange(of: useExistingWorktree) { + if useExistingWorktree { + branchName = "" + } else { + prefillBranchIfNeeded() + } } - - private func chooseExistingWorktree() { - let panel = NSOpenPanel() - panel.canChooseDirectories = true - panel.canChooseFiles = false - panel.allowsMultipleSelection = false - panel.canCreateDirectories = false - if panel.runModal() == .OK { - existingWorktreePath = panel.url?.path ?? "" - } + .onChange(of: repoBranchMode) { + if repoBranchMode == .current, !createWorktree { + branchName = "" + } } - - private func prefillBranchIfNeeded() { - guard createWorktree, folderIsGitRepo, !useExistingWorktree else { return } - guard branchName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } - let repoName = URL(fileURLWithPath: folderPath).lastPathComponent - branchName = BranchNameGenerator.generate( - sessionTitle: title.isEmpty ? nil : title, - repoName: repoName - ) + } + + // MARK: - Section Card Components + + private func sectionCard(@ViewBuilder content: () -> some View) -> some View { + content() + .padding(12) + .background(.white.opacity(0.03), in: RoundedRectangle(cornerRadius: 8)) + } + + private func sectionToggleHeader(icon: String, title: String, isExpanded: Binding) -> some View { + Button { + withAnimation(.easeInOut(duration: 0.2)) { + isExpanded.wrappedValue.toggle() + } + } label: { + HStack(spacing: 6) { + Image(systemName: icon) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.white.opacity(0.4)) + .frame(width: 16) + + Text(title) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.white.opacity(0.7)) + + Spacer() + + Image(systemName: isExpanded.wrappedValue ? "chevron.up" : "chevron.down") + .font(.system(size: 8, weight: .semibold)) + .foregroundStyle(.white.opacity(0.3)) + } } + .buttonStyle(.plain) + } - private func refreshRepoStatus() { - let cleanedFolder = folderPath.trimmingCharacters(in: .whitespacesAndNewlines) - let token = UUID() - repoCheckToken = token - - guard !cleanedFolder.isEmpty else { - isCheckingRepo = false - folderIsGitRepo = false - repoBranchMode = .current - branchName = "" - useExistingWorktree = false - existingWorktreePath = "" - return - } + // MARK: - Logic - isCheckingRepo = true - Task { - let isRepo = await sessionService.isGitRepository(path: cleanedFolder) - await MainActor.run { - guard repoCheckToken == token else { return } - isCheckingRepo = false - folderIsGitRepo = isRepo - if !isRepo { - repoBranchMode = .current - branchName = "" - useExistingWorktree = false - existingWorktreePath = "" - } else { - prefillBranchIfNeeded() - } - } - } + private var isCreateDisabled: Bool { + if isCreating || isCheckingRepo { + return true + } + if effectiveCreateWorktree, !folderIsGitRepo { + return true + } + if effectiveCreateWorktree, useExistingWorktree, existingWorktreePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return true + } + if !effectiveCreateWorktree, + folderIsGitRepo, + repoBranchMode == .custom, + branchName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + return true + } + if launchMode == .autoTool { + guard !selectedToolID.isEmpty else { return true } + let installed = workflowService.vibeTools.first(where: { $0.id == selectedToolID })?.isInstalled ?? false + if !installed { return true } + } + return false + } + + private func configurePresetDefaults() { + createWorktree = sessionService.settings.defaultCreateWorktreeForRepoSessions + sandboxProfile = sessionService.settings.defaultSandboxProfile + networkPolicy = sessionService.settings.defaultNetworkPolicy + if preset == .quick { + folderPath = "" + createWorktree = false + } else if preset == .worktree { + createWorktree = true + showGitSection = true + } + } + + private func chooseFolder() { + let panel = NSOpenPanel() + panel.canChooseDirectories = true + panel.canChooseFiles = false + panel.allowsMultipleSelection = false + panel.canCreateDirectories = false + if panel.runModal() == .OK { + folderPath = panel.url?.path ?? "" + prefillBranchIfNeeded() + } + } + + private func chooseExistingWorktree() { + let panel = NSOpenPanel() + panel.canChooseDirectories = true + panel.canChooseFiles = false + panel.allowsMultipleSelection = false + panel.canCreateDirectories = false + if panel.runModal() == .OK { + existingWorktreePath = panel.url?.path ?? "" + } + } + + private func prefillBranchIfNeeded() { + guard effectiveCreateWorktree, folderIsGitRepo, !useExistingWorktree else { return } + guard branchName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + let repoName = URL(fileURLWithPath: folderPath).lastPathComponent + branchName = BranchNameGenerator.generate( + sessionTitle: title.isEmpty ? nil : title, + repoName: repoName + ) + } + + private func refreshRepoStatus() { + let cleanedFolder = folderPath.trimmingCharacters(in: .whitespacesAndNewlines) + let token = UUID() + repoCheckToken = token + + guard !cleanedFolder.isEmpty else { + isCheckingRepo = false + folderIsGitRepo = false + repoBranchMode = .current + branchName = "" + useExistingWorktree = false + existingWorktreePath = "" + return } - private func createSession() { - errorMessage = nil - isCreating = true + isCheckingRepo = true + Task { + let isRepo = await sessionService.isGitRepository(path: cleanedFolder) + await MainActor.run { + guard repoCheckToken == token else { return } + isCheckingRepo = false + folderIsGitRepo = isRepo + if !isRepo { + repoBranchMode = .current + branchName = "" + useExistingWorktree = false + existingWorktreePath = "" + if sessionService.settings.defaultCreateWorktreeForRepoSessions { + createWorktree = false + } + } else { + if sessionService.settings.defaultCreateWorktreeForRepoSessions { + createWorktree = true + } + prefillBranchIfNeeded() + } + } + } + } + + private func createSession() { + errorMessage = nil + isCreating = true + + Task { + do { + let created = try await sessionService.createSession( + from: SessionCreationRequest( + title: title, + repoPath: folderPath, + createWorktree: effectiveCreateWorktree, + branchName: resolvedBranchName, + existingWorktreePath: useExistingWorktree ? existingWorktreePath : nil, + shellPath: shellPath, + sandboxProfile: sandboxProfile, + networkPolicy: networkPolicy, + launchToolID: launchMode == .autoTool ? selectedToolID : nil + ) + ) - Task { + await MainActor.run { + if launchMode == .autoTool { do { - let created = try await sessionService.createSession( - from: SessionCreationRequest( - title: title, - repoPath: folderPath, - createWorktree: createWorktree, - branchName: resolvedBranchName, - existingWorktreePath: useExistingWorktree ? existingWorktreePath : nil, - shellPath: shellPath, - sandboxProfile: sandboxProfile, - networkPolicy: networkPolicy, - launchToolID: launchMode == .autoTool ? selectedToolID : nil - ) - ) - - await MainActor.run { - if launchMode == .autoTool { - do { - try workflowService.launchTool(selectedToolID, in: created.session.id) - } catch { - sessionService.postStatusMessage(error.localizedDescription, for: created.session.id) - } - } - isCreating = false - coordinator.showingNewSessionSheet = false - } + try workflowService.launchTool(selectedToolID, in: created.session.id) } catch { - await MainActor.run { - isCreating = false - errorMessage = error.localizedDescription - } + sessionService.postStatusMessage(error.localizedDescription, for: created.session.id) } + } + isCreating = false + coordinator.showingNewSessionSheet = false } + } catch { + await MainActor.run { + isCreating = false + errorMessage = error.localizedDescription + } + } } - - private var resolvedBranchName: String? { - let cleaned = branchName.trimmingCharacters(in: .whitespacesAndNewlines) - guard !cleaned.isEmpty else { return nil } - if createWorktree { return cleaned } - if folderIsGitRepo && repoBranchMode == .custom { return cleaned } - return nil - } + } + + private var resolvedBranchName: String? { + let cleaned = branchName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !cleaned.isEmpty else { return nil } + if effectiveCreateWorktree { return cleaned } + if folderIsGitRepo, repoBranchMode == .custom { return cleaned } + return nil + } } private enum LaunchMode: Hashable { - case plainShell - case autoTool + case plainShell + case autoTool } private enum RepoBranchMode: Hashable { - case current - case custom + case current + case custom } diff --git a/idx0Tests/SessionServiceIntegrationTests.swift b/idx0Tests/SessionServiceIntegrationTests.swift index 95d27e2..8d5c8d3 100644 --- a/idx0Tests/SessionServiceIntegrationTests.swift +++ b/idx0Tests/SessionServiceIntegrationTests.swift @@ -1,367 +1,403 @@ import Darwin import Foundation -import XCTest @testable import idx0 +import XCTest @MainActor final class SessionServiceIntegrationTests: XCTestCase { - func testCreateRepoBackedSessionFromGitRepo() async throws { - let root = try makeTempRoot(prefix: "idx0-integration-repo") - defer { try? FileManager.default.removeItem(at: root) } - - let repo = try makeGitRepo(root: root, name: "repo") - let branch = try currentBranch(at: repo.path) - let service = try makeService(root: root) - - let result = try await service.createSession(from: SessionCreationRequest( - title: "Repo Session", - repoPath: repo.path, - createWorktree: false, - branchName: nil, - existingWorktreePath: nil, - shellPath: nil - )) - - XCTAssertNil(result.worktree) - XCTAssertEqual(canonicalPath(result.session.repoPath), canonicalPath(repo.path)) - XCTAssertEqual(result.session.branchName, branch) - XCTAssertFalse(result.session.isWorktreeBacked) - XCTAssertNil(result.session.worktreePath) - - try await Task.sleep(nanoseconds: 300_000_000) + func testCreateRepoBackedSessionWithoutWorktreeWhenSettingDisabled() async throws { + let root = try makeTempRoot(prefix: "idx0-integration-repo") + defer { try? FileManager.default.removeItem(at: root) } + + let repo = try makeGitRepo(root: root, name: "repo") + let branch = try currentBranch(at: repo.path) + let service = try makeService(root: root) + service.saveSettings { $0.defaultCreateWorktreeForRepoSessions = false } + + let result = try await service.createSession(from: SessionCreationRequest( + title: "Repo Session", + repoPath: repo.path, + createWorktree: false, + branchName: nil, + existingWorktreePath: nil, + shellPath: nil + )) + + XCTAssertNil(result.worktree) + XCTAssertEqual(canonicalPath(result.session.repoPath), canonicalPath(repo.path)) + XCTAssertEqual(result.session.branchName, branch) + XCTAssertFalse(result.session.isWorktreeBacked) + XCTAssertNil(result.session.worktreePath) + + try await Task.sleep(nanoseconds: 300_000_000) + } + + func testCreateRepoBackedSessionCreatesWorktreeByDefaultWhenSettingEnabled() async throws { + let root = try makeTempRoot(prefix: "idx0-integration-repo-default-worktree") + defer { try? FileManager.default.removeItem(at: root) } + + let repo = try makeGitRepo(root: root, name: "repo") + let service = try makeService(root: root) + service.saveSettings { $0.defaultCreateWorktreeForRepoSessions = true } + + let result = try await service.createSession(from: SessionCreationRequest( + title: "Repo Session", + repoPath: repo.path, + createWorktree: false, + branchName: nil, + existingWorktreePath: nil, + shellPath: nil + )) + + XCTAssertTrue(result.session.isWorktreeBacked) + XCTAssertNotNil(result.session.worktreePath) + XCTAssertNotNil(result.worktree) + if let worktreePath = result.session.worktreePath { + XCTAssertTrue(worktreePath.hasPrefix(repo.path + "/.idx0/worktrees/")) } - - func testCreateWorktreeBackedSessionFromGitRepo() async throws { - let root = try makeTempRoot(prefix: "idx0-integration-worktree") - defer { try? FileManager.default.removeItem(at: root) } - - let repo = try makeGitRepo(root: root, name: "repo") - let service = try makeService(root: root) - let branch = "idx0/integration-\(UUID().uuidString.prefix(8))" - - let result = try await service.createSession(from: SessionCreationRequest( - title: "Worktree Session", - repoPath: repo.path, - createWorktree: true, - branchName: branch, - existingWorktreePath: nil, - shellPath: nil - )) - - guard let worktreePath = result.session.worktreePath else { - XCTFail("Expected worktree path") - return - } - - XCTAssertTrue(result.session.isWorktreeBacked) - XCTAssertEqual(result.session.branchName, branch) - XCTAssertEqual(result.worktree?.branchName, branch) - XCTAssertEqual(result.worktree?.worktreePath, worktreePath) - - var isDirectory: ObjCBool = false - XCTAssertTrue(FileManager.default.fileExists(atPath: worktreePath, isDirectory: &isDirectory)) - XCTAssertTrue(isDirectory.boolValue) - - let worktreeList = try runGit(["worktree", "list", "--porcelain"], currentDirectory: repo.path) - XCTAssertTrue(worktreeList.contains(worktreePath)) - - try await Task.sleep(nanoseconds: 300_000_000) + } + + func testCreateWorktreeBackedSessionFromGitRepo() async throws { + let root = try makeTempRoot(prefix: "idx0-integration-worktree") + defer { try? FileManager.default.removeItem(at: root) } + + let repo = try makeGitRepo(root: root, name: "repo") + let service = try makeService(root: root) + let branch = "idx0/integration-\(UUID().uuidString.prefix(8))" + + let result = try await service.createSession(from: SessionCreationRequest( + title: "Worktree Session", + repoPath: repo.path, + createWorktree: true, + branchName: branch, + existingWorktreePath: nil, + shellPath: nil + )) + + guard let worktreePath = result.session.worktreePath else { + XCTFail("Expected worktree path") + return } - func testAttachExistingWorktreeSessionFromGitRepo() async throws { - let root = try makeTempRoot(prefix: "idx0-integration-attach-worktree") - defer { try? FileManager.default.removeItem(at: root) } - - let repo = try makeGitRepo(root: root, name: "repo") - let service = try makeService(root: root) - let branch = "idx0/attach-\(UUID().uuidString.prefix(8))" - let existingWorktreePath = root - .appendingPathComponent("attached-\(UUID().uuidString.prefix(8))", isDirectory: true) - .path - - _ = try runGit( - ["worktree", "add", existingWorktreePath, "-b", branch], - currentDirectory: repo.path - ) - - let result = try await service.createSession(from: SessionCreationRequest( - title: "Attach Existing", - repoPath: repo.path, - createWorktree: true, - branchName: nil, - existingWorktreePath: existingWorktreePath, - shellPath: nil - )) - - XCTAssertTrue(result.session.isWorktreeBacked) - XCTAssertEqual(canonicalPath(result.session.repoPath), canonicalPath(repo.path)) - XCTAssertEqual(canonicalPath(result.session.worktreePath), canonicalPath(existingWorktreePath)) - XCTAssertEqual(result.session.branchName, branch) - XCTAssertEqual(canonicalPath(result.worktree?.worktreePath), canonicalPath(existingWorktreePath)) + XCTAssertTrue(result.session.isWorktreeBacked) + XCTAssertEqual(result.session.branchName, branch) + XCTAssertEqual(result.worktree?.branchName, branch) + XCTAssertEqual(result.worktree?.worktreePath, worktreePath) + XCTAssertTrue(worktreePath.hasPrefix(repo.path + "/.idx0/worktrees/")) + + let worktreeName = URL(fileURLWithPath: worktreePath).lastPathComponent + let pattern = #"^wt-[a-z0-9]{12}(?:-[0-9]+)?$"# + let regex = try NSRegularExpression(pattern: pattern) + let range = NSRange(location: 0, length: worktreeName.utf16.count) + XCTAssertNotNil(regex.firstMatch(in: worktreeName, options: [], range: range)) + + var isDirectory: ObjCBool = false + XCTAssertTrue(FileManager.default.fileExists(atPath: worktreePath, isDirectory: &isDirectory)) + XCTAssertTrue(isDirectory.boolValue) + + let worktreeList = try runGit(["worktree", "list", "--porcelain"], currentDirectory: repo.path) + XCTAssertTrue(worktreeList.contains(worktreePath)) + let status = try runGit(["status", "--short"], currentDirectory: repo.path) + XCTAssertFalse(status.contains(".idx0/")) + XCTAssertTrue(status.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + + try await Task.sleep(nanoseconds: 300_000_000) + } + + func testAttachExistingWorktreeSessionFromGitRepo() async throws { + let root = try makeTempRoot(prefix: "idx0-integration-attach-worktree") + defer { try? FileManager.default.removeItem(at: root) } + + let repo = try makeGitRepo(root: root, name: "repo") + let service = try makeService(root: root) + let branch = "idx0/attach-\(UUID().uuidString.prefix(8))" + let existingWorktreePath = root + .appendingPathComponent("attached-\(UUID().uuidString.prefix(8))", isDirectory: true) + .path + + _ = try runGit( + ["worktree", "add", existingWorktreePath, "-b", branch], + currentDirectory: repo.path + ) + + let result = try await service.createSession(from: SessionCreationRequest( + title: "Attach Existing", + repoPath: repo.path, + createWorktree: true, + branchName: nil, + existingWorktreePath: existingWorktreePath, + shellPath: nil + )) + + XCTAssertTrue(result.session.isWorktreeBacked) + XCTAssertEqual(canonicalPath(result.session.repoPath), canonicalPath(repo.path)) + XCTAssertEqual(canonicalPath(result.session.worktreePath), canonicalPath(existingWorktreePath)) + XCTAssertEqual(result.session.branchName, branch) + XCTAssertEqual(canonicalPath(result.worktree?.worktreePath), canonicalPath(existingWorktreePath)) + } + + func testRestoresPersistedSessionsAndSelection() async throws { + let root = try makeTempRoot(prefix: "idx0-integration-restore") + defer { try? FileManager.default.removeItem(at: root) } + + let service = try makeService(root: root) + + let first = try await service.createSession(from: SessionCreationRequest( + title: "First", + repoPath: nil, + createWorktree: false, + branchName: nil, + existingWorktreePath: nil, + shellPath: nil + )).session + + _ = try await service.createSession(from: SessionCreationRequest( + title: "Second", + repoPath: nil, + createWorktree: false, + branchName: nil, + existingWorktreePath: nil, + shellPath: nil + )).session + + service.selectSession(first.id) + + // Session writes are debounced in SessionService. + try await Task.sleep(nanoseconds: 500_000_000) + + let restored = try makeService(root: root) + XCTAssertEqual(restored.sessions.count, 2) + XCTAssertEqual(restored.selectedSessionID, first.id) + } + + func testInboxItemCreationAndResolution() async throws { + let root = try makeTempRoot(prefix: "idx0-integration-inbox") + defer { try? FileManager.default.removeItem(at: root) } + + let service = try makeService(root: root) + let first = try await service.createSession(from: SessionCreationRequest(title: "First")).session + _ = try await service.createSession(from: SessionCreationRequest(title: "Second")).session + + service.injectAttention(sessionID: first.id, reason: .needsInput, message: "Review needed") + XCTAssertEqual(service.unresolvedAttentionItems.count, 1) + XCTAssertEqual(service.unresolvedAttentionItems.first?.sessionID, first.id) + + service.selectSession(first.id) + XCTAssertTrue(service.unresolvedAttentionItems.isEmpty) + } + + func testBrowserPaneStateReloadAndControllerCreation() async throws { + let root = try makeTempRoot(prefix: "idx0-integration-browser") + defer { try? FileManager.default.removeItem(at: root) } + + let service = try makeService(root: root) + let session = try await service.createSession(from: SessionCreationRequest(title: "Browser")).session + service.toggleBrowserSplit(for: session.id) + service.setBrowserURL(for: session.id, urlString: "https://example.com") + + try await Task.sleep(nanoseconds: 500_000_000) + + let restored = try makeService(root: root) + let restoredSession = restored.sessions.first(where: { $0.id == session.id }) + XCTAssertEqual(restoredSession?.browserState?.isVisible, true) + XCTAssertEqual(URL(string: restoredSession?.browserState?.currentURL ?? "")?.host, "example.com") + XCTAssertNotNil(restored.controller(for: session.id)) + XCTAssertNotNil(restored.browserController(for: session.id)) + } + + func testIPCServerRequestResponseRoundTrip() throws { + let socketPath = shortSocketPath() + defer { unlink(socketPath) } + + let server = IPCServer(socketPath: socketPath) { request in + IPCResponse( + success: request.command == "ping", + message: request.command == "ping" ? "pong" : "bad command", + data: ["echo": request.payload["value"] ?? ""] + ) } - - func testRestoresPersistedSessionsAndSelection() async throws { - let root = try makeTempRoot(prefix: "idx0-integration-restore") - defer { try? FileManager.default.removeItem(at: root) } - - let service = try makeService(root: root) - - let first = try await service.createSession(from: SessionCreationRequest( - title: "First", - repoPath: nil, - createWorktree: false, - branchName: nil, - existingWorktreePath: nil, - shellPath: nil - )).session - - _ = try await service.createSession(from: SessionCreationRequest( - title: "Second", - repoPath: nil, - createWorktree: false, - branchName: nil, - existingWorktreePath: nil, - shellPath: nil - )).session - - service.selectSession(first.id) - - // Session writes are debounced in SessionService. - try await Task.sleep(nanoseconds: 500_000_000) - - let restored = try makeService(root: root) - XCTAssertEqual(restored.sessions.count, 2) - XCTAssertEqual(restored.selectedSessionID, first.id) + server.start() + defer { server.stop() } + + waitForSocket(path: socketPath, timeout: 2.0) + + let response = try sendIPCRequest( + socketPath: socketPath, + request: IPCRequest(command: "ping", payload: ["value": "hello"]) + ) + XCTAssertTrue(response.success) + XCTAssertEqual(response.message, "pong") + XCTAssertEqual(response.data?["echo"], "hello") + } + + private func shortSocketPath() -> String { + let suffix = UUID().uuidString.prefix(8) + return "/tmp/idx0-\(suffix).sock" + } + + private func makeService(root: URL) throws -> SessionService { + let paths = try makePaths(root: root) + let gitService = GitService() + let worktreeService = WorktreeService(gitService: gitService, paths: paths) + return SessionService( + sessionStore: SessionStore(url: paths.sessionsFile), + projectStore: ProjectStore(url: paths.projectsFile), + inboxStore: InboxStore(url: paths.inboxFile), + settingsStore: SettingsStore(url: paths.settingsFile), + worktreeService: worktreeService, + host: .shared + ) + } + + private func makePaths(root: URL) throws -> FileSystemPaths { + let appSupport = root.appendingPathComponent("AppSupport", isDirectory: true) + let paths = FileSystemPaths( + appSupportDirectory: appSupport, + sessionsFile: appSupport.appendingPathComponent("sessions.json"), + projectsFile: appSupport.appendingPathComponent("projects.json"), + inboxFile: appSupport.appendingPathComponent("inbox.json"), + settingsFile: appSupport.appendingPathComponent("settings.json"), + runDirectory: appSupport.appendingPathComponent("run", isDirectory: true), + tempDirectory: appSupport.appendingPathComponent("temp", isDirectory: true), + worktreesDirectory: appSupport.appendingPathComponent("worktrees", isDirectory: true) + ) + try paths.ensureDirectories() + return paths + } + + private func makeTempRoot(prefix: String) throws -> URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("\(prefix)-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return root + } + + private func waitForSocket(path: String, timeout: TimeInterval) { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if FileManager.default.fileExists(atPath: path) { + return + } + Thread.sleep(forTimeInterval: 0.02) } + XCTFail("Socket was not created in time: \(path)") + } - func testInboxItemCreationAndResolution() async throws { - let root = try makeTempRoot(prefix: "idx0-integration-inbox") - defer { try? FileManager.default.removeItem(at: root) } - - let service = try makeService(root: root) - let first = try await service.createSession(from: SessionCreationRequest(title: "First")).session - _ = try await service.createSession(from: SessionCreationRequest(title: "Second")).session - - service.injectAttention(sessionID: first.id, reason: .needsInput, message: "Review needed") - XCTAssertEqual(service.unresolvedAttentionItems.count, 1) - XCTAssertEqual(service.unresolvedAttentionItems.first?.sessionID, first.id) - - service.selectSession(first.id) - XCTAssertTrue(service.unresolvedAttentionItems.isEmpty) + private func sendIPCRequest(socketPath: String, request: IPCRequest) throws -> IPCResponse { + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { + throw NSError(domain: "IPC", code: 1, userInfo: [NSLocalizedDescriptionKey: "socket() failed"]) } - - func testBrowserPaneStateReloadAndControllerCreation() async throws { - let root = try makeTempRoot(prefix: "idx0-integration-browser") - defer { try? FileManager.default.removeItem(at: root) } - - let service = try makeService(root: root) - let session = try await service.createSession(from: SessionCreationRequest(title: "Browser")).session - service.toggleBrowserSplit(for: session.id) - service.setBrowserURL(for: session.id, urlString: "https://example.com") - - try await Task.sleep(nanoseconds: 500_000_000) - - let restored = try makeService(root: root) - let restoredSession = restored.sessions.first(where: { $0.id == session.id }) - XCTAssertEqual(restoredSession?.browserState?.isVisible, true) - XCTAssertEqual(URL(string: restoredSession?.browserState?.currentURL ?? "")?.host, "example.com") - XCTAssertNotNil(restored.controller(for: session.id)) - XCTAssertNotNil(restored.browserController(for: session.id)) + defer { close(fd) } + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + let bytes = Array(socketPath.utf8) + let maxPathLength = MemoryLayout.size(ofValue: addr.sun_path) + guard bytes.count < maxPathLength else { + throw NSError(domain: "IPC", code: 2, userInfo: [NSLocalizedDescriptionKey: "Socket path too long"]) } - func testIPCServerRequestResponseRoundTrip() throws { - let socketPath = shortSocketPath() - defer { unlink(socketPath) } - - let server = IPCServer(socketPath: socketPath) { request in - IPCResponse( - success: request.command == "ping", - message: request.command == "ping" ? "pong" : "bad command", - data: ["echo": request.payload["value"] ?? ""] - ) - } - server.start() - defer { server.stop() } - - waitForSocket(path: socketPath, timeout: 2.0) - - let response = try sendIPCRequest( - socketPath: socketPath, - request: IPCRequest(command: "ping", payload: ["value": "hello"]) - ) - XCTAssertTrue(response.success) - XCTAssertEqual(response.message, "pong") - XCTAssertEqual(response.data?["echo"], "hello") + withUnsafeMutablePointer(to: &addr.sun_path) { ptr in + let raw = UnsafeMutableRawPointer(ptr).assumingMemoryBound(to: CChar.self) + raw.initialize(repeating: 0, count: maxPathLength) + for index in bytes.indices { + raw[index] = CChar(bitPattern: bytes[index]) + } } - private func shortSocketPath() -> String { - let suffix = UUID().uuidString.prefix(8) - return "/tmp/idx0-\(suffix).sock" + let len = socklen_t(MemoryLayout.size + bytes.count + 1) + let connectResult = withUnsafePointer(to: &addr) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + connect(fd, $0, len) + } } - - private func makeService(root: URL) throws -> SessionService { - let paths = try makePaths(root: root) - let gitService = GitService() - let worktreeService = WorktreeService(gitService: gitService, paths: paths) - return SessionService( - sessionStore: SessionStore(url: paths.sessionsFile), - projectStore: ProjectStore(url: paths.projectsFile), - inboxStore: InboxStore(url: paths.inboxFile), - settingsStore: SettingsStore(url: paths.settingsFile), - worktreeService: worktreeService, - host: .shared - ) + guard connectResult == 0 else { + throw NSError(domain: "IPC", code: 3, userInfo: [NSLocalizedDescriptionKey: "connect() failed"]) } - private func makePaths(root: URL) throws -> FileSystemPaths { - let appSupport = root.appendingPathComponent("AppSupport", isDirectory: true) - let paths = FileSystemPaths( - appSupportDirectory: appSupport, - sessionsFile: appSupport.appendingPathComponent("sessions.json"), - projectsFile: appSupport.appendingPathComponent("projects.json"), - inboxFile: appSupport.appendingPathComponent("inbox.json"), - settingsFile: appSupport.appendingPathComponent("settings.json"), - runDirectory: appSupport.appendingPathComponent("run", isDirectory: true), - tempDirectory: appSupport.appendingPathComponent("temp", isDirectory: true), - worktreesDirectory: appSupport.appendingPathComponent("worktrees", isDirectory: true) - ) - try paths.ensureDirectories() - return paths + let requestData = try JSONEncoder().encode(request) + _ = requestData.withUnsafeBytes { bytes in + write(fd, bytes.baseAddress, bytes.count) } - - private func makeTempRoot(prefix: String) throws -> URL { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("\(prefix)-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - return root + shutdown(fd, SHUT_WR) + + var responseData = Data() + var buffer = [UInt8](repeating: 0, count: 4096) + while true { + let count = read(fd, &buffer, buffer.count) + if count > 0 { + responseData.append(contentsOf: buffer.prefix(Int(count))) + } else { + break + } } - private func waitForSocket(path: String, timeout: TimeInterval) { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if FileManager.default.fileExists(atPath: path) { - return - } - Thread.sleep(forTimeInterval: 0.02) - } - XCTFail("Socket was not created in time: \(path)") + return try JSONDecoder().decode(IPCResponse.self, from: responseData) + } + + private func makeGitRepo(root: URL, name: String) throws -> URL { + let repo = root.appendingPathComponent(name, isDirectory: true) + try FileManager.default.createDirectory(at: repo, withIntermediateDirectories: true) + + _ = try runGit(["init", "-q"], currentDirectory: repo.path) + _ = try runGit(["config", "user.email", "idx0-tests@example.com"], currentDirectory: repo.path) + _ = try runGit(["config", "user.name", "idx0 tests"], currentDirectory: repo.path) + + let readme = repo.appendingPathComponent("README.md") + try "integration test\n".data(using: .utf8)?.write(to: readme) + + _ = try runGit(["add", "README.md"], currentDirectory: repo.path) + _ = try runGit(["commit", "-q", "-m", "initial"], currentDirectory: repo.path) + return repo + } + + private func currentBranch(at repoPath: String) throws -> String? { + let branch = try runGit(["branch", "--show-current"], currentDirectory: repoPath) + .trimmingCharacters(in: .whitespacesAndNewlines) + return branch.isEmpty ? nil : branch + } + + private func canonicalPath(_ path: String?) -> String? { + guard let path else { return nil } + return URL(fileURLWithPath: path) + .resolvingSymlinksInPath() + .standardizedFileURL + .path + } + + @discardableResult + private func runGit(_ arguments: [String], currentDirectory: String) throws -> String { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/git") + process.arguments = arguments + process.currentDirectoryURL = URL(fileURLWithPath: currentDirectory) + + let stdout = Pipe() + let stderr = Pipe() + process.standardOutput = stdout + process.standardError = stderr + + try process.run() + process.waitUntilExit() + + let stdoutString = String( + data: stdout.fileHandleForReading.readDataToEndOfFile(), + encoding: .utf8 + ) ?? "" + let stderrString = String( + data: stderr.fileHandleForReading.readDataToEndOfFile(), + encoding: .utf8 + ) ?? "" + + guard process.terminationStatus == 0 else { + throw NSError( + domain: "SessionServiceIntegrationTests", + code: Int(process.terminationStatus), + userInfo: [NSLocalizedDescriptionKey: stderrString.isEmpty ? stdoutString : stderrString] + ) } - private func sendIPCRequest(socketPath: String, request: IPCRequest) throws -> IPCResponse { - let fd = socket(AF_UNIX, SOCK_STREAM, 0) - guard fd >= 0 else { - throw NSError(domain: "IPC", code: 1, userInfo: [NSLocalizedDescriptionKey: "socket() failed"]) - } - defer { close(fd) } - - var addr = sockaddr_un() - addr.sun_family = sa_family_t(AF_UNIX) - let bytes = Array(socketPath.utf8) - let maxPathLength = MemoryLayout.size(ofValue: addr.sun_path) - guard bytes.count < maxPathLength else { - throw NSError(domain: "IPC", code: 2, userInfo: [NSLocalizedDescriptionKey: "Socket path too long"]) - } - - withUnsafeMutablePointer(to: &addr.sun_path) { ptr in - let raw = UnsafeMutableRawPointer(ptr).assumingMemoryBound(to: CChar.self) - raw.initialize(repeating: 0, count: maxPathLength) - for index in bytes.indices { - raw[index] = CChar(bitPattern: bytes[index]) - } - } - - let len = socklen_t(MemoryLayout.size + bytes.count + 1) - let connectResult = withUnsafePointer(to: &addr) { - $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { - connect(fd, $0, len) - } - } - guard connectResult == 0 else { - throw NSError(domain: "IPC", code: 3, userInfo: [NSLocalizedDescriptionKey: "connect() failed"]) - } - - let requestData = try JSONEncoder().encode(request) - _ = requestData.withUnsafeBytes { bytes in - write(fd, bytes.baseAddress, bytes.count) - } - shutdown(fd, SHUT_WR) - - var responseData = Data() - var buffer = [UInt8](repeating: 0, count: 4096) - while true { - let count = read(fd, &buffer, buffer.count) - if count > 0 { - responseData.append(contentsOf: buffer.prefix(Int(count))) - } else { - break - } - } - - return try JSONDecoder().decode(IPCResponse.self, from: responseData) - } - - private func makeGitRepo(root: URL, name: String) throws -> URL { - let repo = root.appendingPathComponent(name, isDirectory: true) - try FileManager.default.createDirectory(at: repo, withIntermediateDirectories: true) - - _ = try runGit(["init", "-q"], currentDirectory: repo.path) - _ = try runGit(["config", "user.email", "idx0-tests@example.com"], currentDirectory: repo.path) - _ = try runGit(["config", "user.name", "idx0 tests"], currentDirectory: repo.path) - - let readme = repo.appendingPathComponent("README.md") - try "integration test\n".data(using: .utf8)?.write(to: readme) - - _ = try runGit(["add", "README.md"], currentDirectory: repo.path) - _ = try runGit(["commit", "-q", "-m", "initial"], currentDirectory: repo.path) - return repo - } - - private func currentBranch(at repoPath: String) throws -> String? { - let branch = try runGit(["branch", "--show-current"], currentDirectory: repoPath) - .trimmingCharacters(in: .whitespacesAndNewlines) - return branch.isEmpty ? nil : branch - } - - private func canonicalPath(_ path: String?) -> String? { - guard let path else { return nil } - return URL(fileURLWithPath: path) - .resolvingSymlinksInPath() - .standardizedFileURL - .path - } - - @discardableResult - private func runGit(_ arguments: [String], currentDirectory: String) throws -> String { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/git") - process.arguments = arguments - process.currentDirectoryURL = URL(fileURLWithPath: currentDirectory) - - let stdout = Pipe() - let stderr = Pipe() - process.standardOutput = stdout - process.standardError = stderr - - try process.run() - process.waitUntilExit() - - let stdoutString = String( - data: stdout.fileHandleForReading.readDataToEndOfFile(), - encoding: .utf8 - ) ?? "" - let stderrString = String( - data: stderr.fileHandleForReading.readDataToEndOfFile(), - encoding: .utf8 - ) ?? "" - - guard process.terminationStatus == 0 else { - throw NSError( - domain: "SessionServiceIntegrationTests", - code: Int(process.terminationStatus), - userInfo: [NSLocalizedDescriptionKey: stderrString.isEmpty ? stdoutString : stderrString] - ) - } - - return stdoutString - } + return stdoutString + } } diff --git a/idx0Tests/WorktreePathGenerationTests.swift b/idx0Tests/WorktreePathGenerationTests.swift index a80ba03..ab52379 100644 --- a/idx0Tests/WorktreePathGenerationTests.swift +++ b/idx0Tests/WorktreePathGenerationTests.swift @@ -1,168 +1,255 @@ import Foundation -import XCTest @testable import idx0 +import XCTest final class WorktreePathGenerationTests: XCTestCase { - func testCreateWorktreeAppendsCollisionSuffix() async throws { - let temp = FileManager.default.temporaryDirectory - .appendingPathComponent("idx0-worktree-tests-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: temp, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: temp) } - - let paths = try makePaths(root: temp) - let git = MockGitService() - let service = WorktreeService(gitService: git, paths: paths) - - let first = try await service.createWorktree(repoPath: "/tmp/repo", branchName: "idx0/fix", sessionTitle: "Fix") - - try FileManager.default.createDirectory(atPath: first.worktreePath, withIntermediateDirectories: true) - - _ = try await service.createWorktree(repoPath: "/tmp/repo", branchName: "idx0/fix", sessionTitle: "Fix") - - let createdPaths = git.createdWorktreePaths - XCTAssertEqual(createdPaths.count, 2) - XCTAssertTrue(createdPaths[0].hasSuffix("/idx0-fix")) - XCTAssertTrue(createdPaths[1].hasSuffix("/idx0-fix-2")) - } - - func testDeleteWorktreeIfCleanRemovesThroughGit() async throws { - let temp = FileManager.default.temporaryDirectory - .appendingPathComponent("idx0-worktree-delete-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: temp, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: temp) } - - let paths = try makePaths(root: temp) - let git = MockGitService() - let service = WorktreeService(gitService: git, paths: paths) - - let repoPath = "/tmp/repo-clean" - let worktreePath = temp.appendingPathComponent("wt-clean", isDirectory: true).path - try FileManager.default.createDirectory(atPath: worktreePath, withIntermediateDirectories: true) - - git.listedWorktrees = [WorktreeInfo(repoPath: repoPath, worktreePath: worktreePath, branchName: "main")] - git.statusOutput = "" - - try await service.deleteWorktreeIfClean(repoPath: repoPath, worktreePath: worktreePath) - XCTAssertEqual(git.removedWorktreePaths, [worktreePath]) - } - - func testDeleteWorktreeIfCleanThrowsWhenDirty() async throws { - let temp = FileManager.default.temporaryDirectory - .appendingPathComponent("idx0-worktree-dirty-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: temp, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: temp) } - - let paths = try makePaths(root: temp) - let git = MockGitService() - let service = WorktreeService(gitService: git, paths: paths) - - let repoPath = "/tmp/repo-dirty" - let worktreePath = temp.appendingPathComponent("wt-dirty", isDirectory: true).path - try FileManager.default.createDirectory(atPath: worktreePath, withIntermediateDirectories: true) - - git.listedWorktrees = [WorktreeInfo(repoPath: repoPath, worktreePath: worktreePath, branchName: "main")] - git.statusOutput = " M changed.swift" - - do { - try await service.deleteWorktreeIfClean(repoPath: repoPath, worktreePath: worktreePath) - XCTFail("Expected dirty worktree error") - } catch let error as WorktreeServiceError { - switch error { - case .worktreeDirty: - break - default: - XCTFail("Unexpected worktree error: \(error)") - } - } catch { - XCTFail("Unexpected error: \(error)") - } - } - - private func makePaths(root: URL) throws -> FileSystemPaths { - let appSupport = root.appendingPathComponent("AppSupport", isDirectory: true) - try FileManager.default.createDirectory(at: appSupport, withIntermediateDirectories: true) - - return FileSystemPaths( - appSupportDirectory: appSupport, - sessionsFile: appSupport.appendingPathComponent("sessions.json"), - projectsFile: appSupport.appendingPathComponent("projects.json"), - inboxFile: appSupport.appendingPathComponent("inbox.json"), - settingsFile: appSupport.appendingPathComponent("settings.json"), - runDirectory: appSupport.appendingPathComponent("run", isDirectory: true), - tempDirectory: appSupport.appendingPathComponent("temp", isDirectory: true), - worktreesDirectory: appSupport.appendingPathComponent("worktrees", isDirectory: true) - ) - } + func testCreateWorktreeUsesWorkspaceLocalPathAndAppendsCollisionSuffix() async throws { + let temp = FileManager.default.temporaryDirectory + .appendingPathComponent("idx0-worktree-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: temp, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: temp) } + + let paths = try makePaths(root: temp) + let repoRoot = try makeRepoRoot(at: temp, name: "repo") + let git = MockGitService() + let service = WorktreeService( + gitService: git, + paths: paths, + worktreeNameGenerator: { "collision-name" } + ) + + let first = try await service.createWorktree(repoPath: repoRoot.path, branchName: "idx0/fix", sessionTitle: "Fix") + + try FileManager.default.createDirectory(atPath: first.worktreePath, withIntermediateDirectories: true) + + let second = try await service.createWorktree(repoPath: repoRoot.path, branchName: "idx0/fix-2", sessionTitle: "Fix") + + let createdPaths = git.createdWorktreePaths + XCTAssertEqual(createdPaths.count, 2) + XCTAssertEqual(URL(fileURLWithPath: first.worktreePath).lastPathComponent, "wt-collisionnam") + XCTAssertEqual(URL(fileURLWithPath: second.worktreePath).lastPathComponent, "wt-collisionnam-2") + + let expectedPrefix = repoRoot + .appendingPathComponent(".idx0/worktrees", isDirectory: true) + .path + "/" + XCTAssertTrue(first.worktreePath.hasPrefix(expectedPrefix)) + XCTAssertTrue(second.worktreePath.hasPrefix(expectedPrefix)) + } + + func testCreateWorktreeAddsIDX0RuleToLocalExcludeIdempotently() async throws { + let temp = FileManager.default.temporaryDirectory + .appendingPathComponent("idx0-worktree-exclude-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: temp, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: temp) } + + let paths = try makePaths(root: temp) + let repoRoot = try makeRepoRoot(at: temp, name: "repo") + let excludeURL = repoRoot.appendingPathComponent(".git/info/exclude", isDirectory: false) + try "existing-rule\n".write(to: excludeURL, atomically: true, encoding: .utf8) + + let git = MockGitService() + let service = WorktreeService( + gitService: git, + paths: paths, + worktreeNameGenerator: { "idempotent" } + ) + + _ = try await service.createWorktree(repoPath: repoRoot.path, branchName: "idx0/one", sessionTitle: "One") + _ = try await service.createWorktree(repoPath: repoRoot.path, branchName: "idx0/two", sessionTitle: "Two") + + let exclude = try String(contentsOf: excludeURL, encoding: .utf8) + let normalizedLines = exclude + .components(separatedBy: .newlines) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + XCTAssertEqual(normalizedLines.count(where: { $0 == ".idx0/" }), 1) + } + + func testCreateWorktreeUpdatesExcludeWhenDotGitIsFile() async throws { + let temp = FileManager.default.temporaryDirectory + .appendingPathComponent("idx0-worktree-dotgit-file-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: temp, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: temp) } + + let paths = try makePaths(root: temp) + let repoRoot = temp.appendingPathComponent("repo", isDirectory: true) + try FileManager.default.createDirectory(at: repoRoot, withIntermediateDirectories: true) + + let actualGitDir = temp.appendingPathComponent("shared-git-dir", isDirectory: true) + let infoDir = actualGitDir.appendingPathComponent("info", isDirectory: true) + try FileManager.default.createDirectory(at: infoDir, withIntermediateDirectories: true) + let excludeURL = infoDir.appendingPathComponent("exclude", isDirectory: false) + try "header\n".write(to: excludeURL, atomically: true, encoding: .utf8) + try "gitdir: \(actualGitDir.path)\n".write( + to: repoRoot.appendingPathComponent(".git", isDirectory: false), + atomically: true, + encoding: .utf8 + ) + + let git = MockGitService() + let service = WorktreeService( + gitService: git, + paths: paths, + worktreeNameGenerator: { "dotgitfile" } + ) + + _ = try await service.createWorktree(repoPath: repoRoot.path, branchName: "idx0/dotgit", sessionTitle: "DotGit") + + let exclude = try String(contentsOf: excludeURL, encoding: .utf8) + let normalizedLines = exclude + .components(separatedBy: .newlines) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + XCTAssertEqual(normalizedLines.count(where: { $0 == ".idx0/" }), 1) + } + + func testDeleteWorktreeIfCleanRemovesThroughGit() async throws { + let temp = FileManager.default.temporaryDirectory + .appendingPathComponent("idx0-worktree-delete-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: temp, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: temp) } + + let paths = try makePaths(root: temp) + let git = MockGitService() + let service = WorktreeService(gitService: git, paths: paths) + + let repoPath = "/tmp/repo-clean" + let worktreePath = temp.appendingPathComponent("wt-clean", isDirectory: true).path + try FileManager.default.createDirectory(atPath: worktreePath, withIntermediateDirectories: true) + + git.listedWorktrees = [WorktreeInfo(repoPath: repoPath, worktreePath: worktreePath, branchName: "main")] + git.statusOutput = "" + + try await service.deleteWorktreeIfClean(repoPath: repoPath, worktreePath: worktreePath) + XCTAssertEqual(git.removedWorktreePaths, [worktreePath]) + } + + func testDeleteWorktreeIfCleanThrowsWhenDirty() async throws { + let temp = FileManager.default.temporaryDirectory + .appendingPathComponent("idx0-worktree-dirty-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: temp, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: temp) } + + let paths = try makePaths(root: temp) + let git = MockGitService() + let service = WorktreeService(gitService: git, paths: paths) + + let repoPath = "/tmp/repo-dirty" + let worktreePath = temp.appendingPathComponent("wt-dirty", isDirectory: true).path + try FileManager.default.createDirectory(atPath: worktreePath, withIntermediateDirectories: true) + + git.listedWorktrees = [WorktreeInfo(repoPath: repoPath, worktreePath: worktreePath, branchName: "main")] + git.statusOutput = " M changed.swift" + + do { + try await service.deleteWorktreeIfClean(repoPath: repoPath, worktreePath: worktreePath) + XCTFail("Expected dirty worktree error") + } catch let error as WorktreeServiceError { + switch error { + case .worktreeDirty: + break + default: + XCTFail("Unexpected worktree error: \(error)") + } + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + private func makePaths(root: URL) throws -> FileSystemPaths { + let appSupport = root.appendingPathComponent("AppSupport", isDirectory: true) + try FileManager.default.createDirectory(at: appSupport, withIntermediateDirectories: true) + + return FileSystemPaths( + appSupportDirectory: appSupport, + sessionsFile: appSupport.appendingPathComponent("sessions.json"), + projectsFile: appSupport.appendingPathComponent("projects.json"), + inboxFile: appSupport.appendingPathComponent("inbox.json"), + settingsFile: appSupport.appendingPathComponent("settings.json"), + runDirectory: appSupport.appendingPathComponent("run", isDirectory: true), + tempDirectory: appSupport.appendingPathComponent("temp", isDirectory: true), + worktreesDirectory: appSupport.appendingPathComponent("worktrees", isDirectory: true) + ) + } + + private func makeRepoRoot(at root: URL, name: String) throws -> URL { + let repoRoot = root.appendingPathComponent(name, isDirectory: true) + let infoDir = repoRoot + .appendingPathComponent(".git", isDirectory: true) + .appendingPathComponent("info", isDirectory: true) + try FileManager.default.createDirectory(at: infoDir, withIntermediateDirectories: true) + return repoRoot + } } private final class MockGitService: GitServiceProtocol { - var createdWorktreePaths: [String] = [] - var removedWorktreePaths: [String] = [] - var listedWorktrees: [WorktreeInfo] = [] - var statusOutput = "" - - func repoInfo(for path: String) async throws -> GitRepoInfo { - GitRepoInfo(topLevelPath: path, currentBranch: "main", repoName: "repo") - } - - func currentBranch(repoPath: String) async throws -> String? { - _ = repoPath - return "main" - } - - func currentCommitSHA(repoPath: String) async throws -> String? { - _ = repoPath - return String(repeating: "a", count: 40) - } - - func localBranches(repoPath: String) async throws -> [String] { - _ = repoPath - return ["main"] - } - - func listWorktrees(repoPath: String) async throws -> [WorktreeInfo] { - _ = repoPath - return listedWorktrees - } - - func createWorktree(repoPath: String, branchName: String, worktreePath: String) async throws -> WorktreeInfo { - _ = repoPath - _ = branchName - createdWorktreePaths.append(worktreePath) - return WorktreeInfo(repoPath: repoPath, worktreePath: worktreePath, branchName: branchName) - } - - func statusPorcelain(path: String) async throws -> String { - _ = path - return statusOutput - } - - func removeWorktree(repoPath: String, worktreePath: String) async throws { - _ = repoPath - removedWorktreePaths.append(worktreePath) - } - - func diffNameStatus(path: String) async throws -> [ChangedFileSummary] { - _ = path - return [] - } - - func diffNameStatus(path: String, between leftRef: String, and rightRef: String) async throws -> [ChangedFileSummary] { - _ = path - _ = leftRef - _ = rightRef - return [] - } - - func diffStat(path: String) async throws -> DiffStat? { - _ = path - return DiffStat(filesChanged: 0, additions: 0, deletions: 0) - } - - func diffStat(path: String, between leftRef: String, and rightRef: String) async throws -> DiffStat? { - _ = path - _ = leftRef - _ = rightRef - return DiffStat(filesChanged: 0, additions: 0, deletions: 0) - } + var createdWorktreePaths: [String] = [] + var removedWorktreePaths: [String] = [] + var listedWorktrees: [WorktreeInfo] = [] + var statusOutput = "" + + func repoInfo(for path: String) async throws -> GitRepoInfo { + GitRepoInfo(topLevelPath: path, currentBranch: "main", repoName: "repo") + } + + func currentBranch(repoPath: String) async throws -> String? { + _ = repoPath + return "main" + } + + func currentCommitSHA(repoPath: String) async throws -> String? { + _ = repoPath + return String(repeating: "a", count: 40) + } + + func localBranches(repoPath: String) async throws -> [String] { + _ = repoPath + return ["main"] + } + + func listWorktrees(repoPath: String) async throws -> [WorktreeInfo] { + _ = repoPath + return listedWorktrees + } + + func createWorktree(repoPath: String, branchName: String, worktreePath: String) async throws -> WorktreeInfo { + _ = repoPath + _ = branchName + createdWorktreePaths.append(worktreePath) + return WorktreeInfo(repoPath: repoPath, worktreePath: worktreePath, branchName: branchName) + } + + func statusPorcelain(path: String) async throws -> String { + _ = path + return statusOutput + } + + func removeWorktree(repoPath: String, worktreePath: String) async throws { + _ = repoPath + removedWorktreePaths.append(worktreePath) + } + + func diffNameStatus(path: String) async throws -> [ChangedFileSummary] { + _ = path + return [] + } + + func diffNameStatus(path: String, between leftRef: String, and rightRef: String) async throws -> [ChangedFileSummary] { + _ = path + _ = leftRef + _ = rightRef + return [] + } + + func diffStat(path: String) async throws -> DiffStat? { + _ = path + return DiffStat(filesChanged: 0, additions: 0, deletions: 0) + } + + func diffStat(path: String, between leftRef: String, and rightRef: String) async throws -> DiffStat? { + _ = path + _ = leftRef + _ = rightRef + return DiffStat(filesChanged: 0, additions: 0, deletions: 0) + } }