import APIClient import Foundation import Observation import WireProtocol /// C2 · git 面板状态(`docs/plans/w6-project-git-panel.md` 的移动端对齐)。 /// /// 面板的职责是**如实告诉你发生了什么**,并提供 web 已有的那几个写操作: /// 同步带(↑↓ + 脏度 + fetch)· 改动文件的 stage/unstage · commit · push · /// `git log`(含未推送边界)· PR/CI 芯片。 /// /// 三条纪律(都被单测钉住): /// 1. **绝不本地推算 git 数字**。任一写操作成功后重新向服务器要一遍详情/日志 —— /// `ahead`/`behind`/`lastFetchMs` 只有服务器说的算(w6 的核心教训:本地缓存的 /// 数字会在 push 后继续撒谎)。 /// 2. **分区独立降级**。日志失败不该让同步带消失,PR(gh 会走外网)单独加载, /// 详情失败就**没有**同步带,而不是显示一个编造的"已同步"。 /// 3. **服务器安全文案原样上浮**(SEC-M10 已在服务端把 stderr 分类成安全短句)—— /// 写操作绝不静默失败。 @MainActor @Observable final class GitPanelViewModel { /// 一个待处理的改动文件。 /// /// `stagePaths` 与显示路径分开:重命名必须**同时**把 oldPath 与 newPath 交给 /// `git add`(镜像 web `public/diff.ts` 的做法),否则只暂存新增侧、删除侧留在 /// 工作区,提交出来是"复制"而不是"重命名"。 struct ChangedFile: Equatable, Identifiable, Sendable { let displayPath: String let stagePaths: [String] let status: DiffFileStatus let added: Int let removed: Int var id: String { displayPath } static func make(from file: DiffFile) -> ChangedFile { let primary = file.newPath.isEmpty ? file.oldPath : file.newPath let isRename = file.status == .renamed && !file.oldPath.isEmpty && file.oldPath != file.newPath return ChangedFile( displayPath: isRename ? "\(file.oldPath) → \(file.newPath)" : primary, stagePaths: isRename ? [file.oldPath, file.newPath] : [primary], status: file.status, added: file.added, removed: file.removed ) } } /// 正在进行的写操作(用于禁用按钮 + 行内 spinner)。 enum Operation: Equatable { case fetch case commit case push case stage(String) case unstage(String) } /// 注入的依赖 —— 生产由 `forProject` 包 `APIClient`/`DiffFetcher`,测试注入 fake。 struct Dependencies: Sendable { let detail: @Sendable () async throws -> ProjectDetail let log: @Sendable () async throws -> GitLogResult let pr: @Sendable () async throws -> PrStatus let changes: @Sendable (_ staged: Bool) async throws -> [ChangedFile] let stage: @Sendable (_ files: [String], _ stage: Bool) async throws -> GitWriteOutcome let commit: @Sendable (_ message: String) async throws -> GitWriteOutcome let push: @Sendable () async throws -> GitWriteOutcome let fetchRemote: @Sendable () async throws -> GitWriteOutcome /// 判定 fetch 新鲜度的"现在"(可注入,测试才能确定性地跨过 1h 阈值)。 let nowMs: @Sendable () -> Double init( detail: @escaping @Sendable () async throws -> ProjectDetail, log: @escaping @Sendable () async throws -> GitLogResult, pr: @escaping @Sendable () async throws -> PrStatus, changes: @escaping @Sendable (Bool) async throws -> [ChangedFile], stage: @escaping @Sendable ([String], Bool) async throws -> GitWriteOutcome, commit: @escaping @Sendable (String) async throws -> GitWriteOutcome, push: @escaping @Sendable () async throws -> GitWriteOutcome, fetchRemote: @escaping @Sendable () async throws -> GitWriteOutcome, nowMs: @escaping @Sendable () -> Double = { Date().timeIntervalSince1970 * 1_000 } ) { self.detail = detail self.log = log self.pr = pr self.changes = changes self.stage = stage self.commit = commit self.push = push self.fetchRemote = fetchRemote self.nowMs = nowMs } } let path: String private(set) var isLoaded = false private(set) var branch: String? /// nil = 非 git 目录 **或** 详情读取失败(见 `stateErrorMessage`)。 private(set) var band: GitSyncBand? private(set) var stateErrorMessage: String? private(set) var log: GitLogResult? private(set) var logErrorMessage: String? private(set) var pr: PrStatus? private(set) var unstaged: [ChangedFile] = [] private(set) var staged: [ChangedFile] = [] private(set) var changesErrorMessage: String? private(set) var busy: Operation? /// 最近一次写操作失败(服务器安全文案优先)。 private(set) var errorMessage: String? /// 最近一次写操作成功的简短确认。 private(set) var noticeMessage: String? /// 提交信息草稿(`TextField` 绑定)。 var commitMessage = "" @ObservationIgnored private let dependencies: Dependencies init(path: String, dependencies: Dependencies) { self.path = path self.dependencies = dependencies } /// 生产装配缝(ProjectDetailScreen 只需转手 endpoint/http/path)。 static func forProject( endpoint: HostEndpoint, http: any HTTPTransport, path: String ) -> GitPanelViewModel { let client = APIClient(endpoint: endpoint, http: http) let diff = DiffFetcher(endpoint: endpoint, http: http) return GitPanelViewModel(path: path, dependencies: Dependencies( detail: { try await client.projectDetail(path: path) }, log: { try await client.gitLog(path: path) }, pr: { try await client.prStatus(path: path) }, changes: { staged in try await diff.fetch(path: path, staged: staged).files.map(ChangedFile.make(from:)) }, stage: { files, stage in try await client.gitStage(path: path, files: files, stage: stage) }, commit: { message in try await client.gitCommit(path: path, message: message) }, push: { try await client.gitPush(path: path) }, fetchRemote: { try await client.gitFetch(path: path) } )) } var canCommit: Bool { busy == nil && !commitMessage.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } /// 分离 HEAD 上没有可 fetch 的目标(服务器会 400)——按钮直接禁用。 var canFetch: Bool { busy == nil && band?.canFetch == true } // MARK: - 读 /// 首屏 + 每次写操作之后的刷新。三个分区各自独立降级。 func load() async { await loadState() await loadLog() await loadChanges() isLoaded = true } /// PR/CI 单独一条命令:gh 会替我们走一次外网调用,慢到秒级,绝不放进首屏串行链。 func loadPullRequest() async { do { pr = try await dependencies.pr() } catch { // 200 + 降级 availability 才是"gh 不可用"的正常表达;真错误只是让芯片 // 缺席(面板其余部分照常),绝不弹成写操作错误。 pr = nil } } private func loadState() async { do { let detail = try await dependencies.detail() branch = detail.branch band = GitSyncBand.make( sync: detail.sync, dirtyCount: detail.dirtyCount, nowMs: dependencies.nowMs() ) stateErrorMessage = nil } catch { branch = nil band = nil // 读不到就没有同步带 —— 绝不显示一个编造的"已同步" stateErrorMessage = GitWriteFeedback.message( for: error, fallback: GitPanelCopy.stateFailed ) } } private func loadLog() async { do { log = try await dependencies.log() logErrorMessage = nil } catch { logErrorMessage = GitWriteFeedback.message(for: error, fallback: GitPanelCopy.logFailed) } } private func loadChanges() async { do { unstaged = try await dependencies.changes(false) staged = try await dependencies.changes(true) changesErrorMessage = nil } catch { changesErrorMessage = GitWriteFeedback.message( for: error, fallback: GitPanelCopy.changesFailed ) } } // MARK: - 写 func setStaged(_ file: ChangedFile, staged: Bool) async { await perform( staged ? .stage(file.id) : .unstage(file.id), fallback: staged ? GitPanelCopy.stageFailed : GitPanelCopy.unstageFailed, write: { try await self.dependencies.stage(file.stagePaths, staged) }, onSuccess: { [weak self] (result: StageResult) in self?.noticeMessage = staged ? GitPanelCopy.staged(result.count) : GitPanelCopy.unstaged(result.count) } ) } func commit() async { let message = commitMessage.trimmingCharacters(in: .whitespacesAndNewlines) guard !message.isEmpty else { errorMessage = GitPanelCopy.commitMessageRequired return } await perform( .commit, fallback: GitPanelCopy.commitFailed, write: { try await self.dependencies.commit(message) }, onSuccess: { [weak self] (result: CommitResult) in // 只有成功才清草稿:409「无暂存」时用户的文字必须留着。 self?.commitMessage = "" self?.noticeMessage = GitPanelCopy.committed(result.commit) } ) } func push() async { await perform( .push, fallback: GitPanelCopy.pushFailed, write: { try await self.dependencies.push() }, onSuccess: { [weak self] (result: PushResult) in self?.noticeMessage = GitPanelCopy.pushed( branch: result.branch, remote: result.remote ) } ) } func fetchRemote() async { await perform( .fetch, fallback: GitPanelCopy.fetchFailed, write: { try await self.dependencies.fetchRemote() }, onSuccess: { [weak self] (_: FetchResult) in self?.noticeMessage = GitPanelCopy.fetched } ) } /// 所有写操作的唯一执行点:忙态去重 → 三种结局映射 → 成功后**重新向服务器 /// 取真相**。`write`/`onSuccess` 都是非逃逸闭包,因此在 MainActor 上原地执行。 private func perform( _ operation: Operation, fallback: String, write: () async throws -> GitWriteOutcome, onSuccess: (Payload) -> Void ) async { guard busy == nil else { return } // 重复点击只发一次 busy = operation errorMessage = nil noticeMessage = nil do { switch try await write() { case .ok(let payload): onSuccess(payload) busy = nil await load() // 数字只认服务器(w6:本地推算会在 push 后继续撒谎) return case .rejected(let status, let message): errorMessage = GitWriteFeedback.message( status: status, serverMessage: message, fallback: fallback ) case .rateLimited: errorMessage = GitWriteFeedback.rateLimited // 限流绝不自动重试 } } catch { errorMessage = GitWriteFeedback.message(for: error, fallback: fallback) } busy = nil } } // MARK: - 用户可见文案(中文具名常量,plan §4) enum GitPanelCopy { static let title = "Git 面板" static let entryLabel = "Git 面板" static let stateSection = "同步状态" static let changesSection = "改动" static let commitSection = "提交" static let logSection = "最近提交" static let prSection = "Pull Request" static let upstreamCaption = "上游" static let toPushCaption = "待推送" static let toPullCaption = "待拉取" static let unknownCount = "—" static let inSync = "已同步" static let unverified = "未核实" static let noUpstream = "无上游" static let detachedHead = "分离 HEAD" static let dirtyUnknown = "未检查改动" static let clean = "工作区干净" static let neverFetched = "从未 fetch —— 待拉取数不可信" static let staleFetch = "上次 fetch 已久 —— 待拉取数不可信" static let noUpstreamDetail = "此分支不跟踪任何上游,没有可报告的待推送/待拉取。" static let detachedDetail = "HEAD 处于分离状态:没有分支,也无法 fetch。" static let fetchButton = "Fetch" static let fetchHint = "只刷新远端引用,不 pull、不合并" static let commitButton = "提交" static let pushButton = "推送" static let commitPlaceholder = "提交信息" static let stageButton = "暂存" static let unstageButton = "取消暂存" static let noChanges = "工作区没有待提交的改动。" static let noStaged = "暂存区为空。" static let noCommits = "暂无提交记录。" static let truncatedLog = "仅显示最近若干条提交。" static let commitMessageRequired = "请先填写提交信息。" static let stateFailed = "读取 git 状态失败" static let logFailed = "读取提交记录失败" static let changesFailed = "读取改动列表失败" static let stageFailed = "暂存失败" static let unstageFailed = "取消暂存失败" static let commitFailed = "提交失败" static let pushFailed = "推送失败" static let fetchFailed = "Fetch 失败" static let fetched = "已刷新远端引用。" static func staged(_ count: Int) -> String { "已暂存 \(count) 个文件。" } static func unstaged(_ count: Int) -> String { "已取消暂存 \(count) 个文件。" } static func committed(_ commit: String) -> String { commit.isEmpty ? "已提交。" : "已提交 \(commit)。" } static func pushed(branch: String?, remote: String?) -> String { guard let branch, let remote else { return "已推送。" } return "已推送 \(branch) → \(remote)。" } static func unpushedBoundary(_ upstream: String) -> String { "以上未推送到 \(upstream)" } static func dirtyCount(_ count: Int) -> String { "\(count) 处未提交改动" } static func lastFetched(_ label: String) -> String { "上次 fetch:\(label)" } }