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]>
239 lines
12 KiB
Swift
239 lines
12 KiB
Swift
import Foundation
|
||
import Testing
|
||
|
||
@testable import NucleicCore
|
||
|
||
@Suite("NvrsionTrunk — real git on temp repos (NVRSION §2–3)")
|
||
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)
|
||
}
|
||
}
|