import APIClient import Foundation import Observation import WireProtocol /// T-iOS-32 · worktree 生命周期状态(create / prune / remove)。 /// /// 真源 `src/http/worktrees.ts` + `docs/plans/w4-worktree-lifecycle.md`;web 参照 /// 实现 `public/projects.ts`(`validateBranchNameClient`、`confirmAndRemoveWorktree`、 /// `confirmAndPruneWorktrees`)。 /// /// 三条纪律: /// 1. **校验先于网络**:非法分支名连请求都不发(服务器也会拒,但一次往返换不来 /// 任何信息)。 /// 2. **破坏性操作必须显式确认**,并且 409(脏工作树)**绝不自动升级为 force** —— /// 第二次确认由 UI 再问一次(无单击数据丢失)。 /// 3. **服务器安全文案原样上浮**(SEC-M10:服务端已把 git stderr 分类成安全短句)。 @MainActor @Observable final class WorktreeViewModel { struct Dependencies: Sendable { let create: @Sendable (_ branch: String, _ base: String?) async throws -> GitWriteOutcome let prune: @Sendable () async throws -> GitWriteOutcome let remove: @Sendable (_ worktreePath: String, _ force: Bool) async throws -> GitWriteOutcome } enum CreatePhase: Equatable { case idle case creating /// 服务器返回的 canonical path/branch —— 以它为真相,不回显本地输入。 case created(path: String, branch: String) case failed(String) } enum PrunePhase: Equatable { case idle case pruning case done(String) case failed(String) } enum RemovePhase: Equatable { case idle case removing /// 409:脏工作树需要 force。等用户第二次确认(`confirmForceRemove`), /// 或取消(`cancelForceRemove`)—— 客户端绝不自己升级。 case forceConfirming(path: String, message: String) case removed(path: String) case failed(String) } /// 新分支名(`TextField` 绑定)。 var branchText = "" /// 起点 ref;留空 = 从 HEAD 切(服务器语义),**绝不**送空串。 var baseText = "" private(set) var createPhase: CreatePhase = .idle private(set) var prunePhase: PrunePhase = .idle private(set) var removePhase: RemovePhase = .idle @ObservationIgnored private let dependencies: Dependencies init(dependencies: Dependencies) { self.dependencies = dependencies } /// 生产装配缝。 static func forProject( endpoint: HostEndpoint, http: any HTTPTransport, path: String ) -> WorktreeViewModel { let client = APIClient(endpoint: endpoint, http: http) return WorktreeViewModel(dependencies: Dependencies( create: { branch, base in try await client.createWorktree(path: path, branch: branch, base: base) }, prune: { try await client.pruneWorktrees(path: path) }, remove: { worktreePath, force in try await client.removeWorktree( path: path, worktreePath: worktreePath, force: force ) } )) } /// 输入时的即时校验(空串不提示 —— 还没开始填)。 var branchValidationMessage: String? { let branch = trimmedBranch guard !branch.isEmpty else { return nil } return WorktreeBranchRule.validate(branch) } var canSubmitCreate: Bool { createPhase != .creating && WorktreeBranchRule.validate(trimmedBranch) == nil } var isBusy: Bool { createPhase == .creating || prunePhase == .pruning || removePhase == .removing } /// 只有"链接的、未锁定的"worktree 可以删:主 worktree 是仓库本体(服务器 400), /// 锁定的要先在终端里 unlock(服务器 409)—— UI 不邀请一个必然被拒的动作。 static func canRemove(_ worktree: WorktreeInfo) -> Bool { !worktree.isMain && worktree.locked != true } private var trimmedBranch: String { branchText.trimmingCharacters(in: .whitespacesAndNewlines) } private var trimmedBase: String? { let base = baseText.trimmingCharacters(in: .whitespacesAndNewlines) return base.isEmpty ? nil : base } // MARK: - 创建 func create() async { guard createPhase != .creating else { return } // 重复提交只发一次 let branch = trimmedBranch if let invalid = WorktreeBranchRule.validate(branch) { createPhase = .failed(invalid) // 校验在边界:零网络 I/O return } createPhase = .creating do { switch try await dependencies.create(branch, trimmedBase) { case .ok(let result): createPhase = .created( path: result.path ?? "", branch: result.branch ?? branch ) case .rejected(let status, let message): createPhase = .failed(GitWriteFeedback.message( status: status, serverMessage: message, fallback: WorktreeCopy.createFailed )) case .rateLimited: createPhase = .failed(GitWriteFeedback.rateLimited) } } catch { createPhase = .failed(GitWriteFeedback.message( for: error, fallback: WorktreeCopy.createFailed )) } } /// 创建成功后重置表单(sheet 关闭时调用)。 func resetCreate() { branchText = "" baseText = "" createPhase = .idle } // MARK: - prune(调用方必须先拿到用户确认) func prune() async { guard prunePhase != .pruning else { return } prunePhase = .pruning do { switch try await dependencies.prune() { case .ok(let result): // 空列表 = 没有可回收的(路由是幂等的),这不是错误。 prunePhase = .done( result.pruned.isEmpty ? WorktreeCopy.pruneNothing : WorktreeCopy.pruneDone(result.pruned.count) ) case .rejected(let status, let message): prunePhase = .failed(GitWriteFeedback.message( status: status, serverMessage: message, fallback: WorktreeCopy.pruneFailed )) case .rateLimited: prunePhase = .failed(GitWriteFeedback.rateLimited) } } catch { prunePhase = .failed(GitWriteFeedback.message( for: error, fallback: WorktreeCopy.pruneFailed )) } } func clearPruneResult() { prunePhase = .idle } // MARK: - remove(两级确认) /// 第一次确认之后调用:一律 `force: false`。 func remove(worktreePath: String) async { await runRemove(worktreePath: worktreePath, force: false) } /// `.forceConfirming` 下用户第二次确认之后调用。 func confirmForceRemove() async { guard case .forceConfirming(let path, _) = removePhase else { return } await runRemove(worktreePath: path, force: true) } func cancelForceRemove() { guard case .forceConfirming = removePhase else { return } removePhase = .idle } func clearRemoveResult() { removePhase = .idle } private func runRemove(worktreePath: String, force: Bool) async { guard removePhase != .removing else { return } removePhase = .removing do { switch try await dependencies.remove(worktreePath, force) { case .ok(let result): removePhase = .removed(path: result.path ?? worktreePath) case .rejected(let status, let message): let text = GitWriteFeedback.message( status: status, serverMessage: message, fallback: WorktreeCopy.removeFailed ) // 409 = 脏工作树,唯一可以升级为 force 的情形,且必须再问一次。 // 已经 force 过还 409 ⇒ 直接失败,绝不循环。 removePhase = (status == WorktreeStatus.conflict && !force) ? .forceConfirming(path: worktreePath, message: text) : .failed(text) case .rateLimited: removePhase = .failed(GitWriteFeedback.rateLimited) } } catch { removePhase = .failed(GitWriteFeedback.message( for: error, fallback: WorktreeCopy.removeFailed )) } } } /// 本 VM 需要区分的 HTTP 状态(APIClient 的同名枚举是 internal,不跨包)。 private enum WorktreeStatus { static let conflict = 409 } // MARK: - 分支名校验(纯函数) /// 逐条镜像 web `validateBranchNameClient`(`public/projects.ts:982`),也就是 /// `git check-ref-format` 的常见子集。 /// /// 这不是"防注入"(服务器用 `execFile` argv,从不过 shell,且 `--` 终止选项), /// 而是**先于往返把必然失败的名字拦下来**,顺带解释原因。 enum WorktreeBranchRule { /// 与 web 一致的长度上限。 static let maxLength = 250 static func validate(_ branch: String) -> String? { if branch.isEmpty { return Message.empty } if branch.count > maxLength { return Message.tooLong } if branch.unicodeScalars.contains(where: isForbiddenControlScalar) { return Message.whitespaceOrControl } if branch.hasPrefix("-") { return Message.leadingHyphen } if branch.contains("..") { return Message.doubleDot } if branch.hasSuffix(".lock") { return Message.lockSuffix } if branch.contains(where: forbiddenCharacters.contains) { return Message.forbiddenCharacter } if branch.contains("@{") { return Message.atBrace } if branch.hasPrefix("/") || branch.hasSuffix("/") || branch.contains("//") { return Message.slashUsage } return nil } /// `[\x00-\x20\x7f]` —— 空格与所有 C0 控制字符 + DEL。 private static func isForbiddenControlScalar(_ scalar: Unicode.Scalar) -> Bool { scalar.value <= 0x20 || scalar.value == 0x7F } private static let forbiddenCharacters: Set = ["~", "^", ":", "?", "*", "[", "\\"] private enum Message { static let empty = "分支名不能为空。" static let tooLong = "分支名过长(最多 \(maxLength) 个字符)。" static let whitespaceOrControl = "分支名不能包含空格或控制字符。" static let leadingHyphen = "分支名不能以 - 开头。" static let doubleDot = "分支名不能包含 \"..\"。" static let lockSuffix = "分支名不能以 \".lock\" 结尾。" static let forbiddenCharacter = "分支名包含非法字符(~ ^ : ? * [ \\)。" static let atBrace = "分支名不能包含 \"@{\"。" static let slashUsage = "分支名的 / 用法不合法。" } } // MARK: - 用户可见文案(中文具名常量,plan §4) enum WorktreeCopy { static let sheetTitle = "新建 Worktree" static let branchField = "新分支名" static let baseField = "起点(留空 = 当前 HEAD)" static let submit = "创建 Worktree" static let cancel = "取消" static let createdTitle = "Worktree 已创建" static let openInNewWorktree = "在新 Worktree 开会话" static let createFailed = "创建 worktree 失败" static let pruneButton = "回收失效 Worktree" static let pruneConfirmTitle = "回收文件夹已消失的 worktree?" static let pruneConfirmMessage = "只清理 git 中已失效的登记项,不会删除任何仍存在的工作树。" static let pruneConfirm = "回收" static let pruneNothing = "没有可回收的 worktree。" static let pruneFailed = "回收失败" static let removeButton = "删除" static let removeAccessibilityLabel = "删除 worktree" static let removeConfirmTitle = "删除这个 worktree?" static let removeConfirm = "删除" static let forceConfirmTitle = "有未提交的改动" static let forceConfirmMessage = "继续将丢失该 worktree 里未提交的改动。" static let forceConfirm = "强制删除" static let removeFailed = "删除 worktree 失败" static let lockedHint = "已锁定:请先在终端里 unlock。" static func pruneDone(_ count: Int) -> String { "已回收 \(count) 个失效 worktree。" } static func removeConfirmMessage(_ path: String) -> String { "将删除工作树目录:\(path)" } static func removed(_ path: String) -> String { "已删除 \(path)。" } static func created(path: String, branch: String) -> String { "已在 \(path) 创建分支 \(branch)。" } }