diff --git a/Sources/SubtreeLib/Utilities/GitOperations.swift b/Sources/SubtreeLib/Utilities/GitOperations.swift index 01b384b..e0497e1 100644 --- a/Sources/SubtreeLib/Utilities/GitOperations.swift +++ b/Sources/SubtreeLib/Utilities/GitOperations.swift @@ -125,8 +125,107 @@ public enum GitOperations { return (stdout, stderr, exitCode) } + /// Information about a subtree's split tracking state + public struct SubtreeSplitInfo: Sendable { + /// The split hash recorded for this prefix + public let splitHash: String + /// Whether the commit containing this trailer also has trailers for other prefixes + public let isMultiTrailer: Bool + /// The commit hash where the trailer was found + public let commitHash: String + } + + /// Find the most recent subtree split info for a given prefix + /// + /// Scans git log for commits containing `git-subtree-dir: ` and extracts + /// the corresponding `git-subtree-split` hash. Also detects if the commit has + /// trailers for multiple different prefixes (multi-trailer merge commits). + /// + /// - Parameter prefix: The subtree prefix directory (e.g., "Vendor/secp256k1") + /// - Returns: Split info if found, nil if no subtree tracking exists for this prefix + /// - Throws: GitError if git commands fail + public static func findSubtreeSplitInfo(prefix: String) async throws -> SubtreeSplitInfo? { + // Find the most recent commit with a git-subtree-dir trailer matching this prefix + let logResult = try await run(arguments: [ + "log", "--all", "--grep=^git-subtree-dir: \(prefix)$", + "--format=%H%n%B", "-1" + ]) + + guard logResult.exitCode == 0, !logResult.stdout.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return nil + } + + let output = logResult.stdout + let lines = output.components(separatedBy: "\n") + guard let commitHash = lines.first, !commitHash.isEmpty else { + return nil + } + + // Parse all git-subtree-dir and git-subtree-split pairs from the commit message + var dirSplitPairs: [(dir: String, split: String)] = [] + var currentDir: String? + + for line in lines.dropFirst() { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("git-subtree-dir: ") { + currentDir = String(trimmed.dropFirst("git-subtree-dir: ".count)) + } else if trimmed.hasPrefix("git-subtree-split: "), let dir = currentDir { + let split = String(trimmed.dropFirst("git-subtree-split: ".count)) + dirSplitPairs.append((dir: dir, split: split)) + currentDir = nil + } + } + + // Find the split hash for our target prefix + guard let matchingPair = dirSplitPairs.first(where: { $0.dir == prefix }) else { + return nil + } + + // Count unique prefixes to detect multi-trailer commits + let uniquePrefixes = Set(dirSplitPairs.map { $0.dir }) + + return SubtreeSplitInfo( + splitHash: matchingPair.split, + isMultiTrailer: uniquePrefixes.count > 1, + commitHash: commitHash + ) + } + + /// Create a resync commit to fix multi-trailer split tracking + /// + /// When a merge commit contains `git-subtree-dir`/`git-subtree-split` trailers for + /// multiple different prefixes, `git subtree pull` can pick the wrong split hash. + /// This creates an empty commit with a single correct trailer so `git subtree` + /// finds it first (newest) and uses the right split hash. + /// + /// - Parameters: + /// - prefix: The subtree prefix directory + /// - splitHash: The correct split hash for this prefix + /// - Throws: GitError if the commit fails + public static func createResyncCommit(prefix: String, splitHash: String) async throws { + let shortHash = String(splitHash.prefix(8)) + let message = """ + Squashed '\(prefix)/' content from commit \(shortHash) + + git-subtree-dir: \(prefix) + git-subtree-split: \(splitHash) + """ + + let result = try await run(arguments: [ + "commit", "--allow-empty", "-m", message + ]) + guard result.exitCode == 0 else { + throw GitError.commandFailed("Failed to create resync commit: \(result.stderr)") + } + } + // T006: Git subtree pull wrapper for update operations /// Execute git subtree pull to update a subtree + /// + /// Before pulling, checks for multi-trailer merge commits that can confuse + /// `git subtree`'s split hash detection. If found, creates a resync commit + /// with the correct single-prefix trailer to fix the issue transparently. + /// /// - Parameters: /// - prefix: Local directory path for the subtree /// - remote: Git remote URL @@ -135,6 +234,13 @@ public enum GitOperations { /// - Returns: Commit hash of the pulled changes /// - Throws: GitError if operation fails public static func subtreePull(prefix: String, remote: String, ref: String, squash: Bool) async throws -> String { + // Pre-flight: detect and fix multi-trailer split tracking + if squash, let splitInfo = try? await findSubtreeSplitInfo(prefix: prefix) { + if splitInfo.isMultiTrailer { + try await createResyncCommit(prefix: prefix, splitHash: splitInfo.splitHash) + } + } + var args = ["subtree", "pull", "--prefix=\(prefix)"] if squash { args.append("--squash") diff --git a/Tests/IntegrationTests/MultiTrailerResyncTests.swift b/Tests/IntegrationTests/MultiTrailerResyncTests.swift new file mode 100644 index 0000000..27776e8 --- /dev/null +++ b/Tests/IntegrationTests/MultiTrailerResyncTests.swift @@ -0,0 +1,203 @@ +import Testing +import Foundation +#if canImport(System) +import System +#else +import SystemPackage +#endif + +@Suite("Multi-Trailer Resync Tests") +final class MultiTrailerResyncTests { + + let harness = TestHarness() + + /// Helper: get the file:// URL for a fixture's path + private func fileURL(for fixture: GitRepositoryFixture) -> String { + "file://\(fixture.path.string)" + } + + /// Helper: create a bare upstream repo with an initial commit and tag + private func createUpstreamRepo(name: String, tag: String) async throws -> GitRepositoryFixture { + let upstream = try await GitRepositoryFixture() + + // Add a source file so the subtree has content + let srcDir = upstream.path.appending("src") + try FileManager.default.createDirectory(atPath: srcDir.string, withIntermediateDirectories: true) + try "int main() { return 0; }\n".write( + toFile: srcDir.appending("main.c").string, atomically: true, encoding: .utf8 + ) + try await upstream.runGit(["add", "."]) + try await upstream.runGit(["commit", "-m", "Add source files"]) + try await upstream.runGit(["tag", tag]) + + return upstream + } + + /// Helper: add new content to upstream and tag it + private func addUpstreamUpdate(repo: GitRepositoryFixture, tag: String) async throws { + try "// updated\n".write( + toFile: repo.path.appending("src/main.c").string, atomically: true, encoding: .utf8 + ) + try await repo.runGit(["add", "."]) + try await repo.runGit(["commit", "-m", "Update for \(tag)"]) + try await repo.runGit(["tag", tag]) + } + + /// Helper: get the full commit hash for a tag in a repo + private func getTagCommit(repo: GitRepositoryFixture, tag: String) async throws -> String { + let output = try await repo.runGit(["rev-parse", tag]) + return output.trimmingCharacters(in: .whitespacesAndNewlines) + } + + // MARK: - Multi-Trailer Detection Tests + + @Test("update succeeds when multi-trailer merge commit exists") + func testUpdateWithMultiTrailerCommit() async throws { + // Create two upstream repos + let upstreamA = try await createUpstreamRepo(name: "libA", tag: "v1.0") + defer { try? upstreamA.tearDown() } + let upstreamB = try await createUpstreamRepo(name: "libB", tag: "v1.0") + defer { try? upstreamB.tearDown() } + + // Create consumer repo + let local = try await GitRepositoryFixture() + defer { try? local.tearDown() } + + // Init subtree config + _ = try await harness.run(arguments: ["init"], workingDirectory: local.path) + + // Add both subtrees individually + let addA = try await harness.run( + arguments: ["add", "--remote", fileURL(for: upstreamA), "--name", "libA", + "--prefix", "Vendor/libA", "--ref", "v1.0"], + workingDirectory: local.path + ) + #expect(addA.exitCode == 0, "Add libA should succeed: \(addA.stderr)") + + let addB = try await harness.run( + arguments: ["add", "--remote", fileURL(for: upstreamB), "--name", "libB", + "--prefix", "Vendor/libB", "--ref", "v1.0"], + workingDirectory: local.path + ) + #expect(addB.exitCode == 0, "Add libB should succeed: \(addB.stderr)") + + // Get the split hashes from the individual subtree add commits + let commitHashA = try await getTagCommit(repo: upstreamA, tag: "v1.0") + let commitHashB = try await getTagCommit(repo: upstreamB, tag: "v1.0") + + // Create a multi-trailer commit (simulates squash-merged PR that combined both adds) + let multiTrailerMessage = """ + refactor: Replace submodules with subtrees + + git-subtree-dir: Vendor/libA + git-subtree-split: \(commitHashA) + git-subtree-dir: Vendor/libB + git-subtree-split: \(commitHashB) + """ + try await local.runGit(["commit", "--allow-empty", "-m", multiTrailerMessage]) + + // Add new content to upstream A and tag v2.0 + try await addUpstreamUpdate(repo: upstreamA, tag: "v2.0") + + // Update libA — this should trigger resync and succeed + let updateResult = try await harness.run( + arguments: ["update", "libA"], + workingDirectory: local.path + ) + + #expect(updateResult.exitCode == 0, + "Update should succeed after multi-trailer resync: \(updateResult.stdout)\(updateResult.stderr)") + #expect(updateResult.stdout.contains("Updated libA") || updateResult.stdout.contains("up to date"), + "Should show update status") + } + + @Test("update succeeds when no multi-trailer issue exists") + func testUpdateWithoutMultiTrailerCommit() async throws { + // Create upstream repo + let upstream = try await createUpstreamRepo(name: "lib", tag: "v1.0") + defer { try? upstream.tearDown() } + + // Create consumer repo + let local = try await GitRepositoryFixture() + defer { try? local.tearDown() } + + // Init and add subtree + _ = try await harness.run(arguments: ["init"], workingDirectory: local.path) + let addResult = try await harness.run( + arguments: ["add", "--remote", fileURL(for: upstream), "--name", "lib", + "--prefix", "Vendor/lib", "--ref", "v1.0"], + workingDirectory: local.path + ) + #expect(addResult.exitCode == 0, "Add should succeed") + + // Add new content upstream + try await addUpstreamUpdate(repo: upstream, tag: "v2.0") + + // Update should succeed normally (no multi-trailer issue) + let updateResult = try await harness.run( + arguments: ["update", "lib"], + workingDirectory: local.path + ) + + #expect(updateResult.exitCode == 0, "Update should succeed: \(updateResult.stdout)\(updateResult.stderr)") + #expect(updateResult.stdout.contains("Updated lib"), "Should show updated message") + } + + @Test("resync commit has correct single-prefix trailer") + func testResyncCommitFormat() async throws { + // Create two upstream repos + let upstreamA = try await createUpstreamRepo(name: "libA", tag: "v1.0") + defer { try? upstreamA.tearDown() } + let upstreamB = try await createUpstreamRepo(name: "libB", tag: "v1.0") + defer { try? upstreamB.tearDown() } + + // Create consumer repo + let local = try await GitRepositoryFixture() + defer { try? local.tearDown() } + + // Init and add both subtrees + _ = try await harness.run(arguments: ["init"], workingDirectory: local.path) + _ = try await harness.run( + arguments: ["add", "--remote", fileURL(for: upstreamA), "--name", "libA", + "--prefix", "Vendor/libA", "--ref", "v1.0"], + workingDirectory: local.path + ) + _ = try await harness.run( + arguments: ["add", "--remote", fileURL(for: upstreamB), "--name", "libB", + "--prefix", "Vendor/libB", "--ref", "v1.0"], + workingDirectory: local.path + ) + + // Create multi-trailer commit + let commitHashA = try await getTagCommit(repo: upstreamA, tag: "v1.0") + let commitHashB = try await getTagCommit(repo: upstreamB, tag: "v1.0") + let multiTrailerMessage = """ + combined subtree adds + + git-subtree-dir: Vendor/libA + git-subtree-split: \(commitHashA) + git-subtree-dir: Vendor/libB + git-subtree-split: \(commitHashB) + """ + try await local.runGit(["commit", "--allow-empty", "-m", multiTrailerMessage]) + + let commitsBefore = try await local.getCommitCount() + + // Add upstream update and run update + try await addUpstreamUpdate(repo: upstreamA, tag: "v2.0") + let updateResult = try await harness.run( + arguments: ["update", "libA"], + workingDirectory: local.path + ) + #expect(updateResult.exitCode == 0, "Update should succeed") + + // Verify a resync commit was created (commit count should be higher than expected) + // Expected: +1 resync + 1 subtree pull merge + amend = at least commitsBefore + 2 + let commitsAfter = try await local.getCommitCount() + #expect(commitsAfter > commitsBefore, "Should have new commits from resync + update") + + // Verify the resync commit exists with correct format + let log = try await local.runGit(["log", "--all", "--oneline", "--grep=git-subtree-dir: Vendor/libA"]) + #expect(log.contains("Squashed 'Vendor/libA/'"), "Should contain resync commit") + } +}