// // GitFixture.swift — T-iOS-32 端到端腿的**一次性 git 仓库**夹具 // // 为什么必须是自建的临时仓库:`POST /projects/worktree` 是全应用**唯一**真正 // 写盘的功能,服务器会在 `/-worktrees/` 下真建 // 目录、真开分支(src/http/worktrees.ts:146-160,224-256)。把它指向用户自己的 // 仓库 = 在别人的工作区里留垃圾分支/目录。所以夹具把 repo **和**它派生出来的 // worktree 根一起关在同一个临时目录里,`destroy()` 一把删干净。 // // 布局(worktreeBase 的算法与服务端逐字对应,不是猜的): // /repo ← 夹具仓库(一次空提交,HEAD 可用) // /repo-worktrees/ ← 服务器自己算出来的 worktree 目录 // // 所有断言都拿 **git 自己**的 `worktree list --porcelain` 当第二信源:只信 // HTTP 响应体的话,"服务器说建好了" 和 "盘上真建好了" 就分不开。 import Darwin import Foundation /// `git worktree list --porcelain` 里的一条记录(只留断言用得上的字段)。 struct RegisteredWorktree: Sendable, Equatable { let path: String let branch: String? /// git 自己标记的"目录没了、登记还在"(`prunable `)。 let prunable: Bool } /// 跑一条 git 命令并返回合并后的输出;非零退出码抛 `HarnessError.setup` /// (夹具失败必须**响亮**,绝不能静默变成一条"断言不成立")。 @discardableResult func runGit(_ arguments: [String], in directory: URL) throws -> String { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/git") process.arguments = arguments process.currentDirectoryURL = directory let pipe = Pipe() process.standardOutput = pipe process.standardError = pipe var environment = ProcessInfo.processInfo.environment environment["GIT_TERMINAL_PROMPT"] = "0" // 夹具永远不联网、永远不问密码 process.environment = environment try process.run() // 先读到 EOF 再 wait:反过来会在输出撑满管道缓冲时死锁。 let data = pipe.fileHandleForReading.readDataToEndOfFile() process.waitUntilExit() let text = String(decoding: data, as: UTF8.self) guard process.terminationStatus == 0 else { throw HarnessError.setup( "git \(arguments.joined(separator: " ")) 退出码 \(process.terminationStatus): \(text)") } return text } /// 一个临时目录里的一次性 git 仓库。值语义 + 显式 `destroy()`(用例 `defer` 调)。 struct GitFixture: Sendable { /// 临时根目录,**已解 symlink**(macOS 的 $TMPDIR 是 /var/… → /private/var/…; /// 不解的话服务端 realpath 之后返回的路径与我们手上的字符串对不上)。 let root: URL /// 夹具仓库本体(`/repo`)。 let repo: URL /// 服务端在 `worktreeRoot` 未配置时算出来的 worktree 根: /// `/-worktrees`(src/http/worktrees.ts:151-152)。 var worktreeBase: URL { root.appending(path: "\(repo.lastPathComponent)-worktrees") } /// 提交身份走 `-c`:不读、不写用户的全局 git 配置,也不触发 gpg 签名。 private static let identity = [ "-c", "user.email=fixture@example.invalid", "-c", "user.name=WebTerm Fixture", "-c", "commit.gpgsign=false", ] /// 建仓 + 一次空提交(`git worktree add` 在 unborn HEAD 上会失败,所以必须有)。 static func make() throws -> GitFixture { let manager = FileManager.default let raw = manager.temporaryDirectory .appending(path: "webterm-worktree-e2e-\(UUID().uuidString)") try manager.createDirectory(at: raw, withIntermediateDirectories: true) // 一次性钉成规范形式(macOS 的 $TMPDIR 是 /var/… → /private/var/…)。服务端 // 拿到的就是规范路径,它算出来的 worktree 目录、git 报回来的路径三者同形。 let root = URL(fileURLWithPath: canonicalPath(raw.path), isDirectory: true) let repo = root.appending(path: "repo") try manager.createDirectory(at: repo, withIntermediateDirectories: true) try runGit(["-c", "init.defaultBranch=main", "init", "-q"], in: repo) try runGit(identity + ["commit", "-q", "--allow-empty", "-m", "fixture"], in: repo) return GitFixture(root: root, repo: repo) } /// 尽力而为的清理:repo 与它的 worktree 根都在 `root` 底下,一把删完。 /// 吞错是刻意的 —— 清理失败只留一个临时目录,不该把用例判定改写掉。 func destroy() { try? FileManager.default.removeItem(at: root) } // ── 第二信源:git 自己的登记 ──────────────────────────────────────────── /// 解析 `git worktree list --porcelain`(空行分隔的记录,首条是主 worktree)。 func registeredWorktrees() throws -> [RegisteredWorktree] { let porcelain = try runGit(["worktree", "list", "--porcelain"], in: repo) var out: [RegisteredWorktree] = [] var path: String? var branch: String? var prunable = false func flush() { guard let path else { return } out.append(RegisteredWorktree(path: path, branch: branch, prunable: prunable)) (branch, prunable) = (nil, false) } for rawLine in porcelain.split(separator: "\n", omittingEmptySubsequences: false) { let line = String(rawLine) if line.isEmpty { flush() path = nil continue } if line.hasPrefix("worktree ") { path = String(line.dropFirst("worktree ".count)) } else if line.hasPrefix("branch refs/heads/") { branch = String(line.dropFirst("branch refs/heads/".count)) } else if line == "prunable" || line.hasPrefix("prunable ") { prunable = true } } flush() return out } /// 按规范路径找一条登记(symlink 别名与规范路径必须算同一个)。 func registeredWorktree(atCanonicalPath target: String) throws -> RegisteredWorktree? { let wanted = canonicalPath(target) return try registeredWorktrees().first { canonicalPath($0.path) == wanted } } /// `.git/worktrees/` 下的行政登记目录名(prune 要清掉的正是这些)。 func administrativeEntries() -> [String] { let dir = repo.appending(path: ".git/worktrees") let names = try? FileManager.default.contentsOfDirectory(atPath: dir.path) return (names ?? []).sorted() } } /// 规范绝对路径 = `realpath(3)`;路径不存在时**原样返回**。 /// /// 为什么不能用 `URL.resolvingSymlinksInPath()`(实测踩过,本文件的 RED 就是它): /// 它的文档行为是「若路径以 /private 开头就**去掉** /private」,方向正好相反 —— /// 于是夹具根停在 `/var/folders/…`,而 git 的 `worktree list` 报的是 /// `/private/var/folders/…`。更糟的是那层转换**依赖路径是否存在**:目录被删掉之后 /// `/private` 不再被剥离,于是"删干净了吗"这类断言会因为路径形状而不是因为事实 /// 失败。`realpath(3)` 只做一个方向(→ 规范形式),夹具在建立时就把根钉成规范形式, /// 此后所有比较都是纯字符串相等,与"路径此刻还存不存在"无关。 func canonicalPath(_ path: String) -> String { guard let resolved = realpath(path, nil) else { return path } defer { free(resolved) } return String(cString: resolved) }