From 3f301864f1c88f495e28770519f49240a797da64 Mon Sep 17 00:00:00 2001 From: Oliver Drobnik Date: Wed, 15 Jul 2026 14:30:40 +0200 Subject: [PATCH 1/2] Allow detached HEAD as push source --- Sources/GitKit/Repository+Push.swift | 2 ++ Tests/GitKitTests/RepositoryTests.swift | 38 +++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/Sources/GitKit/Repository+Push.swift b/Sources/GitKit/Repository+Push.swift index 5c171e5..6b3eb55 100644 --- a/Sources/GitKit/Repository+Push.swift +++ b/Sources/GitKit/Repository+Push.swift @@ -209,6 +209,8 @@ extension Repository { } private func qualifiedPushSource(_ src: String) throws -> (String, String) { + if src == "HEAD" { return (src, "refs/heads/") } + let branch = "refs/heads/\(src)" let tag = "refs/tags/\(src)" let hasBranch = try referenceExists(branch) diff --git a/Tests/GitKitTests/RepositoryTests.swift b/Tests/GitKitTests/RepositoryTests.swift index d2b7e63..010a6f4 100644 --- a/Tests/GitKitTests/RepositoryTests.swift +++ b/Tests/GitKitTests/RepositoryTests.swift @@ -164,6 +164,44 @@ struct RepositoryTests { #expect(remoteSHA == localSHA) } + @Test("push accepts detached HEAD and retains fast-forward protection") + func pushDetachedHEAD() throws { + let dir = try makeFixtureRepo() + defer { try? FileManager.default.removeItem(at: dir) } + let origin = try makeBareOrigin() + defer { try? FileManager.default.removeItem(at: origin) } + + let headSHA = try runGit(["rev-parse", "HEAD"], in: dir) + .trimmingCharacters(in: .whitespacesAndNewlines) + let repo = try Repository.open(at: dir) + try repo.addRemote(name: "origin", url: origin) + try repo.checkout(ref: headSHA) + #expect(try repo.currentBranch() == nil) + try repo.push( + remote: "origin", refspec: "HEAD:refs/heads/review-fixes", + setUpstream: false, progress: { _ in }) + + let pushedSHA = try runGit(["rev-parse", "refs/heads/review-fixes"], in: origin) + .trimmingCharacters(in: .whitespacesAndNewlines) + #expect(pushedSHA == headSHA) + + try Data("advanced\n".utf8).write(to: dir.appendingPathComponent("README.md")) + try runGit(["commit", "-am", "advance remote"], in: dir) + try runGit(["push", "origin", "HEAD:refs/heads/review-fixes"], in: dir) + let advancedSHA = try runGit(["rev-parse", "HEAD"], in: dir) + .trimmingCharacters(in: .whitespacesAndNewlines) + try repo.checkout(ref: headSHA) + + #expect(throws: Libgit2Error.self) { + try repo.push( + remote: "origin", refspec: "HEAD:refs/heads/review-fixes", + setUpstream: false, progress: { _ in }) + } + let protectedSHA = try runGit(["rev-parse", "refs/heads/review-fixes"], in: origin) + .trimmingCharacters(in: .whitespacesAndNewlines) + #expect(protectedSHA == advancedSHA) + } + @Test("push accepts a short tag refspec") func pushShortTag() throws { let dir = try makeFixtureRepo() From 500665490116d76d37a8322a4fae2ba3f4829c8a Mon Sep 17 00:00:00 2001 From: Oliver Drobnik Date: Wed, 15 Jul 2026 14:38:31 +0200 Subject: [PATCH 2/2] Match HEAD push qualification to Git --- Sources/GitKit/Repository+Push.swift | 5 ++++- Tests/GitKitTests/RepositoryTests.swift | 26 +++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/Sources/GitKit/Repository+Push.swift b/Sources/GitKit/Repository+Push.swift index 6b3eb55..0f2b5d9 100644 --- a/Sources/GitKit/Repository+Push.swift +++ b/Sources/GitKit/Repository+Push.swift @@ -209,7 +209,10 @@ extension Repository { } private func qualifiedPushSource(_ src: String) throws -> (String, String) { - if src == "HEAD" { return (src, "refs/heads/") } + if src == "HEAD" { + guard let branch = try currentBranch() else { return (src, "") } + return ("refs/heads/\(branch)", "refs/heads/") + } let branch = "refs/heads/\(src)" let tag = "refs/tags/\(src)" diff --git a/Tests/GitKitTests/RepositoryTests.swift b/Tests/GitKitTests/RepositoryTests.swift index 010a6f4..6374e95 100644 --- a/Tests/GitKitTests/RepositoryTests.swift +++ b/Tests/GitKitTests/RepositoryTests.swift @@ -177,6 +177,11 @@ struct RepositoryTests { try repo.addRemote(name: "origin", url: origin) try repo.checkout(ref: headSHA) #expect(try repo.currentBranch() == nil) + #expect(throws: Libgit2Error.self) { + try repo.push( + remote: "origin", refspec: "HEAD:review-fixes", + setUpstream: false, progress: { _ in }) + } try repo.push( remote: "origin", refspec: "HEAD:refs/heads/review-fixes", setUpstream: false, progress: { _ in }) @@ -202,6 +207,27 @@ struct RepositoryTests { #expect(protectedSHA == advancedSHA) } + @Test("push HEAD sets upstream for the attached branch") + func pushAttachedHEADSetUpstream() throws { + let dir = try makeFixtureRepo() + defer { try? FileManager.default.removeItem(at: dir) } + let origin = try makeBareOrigin() + defer { try? FileManager.default.removeItem(at: origin) } + + let repo = try Repository.open(at: dir) + try repo.addRemote(name: "origin", url: origin) + try repo.push( + remote: "origin", refspec: "HEAD:refs/heads/review-fixes", + setUpstream: true, progress: { _ in }) + + let upstreamRemote = try runGit(["config", "branch.main.remote"], in: dir) + .trimmingCharacters(in: .whitespacesAndNewlines) + let upstreamMerge = try runGit(["config", "branch.main.merge"], in: dir) + .trimmingCharacters(in: .whitespacesAndNewlines) + #expect(upstreamRemote == "origin") + #expect(upstreamMerge == "refs/heads/review-fixes") + } + @Test("push accepts a short tag refspec") func pushShortTag() throws { let dir = try makeFixtureRepo()