Files
nucleic/Tests/NucleicCoreTests/NvrsionTrunkTests.swift
T
NucleicandClaude Opus 4.8 174da0301e Add nvrsion: per-file shared-trunk versioning for multi-agent orchestration
An opt-in (Beta, Nucleic-Control-only) versioning mode where a project's agent
sessions share one nucleic/trunk checkout, lock individual files per-edit, land each
completed edit into the trunk immediately, and release fast — instead of holding a
session-long lock until a big merge. Conflicts are structurally impossible within the
trunk (serialized per-file writes + forced re-ground), so 'merge' collapses to 'commit'.

- Phase A: ProjectNvrsion config, migration v20-nvrsion, nvrsionActive gate, Beta toggle
- Phase B: NvrsionTrunk actor (ensureTrunk/land/regroundOnGrant), shared-trunk topology
  (no per-session worktree), per-edit host-mediated path-scoped commit + release
- Phase C: NvrsionReleaseGovernor keep-warm idle eviction, launch crash-recovery,
  flip-safety guard
- Phase D: pre-land validation hook, trunk->base squash promotion + 'Promote trunk' UI

Design and rationale: docs/NVRSION.md. Full suite green (585 tests).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-25 21:32:09 -07:00

239 lines
12 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
import Testing
@testable import NucleicCore
@Suite("NvrsionTrunk — real git on temp repos (NVRSION §23)")
struct NvrsionTrunkTests {
let trunkBranch = "nucleic/trunk"
/// Create a repo + ensure its trunk; returns (repo, trunk, trunkPath).
func makeTrunk() async throws -> (GitTestRepo, NvrsionTrunk, String) {
let repo = try await GitTestRepo()
let trunk = NvrsionTrunk()
let trunkPath = repo.project().resolvedTrunkPath
try await trunk.ensureTrunk(
root: repo.root, trunkPath: trunkPath, trunkBranch: trunkBranch, base: "main")
return (repo, trunk, trunkPath)
}
@Test func ensureTrunkCreatesWorktreeOnBranchOffBase() async throws {
let (repo, trunk, trunkPath) = try await makeTrunk()
defer { repo.cleanup() }
#expect(FileManager.default.fileExists(atPath: trunkPath))
// The trunk branch exists and starts at the base (main) HEAD.
let mainHead = try await repo.revParse("HEAD")
let trunkHead = try await repo.revParse("refs/heads/\(trunkBranch)")
#expect(trunkHead == mainHead)
// The checkout in the trunk dir is on the trunk branch.
let onBranch = try await repo.run(["rev-parse", "--abbrev-ref", "HEAD"], in: trunkPath).stdout
.trimmingCharacters(in: .whitespacesAndNewlines)
#expect(onBranch == trunkBranch)
// Idempotent: a second ensure is a no-op and doesn't throw.
try await trunk.ensureTrunk(
root: repo.root, trunkPath: trunkPath, trunkBranch: trunkBranch, base: "main")
#expect(try await repo.revParse("refs/heads/\(trunkBranch)") == trunkHead)
}
@Test func landsEditAsScopedCommitWithTrailer() async throws {
let (repo, trunk, trunkPath) = try await makeTrunk()
defer { repo.cleanup() }
let session = SessionID.generate()
// Agent "writes" a new file into the shared trunk dir; the host lands it.
try repo.write("src/a.swift", "let a = 1\n", in: trunkPath)
let before = try await repo.revParse("refs/heads/\(trunkBranch)")
let result = await trunk.land(
trunkPath: trunkPath, session: session, paths: ["src/a.swift"], message: "add a")
guard case .landed = result else { Issue.record("expected .landed, got \(result)"); return }
let after = try await repo.revParse("refs/heads/\(trunkBranch)")
#expect(after != before) // trunk advanced
// The file is committed on the trunk branch...
let names = try await repo.run(["show", "--name-only", "--format=", "HEAD"], in: trunkPath).stdout
#expect(names.contains("src/a.swift"))
// ...with the session attribution trailer (NVRSION §3).
let body = try await repo.run(["log", "-1", "--format=%B", "HEAD"], in: trunkPath).stdout
#expect(body.contains("Nucleic-Session: \(session.rawValue)"))
}
@Test func landIsPathScopedAndDoesNotSweepOtherSessionsFiles() async throws {
let (repo, trunk, trunkPath) = try await makeTrunk()
defer { repo.cleanup() }
let a = SessionID.generate()
let b = SessionID.generate()
// Both sessions have in-flight writes in the one shared trunk dir at once.
try repo.write("a.txt", "from A\n", in: trunkPath)
try repo.write("b.txt", "from B\n", in: trunkPath)
// A lands only a.txt — the commit must contain a.txt and NOT b.txt (b stays uncommitted).
guard case .landed = await trunk.land(
trunkPath: trunkPath, session: a, paths: ["a.txt"], message: "A") else {
Issue.record("A land failed"); return
}
let firstNames = try await repo.run(["show", "--name-only", "--format=", "HEAD"], in: trunkPath).stdout
#expect(firstNames.contains("a.txt"))
#expect(!firstNames.contains("b.txt"))
// B then lands b.txt as its own commit.
guard case .landed = await trunk.land(
trunkPath: trunkPath, session: b, paths: ["b.txt"], message: "B") else {
Issue.record("B land failed"); return
}
let secondNames = try await repo.run(["show", "--name-only", "--format=", "HEAD"], in: trunkPath).stdout
#expect(secondNames.contains("b.txt"))
#expect(!secondNames.contains("a.txt"))
// Both files now live in the trunk tree.
#expect(repo.read("a.txt", in: trunkPath) == "from A\n")
#expect(repo.read("b.txt", in: trunkPath) == "from B\n")
}
@Test func landIsNoopWhenContentUnchanged() async throws {
let (repo, trunk, trunkPath) = try await makeTrunk()
defer { repo.cleanup() }
let session = SessionID.generate()
try repo.write("c.txt", "v1\n", in: trunkPath)
guard case .landed = await trunk.land(
trunkPath: trunkPath, session: session, paths: ["c.txt"], message: "c") else {
Issue.record("first land failed"); return
}
// Landing again with identical content commits nothing.
let again = await trunk.land(
trunkPath: trunkPath, session: session, paths: ["c.txt"], message: "c again")
#expect(again == .noop)
}
@Test func prelandHookGatesTheCommit() async throws {
let (repo, trunk, trunkPath) = try await makeTrunk()
defer { repo.cleanup() }
let s = SessionID.generate()
// A failing hook rejects the edit: nothing is committed and the file stays on disk.
try repo.write("gated.txt", "v1\n", in: trunkPath)
let before = try await repo.revParse("refs/heads/\(trunkBranch)")
let rejected = await trunk.land(
trunkPath: trunkPath, session: s, paths: ["gated.txt"], message: "g", prelandHook: "exit 1")
guard case .rejected = rejected else { Issue.record("expected .rejected, got \(rejected)"); return }
#expect(try await repo.revParse("refs/heads/\(trunkBranch)") == before) // trunk unchanged
let dirty = try await repo.run(["status", "--porcelain"], in: trunkPath).stdout
#expect(dirty.contains("gated.txt")) // the agent's work is still on disk, not lost
// A passing hook lets the same edit land.
guard case .landed = await trunk.land(
trunkPath: trunkPath, session: s, paths: ["gated.txt"], message: "g", prelandHook: "exit 0") else {
Issue.record("expected .landed after passing hook"); return
}
#expect(try await repo.revParse("refs/heads/\(trunkBranch)") != before)
}
@Test func prelandHookReceivesEditedPaths() async throws {
let (repo, trunk, trunkPath) = try await makeTrunk()
defer { repo.cleanup() }
let s = SessionID.generate()
try repo.write("p.txt", "x\n", in: trunkPath)
// The hook passes only when NUCLEIC_NVR_PATHS carries the edited path.
let result = await trunk.land(
trunkPath: trunkPath, session: s, paths: ["p.txt"], message: "p",
prelandHook: #"[ "$NUCLEIC_NVR_PATHS" = "p.txt" ]"#)
guard case .landed = result else { Issue.record("expected .landed, got \(result)"); return }
}
@Test func promoteSquashesTrunkIntoBase() async throws {
let (repo, trunk, trunkPath) = try await makeTrunk()
defer { repo.cleanup() }
// Two sessions land work on the trunk.
try repo.write("a.txt", "from A\n", in: trunkPath)
_ = await trunk.land(trunkPath: trunkPath, session: .generate(), paths: ["a.txt"], message: "A")
try repo.write("b.txt", "from B\n", in: trunkPath)
_ = await trunk.land(trunkPath: trunkPath, session: .generate(), paths: ["b.txt"], message: "B")
let baseBefore = try await repo.revParse("refs/heads/main")
let result = await trunk.promote(
root: repo.root, trunkPath: trunkPath, trunkBranch: trunkBranch, base: "main",
message: "promote")
guard case .promoted = result else { Issue.record("expected .promoted, got \(result)"); return }
// The real branch (main, checked out at root) advanced and now carries both files.
#expect(try await repo.revParse("refs/heads/main") != baseBefore)
#expect(repo.read("a.txt", in: repo.root) == "from A\n")
#expect(repo.read("b.txt", in: repo.root) == "from B\n")
let body = try await repo.run(["log", "-1", "--format=%B", "main"]).stdout
#expect(body.contains("Nucleic-Promote: 1"))
// Re-promoting with no new trunk work is a no-op (trunk was resynced onto the new base).
#expect(await trunk.promote(
root: repo.root, trunkPath: trunkPath, trunkBranch: trunkBranch, base: "main",
message: "promote again") == .nothingToPromote)
}
@Test func promoteIsNothingWhenTrunkMatchesBase() async throws {
let (repo, trunk, trunkPath) = try await makeTrunk()
defer { repo.cleanup() }
// Fresh trunk == base → nothing to promote.
#expect(await trunk.promote(
root: repo.root, trunkPath: trunkPath, trunkBranch: trunkBranch, base: "main",
message: "promote") == .nothingToPromote)
}
@Test func recoverCommitsDirtyTrunkResidueOnLaunch() async throws {
let (repo, trunk, trunkPath) = try await makeTrunk()
defer { repo.cleanup() }
// Simulate a crash mid-edit: a file written into the trunk but never committed.
try repo.write("half.swift", "// written, never landed\n", in: trunkPath)
let before = try await repo.revParse("refs/heads/\(trunkBranch)")
await trunk.recover(root: repo.root, trunkPath: trunkPath, trunkBranch: trunkBranch, base: "main")
// The residue is now committed (a recovery commit), so the trunk is clean.
let status = try await repo.run(["status", "--porcelain"], in: trunkPath).stdout
.trimmingCharacters(in: .whitespacesAndNewlines)
#expect(status.isEmpty)
let after = try await repo.revParse("refs/heads/\(trunkBranch)")
#expect(after != before)
let body = try await repo.run(["log", "-1", "--format=%B", "HEAD"], in: trunkPath).stdout
#expect(body.contains("recovered uncommitted work"))
#expect(body.contains("Nucleic-Recovery: 1"))
// Idempotent: a second recovery on a clean trunk commits nothing.
await trunk.recover(root: repo.root, trunkPath: trunkPath, trunkBranch: trunkBranch, base: "main")
#expect(try await repo.revParse("refs/heads/\(trunkBranch)") == after)
}
@Test func regroundFiresOnlyAfterAFileMovesUnderASession() async throws {
let (repo, trunk, trunkPath) = try await makeTrunk()
defer { repo.cleanup() }
let a = SessionID.generate()
let b = SessionID.generate()
// A lands x.txt.
try repo.write("x.txt", "one\n", in: trunkPath)
guard case .landed = await trunk.land(
trunkPath: trunkPath, session: a, paths: ["x.txt"], message: "x1") else {
Issue.record("A land failed"); return
}
// B's FIRST acquaintance with x.txt is clean (matches LOCKING §4.5: re-ground only when a
// file the session already had advances). It now records B has seen the current content.
#expect(await trunk.regroundOnGrant(trunkPath: trunkPath, session: b, files: ["x.txt"]) == .clean)
// A changes x.txt under B.
try repo.write("x.txt", "two\n", in: trunkPath)
guard case .landed = await trunk.land(
trunkPath: trunkPath, session: a, paths: ["x.txt"], message: "x2") else {
Issue.record("A reland failed"); return
}
// Now B is granted x.txt again → it moved since B last saw it → re-ground.
let moved = await trunk.regroundOnGrant(trunkPath: trunkPath, session: b, files: ["x.txt"])
#expect(moved == .changed(files: ["x.txt"], diff: ""))
// And once B has re-read (state updated), a further grant with no movement is clean again.
#expect(await trunk.regroundOnGrant(trunkPath: trunkPath, session: b, files: ["x.txt"]) == .clean)
}
}