T-iOS-32's acceptance is "builder 测试 + 端到端一次". The builder half was green, but grep for projects/worktree across the whole test tree returned nothing — no test had ever created or removed a real git worktree against a real server. Six tests on a throwaway git fixture: create/remove/prune verified against both the filesystem and git's own `worktree list --porcelain` + .git/worktrees/, and asserting the values the SERVER derives rather than echoing our input (branch feature/e2e-worktree becomes directory feature-e2e-worktree; the raw branch name must never appear in the path). Includes a differential leg for the guarded-route rule — the same request without Origin is 403 with zero side effects, and with it is 200 — which needs raw URLSession, since APIClient structurally cannot emit an Origin-less guarded request. GET /sessions gets its own HOME-isolated server: history.ts reads os.homedir()/.claude/projects, so against the shared harness the assertions would land on the developer's real Claude history, and in CI the directory is absent so it degrades to [] and proves nothing. Integration tests 26 -> 32, independently re-run.
156 lines
7.7 KiB
Swift
156 lines
7.7 KiB
Swift
//
|
||
// GitFixture.swift — T-iOS-32 端到端腿的**一次性 git 仓库**夹具
|
||
//
|
||
// 为什么必须是自建的临时仓库:`POST /projects/worktree` 是全应用**唯一**真正
|
||
// 写盘的功能,服务器会在 `<dirname(repo)>/<basename(repo)>-worktrees/` 下真建
|
||
// 目录、真开分支(src/http/worktrees.ts:146-160,224-256)。把它指向用户自己的
|
||
// 仓库 = 在别人的工作区里留垃圾分支/目录。所以夹具把 repo **和**它派生出来的
|
||
// worktree 根一起关在同一个临时目录里,`destroy()` 一把删干净。
|
||
//
|
||
// 布局(worktreeBase 的算法与服务端逐字对应,不是猜的):
|
||
// <root>/repo ← 夹具仓库(一次空提交,HEAD 可用)
|
||
// <root>/repo-worktrees/<sanitized> ← 服务器自己算出来的 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 <reason>`)。
|
||
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
|
||
/// 夹具仓库本体(`<root>/repo`)。
|
||
let repo: URL
|
||
|
||
/// 服务端在 `worktreeRoot` 未配置时算出来的 worktree 根:
|
||
/// `<dirname(repo)>/<basename(repo)>-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)
|
||
}
|