diff --git a/Sources/GitKit/CallbacksPayload.swift b/Sources/GitKit/CallbacksPayload.swift index c111b84..c2277dc 100644 --- a/Sources/GitKit/CallbacksPayload.swift +++ b/Sources/GitKit/CallbacksPayload.swift @@ -9,13 +9,89 @@ import CGitKit final class CallbackBox { let credentialsProvider: CredentialProvider? var reporter: ProgressReporter? + let pushLease: PushLeaseCheck? - init(credentials: CredentialProvider?, reporter: ProgressReporter?) { + init( + credentials: CredentialProvider?, + reporter: ProgressReporter?, + pushLease: PushLeaseCheck? = nil + ) { self.credentialsProvider = credentials self.reporter = reporter + self.pushLease = pushLease } } +final class PushLeaseCheck { + let remoteRef: String + let expectedOID: git_oid? + var failure: Libgit2Error? + + init(remoteRef: String, expectedOID: git_oid?) { + self.remoteRef = remoteRef + self.expectedOID = expectedOID + } + + func verify( + updates: UnsafeMutablePointer?>?, + count: Int + ) -> Int32 { + guard let updates else { + failure = leaseError(message: "force-with-lease rejected \(remoteRef): push did not negotiate updates") + return GIT_EMODIFIED.rawValue + } + + for i in 0.. Libgit2Error { + Libgit2Error( + code: GIT_EMODIFIED.rawValue, + klass: Int32(GIT_ERROR_REFERENCE.rawValue), + message: message) + } +} + +private func oidDescription(_ oid: inout git_oid) -> String { + if git_oid_is_zero(&oid) != 0 { return "missing" } + let buf = UnsafeMutablePointer.allocate(capacity: 41) + defer { buf.deallocate() } + buf.initialize(repeating: 0, count: 41) + _ = git_oid_tostr(buf, 41, &oid) + return String(cString: buf) +} + /// Run `body` with a shared raw payload pointer plus the C trampolines /// each callback slot wants. The reporter's final state is synced back /// to `outReporter` so the caller can flush pending lines. @@ -25,6 +101,7 @@ final class CallbackBox { func withCallbacksPayload( credentials: CredentialProvider?, reporter: ProgressReporter?, + pushLease: PushLeaseCheck? = nil, _ body: ( _ credentialsCB: git_credential_acquire_cb?, _ sidebandCB: git_transport_message_cb?, @@ -35,14 +112,16 @@ func withCallbacksPayload( _ pushRefCB: git_push_update_reference_cb?, _ packCB: git_packbuilder_progress?, _ pushTransferCB: git_push_transfer_progress_cb?, + _ pushNegotiationCB: git_push_negotiation?, _ payload: UnsafeMutableRawPointer? ) throws -> T, outReporter: (inout ProgressReporter) -> Void = { _ in } ) rethrows -> T { - if credentials == nil && reporter == nil { - return try body(nil, nil, nil, nil, nil, nil, nil, nil) + if credentials == nil && reporter == nil && pushLease == nil { + return try body(nil, nil, nil, nil, nil, nil, nil, nil, nil) } - let box = CallbackBox(credentials: credentials, reporter: reporter) + let box = CallbackBox( + credentials: credentials, reporter: reporter, pushLease: pushLease) let raw = Unmanaged.passRetained(box).toOpaque() defer { if var r = box.reporter { outReporter(&r); box.reporter = r } @@ -56,8 +135,9 @@ func withCallbacksPayload( let pushRefCB = reporter != nil ? combinedPushRefTrampoline : nil let packCB = reporter != nil ? combinedPackProgressTrampoline : nil let pushTransferCB = reporter != nil ? combinedPushTransferTrampoline : nil + let pushNegotiationCB = pushLease != nil ? combinedPushNegotiationTrampoline : nil return try body(credCB, sidebandCB, transferCB, updateCB, pushRefCB, - packCB, pushTransferCB, raw) + packCB, pushTransferCB, pushNegotiationCB, raw) } // MARK: Single-branch clone @@ -376,6 +456,14 @@ private let combinedPushTransferTrampoline: git_push_transfer_progress_cb = { return 0 } +private let combinedPushNegotiationTrampoline: git_push_negotiation = { + updates, len, payload in + guard let payload else { return 0 } + let box = Unmanaged.fromOpaque(payload).takeUnretainedValue() + guard let pushLease = box.pushLease else { return 0 } + return pushLease.verify(updates: updates, count: Int(len)) +} + private func humanBytes(_ bytes: Int) -> String { let kib = 1024.0 let mib = kib * 1024.0 diff --git a/Sources/GitKit/Repository+Clone.swift b/Sources/GitKit/Repository+Clone.swift index 48f014a..3d11ae3 100644 --- a/Sources/GitKit/Repository+Clone.swift +++ b/Sources/GitKit/Repository+Clone.swift @@ -69,7 +69,7 @@ extension Repository { func runClone() throws { try withCallbacksPayload( credentials: credentials, reporter: reporter, - { credCB, sidebandCB, transferCB, _, _, _, _, payload in + { credCB, sidebandCB, transferCB, _, _, _, _, _, payload in opts.fetch_opts.callbacks.credentials = credCB opts.fetch_opts.callbacks.sideband_progress = sidebandCB opts.fetch_opts.callbacks.transfer_progress = transferCB @@ -176,7 +176,7 @@ extension Repository { opts.depth = rawDepth try withCallbacksPayload( credentials: credentials, reporter: reporter, - { credCB, sidebandCB, transferCB, updateCB, _, _, _, payload in + { credCB, sidebandCB, transferCB, updateCB, _, _, _, _, payload in opts.callbacks.credentials = credCB opts.callbacks.sideband_progress = sidebandCB opts.callbacks.transfer_progress = transferCB diff --git a/Sources/GitKit/Repository+Push.swift b/Sources/GitKit/Repository+Push.swift new file mode 100644 index 0000000..5c171e5 --- /dev/null +++ b/Sources/GitKit/Repository+Push.swift @@ -0,0 +1,310 @@ +import Foundation +import CGitKit + +/// The lease expectation used by force-with-lease pushes. +public enum PushLease: Sendable, Equatable { + /// Mirror `git push --force-with-lease`: expect the destination branch + /// to match `refs/remotes//`. If that tracking ref is + /// absent, the remote branch must also be absent. + case tracking + /// Expect the destination ref to still point at this full 40-character + /// object ID. + case expecting(String) +} + +extension Repository { + + /// Push `refspec` to `remote`. Mirrors `git push ` + /// including its progress output and per-ref summary lines. + /// + /// - Parameters: + /// - remote: The remote name to push to (e.g. `"origin"`). + /// - refspec: The refspec to push (e.g. `"main"` or + /// `"refs/heads/main:refs/heads/main"`). + /// - setUpstream: When `true`, configure the pushed branch's upstream + /// afterwards — the `git push -u` semantics libgit2 doesn't bundle. + /// - credentials: Invoked by the transport on auth challenges. + /// - progress: Sink for real-git-style progress lines (defaults to + /// the process's stderr). + public func push( + remote: String, + refspec: String, + setUpstream: Bool, + credentials: CredentialProvider? = nil, + progress: @escaping @Sendable (String) -> Void = GitProgress.standardError + ) throws { + try pushImpl( + remote: remote, refspec: refspec, setUpstream: setUpstream, + forceWithLease: nil, credentials: credentials, progress: progress) + } + + /// Push `refspec` to `remote` with `--force-with-lease` semantics. + /// + /// The refspec is force-pushed, but only after the remote advertises + /// the expected destination ref value on the same push connection. + /// This preserves the race protection of `git push --force-with-lease`. + /// + /// - Parameters: + /// - remote: The remote name to push to (e.g. `"origin"`). + /// - refspec: The refspec to push (e.g. `"main"` or + /// `"refs/heads/main:refs/heads/main"`). + /// - setUpstream: When `true`, configure the pushed branch's upstream + /// after a successful push. + /// - forceWithLease: The lease expectation to verify before sending + /// the forced update. + /// - credentials: Invoked by the transport on auth challenges. + /// - progress: Sink for real-git-style progress lines (defaults to + /// the process's stderr). + public func push( + remote: String, + refspec: String, + setUpstream: Bool, + forceWithLease lease: PushLease, + credentials: CredentialProvider? = nil, + progress: @escaping @Sendable (String) -> Void = GitProgress.standardError + ) throws { + try pushImpl( + remote: remote, refspec: refspec, setUpstream: setUpstream, + forceWithLease: lease, credentials: credentials, progress: progress) + } + + private func pushImpl( + remote: String, + refspec: String, + setUpstream: Bool, + forceWithLease lease: PushLease?, + credentials: CredentialProvider?, + progress: @escaping @Sendable (String) -> Void + ) throws { + var remoteHandle: OpaquePointer? + try check(git_remote_lookup(&remoteHandle, repo, remote)) + defer { git_remote_free(remoteHandle) } + + let remoteURL = git_remote_url(remoteHandle).map { String(cString: $0) } + var reporter = ProgressReporter( + headerURL: remoteURL, direction: .push, output: progress) + reporter.suppressTransferProgress = + ProgressReporter.isLocalURL(remoteURL) + + let qualifiedRefspec = try qualifiedPushRefspec(refspec) + let pushRefspec = lease == nil + ? qualifiedRefspec + : forcePushRefspec(qualifiedRefspec) + let pushLease = try lease.map { + try pushLeaseCheck($0, remote: remote, refspec: pushRefspec) + } + + try pushRefspec.withCString { cstr in + var copy: UnsafeMutablePointer? = strdup(cstr) + defer { free(copy) } + try withUnsafeMutablePointer(to: ©) { copyPtr in + var arr = git_strarray(strings: copyPtr, count: 1) + var opts = git_push_options() + try check(git_push_options_init(&opts, UInt32(GIT_PUSH_OPTIONS_VERSION))) + try withCallbacksPayload( + credentials: credentials, reporter: reporter, + pushLease: pushLease, + { + credCB, sidebandCB, _, _, pushRefCB, packCB, + pushTransferCB, pushNegotiationCB, payload in + opts.callbacks.credentials = credCB + opts.callbacks.sideband_progress = sidebandCB + opts.callbacks.push_update_reference = pushRefCB + opts.callbacks.pack_progress = packCB + opts.callbacks.push_transfer_progress = pushTransferCB + opts.callbacks.push_negotiation = pushNegotiationCB + opts.callbacks.payload = payload + let rc = git_remote_push(remoteHandle, &arr, &opts) + if rc < 0, let failure = pushLease?.failure { + throw failure + } + try check(rc) + }, + outReporter: { reporter = $0 }) + } + } + + reporter.flushRefLines() + + // libgit2 doesn't have a one-shot "push -u": after a successful + // push, write the upstream config ourselves to mirror the CLI's + // `--set-upstream` semantics. + if setUpstream { + try setUpstreamForRefspec(remote: remote, refspec: pushRefspec) + } + } + + /// libgit2's push parser expects fully-qualified refs where the CLI + /// accepts local branch shorthands like `main` or `topic:other`. + private func qualifiedPushRefspec(_ refspec: String) throws -> String { + let force = refspec.hasPrefix("+") + let body = force ? String(refspec.dropFirst()) : refspec + let prefix = force ? "+" : "" + + let colon = body.firstIndex(of: ":") + let src = colon.map { String(body[..<$0]) } ?? body + guard !src.isEmpty, !src.hasPrefix("refs/") else { return refspec } + + let (qualifiedSrc, dstPrefix) = try qualifiedPushSource(src) + guard let colon else { + return "\(prefix)\(qualifiedSrc):\(qualifiedSrc)" + } + + let dstStart = body.index(after: colon) + let dst = String(body[dstStart...]) + let qualifiedDst = dst.isEmpty || dst.hasPrefix("refs/") + ? dst + : "\(dstPrefix)\(dst)" + return "\(prefix)\(qualifiedSrc):\(qualifiedDst)" + } + + private func forcePushRefspec(_ refspec: String) -> String { + refspec.hasPrefix("+") ? refspec : "+\(refspec)" + } + + private func pushLeaseCheck( + _ lease: PushLease, + remote: String, + refspec: String + ) throws -> PushLeaseCheck { + guard let destination = destinationRefName(fromPushRefspec: refspec) else { + throw Libgit2Error( + code: GIT_EINVALIDSPEC.rawValue, + klass: Int32(GIT_ERROR_INVALID.rawValue), + message: "force-with-lease requires a destination ref") + } + + let expectedOID: git_oid? + switch lease { + case .tracking: + guard let branch = localBranchName(from: destination) else { + throw Libgit2Error( + code: GIT_EINVALIDSPEC.rawValue, + klass: Int32(GIT_ERROR_INVALID.rawValue), + message: "force-with-lease tracking requires a branch destination") + } + expectedOID = try oidForReference("refs/remotes/\(remote)/\(branch)") + case .expecting(let sha): + expectedOID = try parseFullOID(sha, label: "force-with-lease expected oid") + } + + return PushLeaseCheck(remoteRef: destination, expectedOID: expectedOID) + } + + private func destinationRefName(fromPushRefspec refspec: String) -> String? { + let body = refspec.hasPrefix("+") + ? String(refspec.dropFirst()) + : refspec + + guard let colon = body.firstIndex(of: ":") else { + return body.isEmpty ? nil : body + } + + let dstStart = body.index(after: colon) + let dst = String(body[dstStart...]) + if !dst.isEmpty { return dst } + + let src = String(body[.. (String, String) { + let branch = "refs/heads/\(src)" + let tag = "refs/tags/\(src)" + let hasBranch = try referenceExists(branch) + let hasTag = try referenceExists(tag) + + if hasBranch && hasTag { + throw Libgit2Error( + code: GIT_ERROR.rawValue, + klass: Int32(GIT_ERROR_REFERENCE.rawValue), + message: "src refspec \(src) matches more than one") + } + if hasBranch { return (branch, "refs/heads/") } + if hasTag { return (tag, "refs/tags/") } + + throw Libgit2Error( + code: GIT_ERROR.rawValue, + klass: Int32(GIT_ERROR_REFERENCE.rawValue), + message: "src refspec \(src) does not match any") + } + + private func referenceExists(_ name: String) throws -> Bool { + var ref: OpaquePointer? + let rc = git_reference_lookup(&ref, repo, name) + if rc == 0 { + git_reference_free(ref) + return true + } + if rc == GIT_ENOTFOUND.rawValue { return false } + try check(rc) + return false + } + + private func oidForReference(_ name: String) throws -> git_oid? { + var ref: OpaquePointer? + let rc = git_reference_lookup(&ref, repo, name) + if rc == GIT_ENOTFOUND.rawValue { return nil } + try check(rc) + defer { git_reference_free(ref) } + + var resolved: OpaquePointer? + try check(git_reference_resolve(&resolved, ref)) + defer { git_reference_free(resolved) } + + guard let target = git_reference_target(resolved) else { return nil } + return target.pointee + } + + private func parseFullOID(_ sha: String, label: String) throws -> git_oid { + var oid = git_oid() + let rc = sha.withCString { git_oid_fromstrp(&oid, $0) } + if rc < 0 { + throw Libgit2Error( + code: GIT_EINVALID.rawValue, + klass: Int32(GIT_ERROR_INVALID.rawValue), + message: "invalid \(label): \(sha)") + } + return oid + } + + /// `:` form has both sides; bare ref like `main` means + /// `refs/heads/main:refs/heads/main`. Use the local side as the branch + /// being configured, and the destination side as its upstream merge ref. + private func setUpstreamForRefspec(remote: String, refspec: String) throws { + let body = refspec.hasPrefix("+") + ? String(refspec.dropFirst()) + : refspec + + let src: String + let dst: String + if let colon = body.firstIndex(of: ":") { + src = String(body[.. String? { + let prefix = "refs/heads/" + if ref.hasPrefix(prefix) { + let name = String(ref.dropFirst(prefix.count)) + return name.isEmpty ? nil : name + } + return ref.isEmpty || ref.hasPrefix("refs/") ? nil : ref + } +} diff --git a/Sources/GitKit/Repository.swift b/Sources/GitKit/Repository.swift index 0fdaa6c..22d2567 100644 --- a/Sources/GitKit/Repository.swift +++ b/Sources/GitKit/Repository.swift @@ -207,69 +207,6 @@ public final class Repository { try check(git_repository_set_head_detached(repo, oid)) } - /// Push `refspec` to `remote`. Mirrors `git push ` - /// including its progress output and per-ref summary lines. - /// - /// - Parameters: - /// - remote: The remote name to push to (e.g. `"origin"`). - /// - refspec: The refspec to push (e.g. `"main"` or - /// `"refs/heads/main:refs/heads/main"`). - /// - setUpstream: When `true`, configure the pushed branch's upstream - /// afterwards — the `git push -u` semantics libgit2 doesn't bundle. - /// - credentials: Invoked by the transport on auth challenges. - /// - progress: Sink for real-git-style progress lines (defaults to - /// the process's stderr). - public func push( - remote: String, - refspec: String, - setUpstream: Bool, - credentials: CredentialProvider? = nil, - progress: @escaping @Sendable (String) -> Void = GitProgress.standardError - ) throws { - var remoteHandle: OpaquePointer? - try check(git_remote_lookup(&remoteHandle, repo, remote)) - defer { git_remote_free(remoteHandle) } - - let remoteURL = git_remote_url(remoteHandle).map { String(cString: $0) } - var reporter = ProgressReporter( - headerURL: remoteURL, direction: .push, output: progress) - reporter.suppressTransferProgress = - ProgressReporter.isLocalURL(remoteURL) - - let pushRefspec = try qualifiedPushRefspec(refspec) - - try pushRefspec.withCString { cstr in - var copy: UnsafeMutablePointer? = strdup(cstr) - defer { free(copy) } - try withUnsafeMutablePointer(to: ©) { copyPtr in - var arr = git_strarray(strings: copyPtr, count: 1) - var opts = git_push_options() - try check(git_push_options_init(&opts, UInt32(GIT_PUSH_OPTIONS_VERSION))) - try withCallbacksPayload( - credentials: credentials, reporter: reporter, - { credCB, sidebandCB, _, _, pushRefCB, packCB, pushTransferCB, payload in - opts.callbacks.credentials = credCB - opts.callbacks.sideband_progress = sidebandCB - opts.callbacks.push_update_reference = pushRefCB - opts.callbacks.pack_progress = packCB - opts.callbacks.push_transfer_progress = pushTransferCB - opts.callbacks.payload = payload - try check(git_remote_push(remoteHandle, &arr, &opts)) - }, - outReporter: { reporter = $0 }) - } - } - - reporter.flushRefLines() - - // libgit2 doesn't have a one-shot "push -u": after a successful - // push, write the upstream config ourselves to mirror the CLI's - // `--set-upstream` semantics. - if setUpstream { - try setUpstreamForRefspec(remote: remote, refspec: pushRefspec) - } - } - /// Create remote `name` pointing at `url`, with the default fetch /// refspec. Mirrors `git remote add `. public func addRemote(name: String, url: URL) throws { @@ -472,103 +409,6 @@ public final class Repository { // MARK: Internals - /// libgit2's push parser expects fully-qualified refs where the CLI - /// accepts local branch shorthands like `main` or `topic:other`. - private func qualifiedPushRefspec(_ refspec: String) throws -> String { - let force = refspec.hasPrefix("+") - let body = force ? String(refspec.dropFirst()) : refspec - let prefix = force ? "+" : "" - - let colon = body.firstIndex(of: ":") - let src = colon.map { String(body[..<$0]) } ?? body - guard !src.isEmpty, !src.hasPrefix("refs/") else { return refspec } - - let (qualifiedSrc, dstPrefix) = try qualifiedPushSource(src) - guard let colon else { - return "\(prefix)\(qualifiedSrc):\(qualifiedSrc)" - } - - let dstStart = body.index(after: colon) - let dst = String(body[dstStart...]) - let qualifiedDst = dst.isEmpty || dst.hasPrefix("refs/") - ? dst - : "\(dstPrefix)\(dst)" - return "\(prefix)\(qualifiedSrc):\(qualifiedDst)" - } - - private func qualifiedPushSource(_ src: String) throws -> (String, String) { - let branch = "refs/heads/\(src)" - let tag = "refs/tags/\(src)" - let hasBranch = try referenceExists(branch) - let hasTag = try referenceExists(tag) - - if hasBranch && hasTag { - throw Libgit2Error( - code: GIT_ERROR.rawValue, - klass: Int32(GIT_ERROR_REFERENCE.rawValue), - message: "src refspec \(src) matches more than one") - } - if hasBranch { return (branch, "refs/heads/") } - if hasTag { return (tag, "refs/tags/") } - - throw Libgit2Error( - code: GIT_ERROR.rawValue, - klass: Int32(GIT_ERROR_REFERENCE.rawValue), - message: "src refspec \(src) does not match any") - } - - private func referenceExists(_ name: String) throws -> Bool { - var ref: OpaquePointer? - let rc = git_reference_lookup(&ref, repo, name) - if rc == 0 { - git_reference_free(ref) - return true - } - if rc == GIT_ENOTFOUND.rawValue { return false } - try check(rc) - return false - } - - /// `:` form has both sides; bare ref like `main` means - /// `refs/heads/main:refs/heads/main`. Use the local side as the branch - /// being configured, and the destination side as its upstream merge ref. - private func setUpstreamForRefspec(remote: String, refspec: String) throws { - let body = refspec.hasPrefix("+") - ? String(refspec.dropFirst()) - : refspec - - let src: String - let dst: String - if let colon = body.firstIndex(of: ":") { - src = String(body[.. String? { - let prefix = "refs/heads/" - if ref.hasPrefix(prefix) { - let name = String(ref.dropFirst(prefix.count)) - return name.isEmpty ? nil : name - } - return ref.isEmpty || ref.hasPrefix("refs/") ? nil : ref - } - /// Run `body` with an array of heap-allocated C strings (one per /// input). The `strdup`'d copies are freed when `body` returns. /// Used to build `git_strarray` payloads — libgit2 keeps the diff --git a/Tests/GitKitTests/RepositoryTests.swift b/Tests/GitKitTests/RepositoryTests.swift index f680c8f..d2b7e63 100644 --- a/Tests/GitKitTests/RepositoryTests.swift +++ b/Tests/GitKitTests/RepositoryTests.swift @@ -227,6 +227,77 @@ struct RepositoryTests { #expect(upstreamMerge == "refs/heads/remote") } + @Test("push forceWithLease rejects stale leases and accepts matching leases") + func pushForceWithLease() throws { + let dir = try makeFixtureRepo() + defer { try? FileManager.default.removeItem(at: dir) } + let origin = try makeBareOrigin() + defer { try? FileManager.default.removeItem(at: origin) } + let other = FileManager.default.temporaryDirectory + .appendingPathComponent("RepositoryTests-other-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: other) } + + let repo = try Repository.open(at: dir) + try repo.addRemote(name: "origin", url: origin) + try repo.push(remote: "origin", refspec: "main", setUpstream: false, progress: { _ in }) + try runGit(["fetch", "origin", "main"], in: dir) + + let leaseSHA = try runGit(["rev-parse", "refs/remotes/origin/main"], in: dir) + .trimmingCharacters(in: .whitespacesAndNewlines) + + try Data("local\n".utf8).write(to: dir.appendingPathComponent("README.md")) + try runGit(["commit", "-am", "local"], in: dir) + let localSHA = try runGit(["rev-parse", "refs/heads/main"], in: dir) + .trimmingCharacters(in: .whitespacesAndNewlines) + + try runGit(["clone", "-b", "main", origin.path, other.path], + in: FileManager.default.temporaryDirectory) + try runGit(["config", "user.email", "test@example.com"], in: other) + try runGit(["config", "user.name", "Test"], in: other) + try Data("remote\n".utf8).write(to: other.appendingPathComponent("README.md")) + try runGit(["commit", "-am", "remote"], in: other) + try runGit(["push", "origin", "main"], in: other) + let advancedSHA = try runGit(["rev-parse", "refs/heads/main"], in: origin) + .trimmingCharacters(in: .whitespacesAndNewlines) + #expect(advancedSHA != leaseSHA) + + let staleRepo = try Repository.open(at: dir) + do { + try staleRepo.push( + remote: "origin", refspec: "main", setUpstream: false, + forceWithLease: .tracking, progress: { _ in }) + Issue.record("expected stale force-with-lease push to fail") + } catch let error as Libgit2Error { + #expect(error.code == GIT_EMODIFIED.rawValue) + #expect(error.message.contains("force-with-lease rejected refs/heads/main")) + } catch { + Issue.record("expected Libgit2Error, got \(error)") + } + + let stillAdvancedSHA = try runGit(["rev-parse", "refs/heads/main"], in: origin) + .trimmingCharacters(in: .whitespacesAndNewlines) + #expect(stillAdvancedSHA == advancedSHA) + + try staleRepo.push( + remote: "origin", refspec: "main", setUpstream: false, + forceWithLease: .expecting(advancedSHA), progress: { _ in }) + + let finalRemoteSHA = try runGit(["rev-parse", "refs/heads/main"], in: origin) + .trimmingCharacters(in: .whitespacesAndNewlines) + #expect(finalRemoteSHA == localSHA) + + try staleRepo.push( + remote: "origin", refspec: "main", setUpstream: false, + forceWithLease: .expecting(advancedSHA), progress: { _ in }) + try staleRepo.push( + remote: "origin", refspec: "main", setUpstream: false, + forceWithLease: .tracking, progress: { _ in }) + + let noOpRemoteSHA = try runGit(["rev-parse", "refs/heads/main"], in: origin) + .trimmingCharacters(in: .whitespacesAndNewlines) + #expect(noOpRemoteSHA == localSHA) + } + @Test("remoteURL returns nil for a missing remote") func remoteURLMissing() throws { let dir = try makeFixtureRepo()