From 106b3c20453ed9baa81924c78d1e114c0779da85 Mon Sep 17 00:00:00 2001 From: Abe Wheeler Date: Wed, 28 Oct 2026 00:22:14 -0500 Subject: [PATCH] Bound URL push tracking updates on Git 2.56 --- packages/filegrc/src/git.js | 69 ++++++++++++++++--------------- packages/filegrc/test/git.test.js | 63 +++++++++++++++++++++++++++- 2 files changed, 97 insertions(+), 35 deletions(-) diff --git a/packages/filegrc/src/git.js b/packages/filegrc/src/git.js index 0029b75..1f2123e 100644 --- a/packages/filegrc/src/git.js +++ b/packages/filegrc/src/git.js @@ -2750,25 +2750,28 @@ function captureManualPushTarget(root, before) { }; } +function remoteTrackingUpdates(target, commit) { + // Git 2.56 may already update this ref after an explicit URL push. + // Each attempt still compares the old value atomically. + return [...new Set([ + target.expectedTrackingCommit || "0000000000000000000000000000000000000000", + commit, + ])].map((expected) => [ + "update-ref", "-m", "FileGRC exact push", target.trackingRef, commit, expected, + ]); +} + function reconcileRemoteTracking(root, target, commit) { - try { - gitForWrite( - root, - [ - "update-ref", - "-m", - "FileGRC exact push", - target.trackingRef, - commit, - target.expectedTrackingCommit || - "0000000000000000000000000000000000000000", - ], - "reconcile the remote-tracking branch after a successful push", - ); - return null; - } catch (error) { - return `${error.message} The exact commit was pushed, but the local remote-tracking branch needs reconciliation with Git.`; + let failure; + for (const args of remoteTrackingUpdates(target, commit)) { + try { + gitForWrite(root, args, "reconcile the remote-tracking branch after a successful push"); + return null; + } catch (error) { + failure = error; + } } + return `${failure.message} The exact commit was pushed, but the local remote-tracking branch needs reconciliation with Git.`; } function exactPushArgs(target, commit) { @@ -2776,6 +2779,12 @@ function exactPushArgs(target, commit) { return [ "-c", "push.pushOption=", + // Keep the captured remote a URL match, then exclude the pushed ref from + // implicit fetch-refspec updates. Only our bounded CAS updates tracking. + "-c", + `remote.${target.remote}.pushurl=${target.url}`, + "-c", + `remote.${target.remote}.fetch=^${target.destination}`, "push", "--porcelain", "--no-follow-tags", @@ -3129,24 +3138,16 @@ async function pushConfiguredBranch( } async function reconcileRemoteTrackingAsync(root, target, commit) { - try { - await gitForWriteAsync( - root, - [ - "update-ref", - "-m", - "FileGRC exact push", - target.trackingRef, - commit, - target.expectedTrackingCommit || - "0000000000000000000000000000000000000000", - ], - "reconcile the remote-tracking branch after a successful push", - ); - return null; - } catch (error) { - return `${error.message} The exact commit was pushed, but the local remote-tracking branch needs reconciliation with Git.`; + let failure; + for (const args of remoteTrackingUpdates(target, commit)) { + try { + await gitForWriteAsync(root, args, "reconcile the remote-tracking branch after a successful push"); + return null; + } catch (error) { + failure = error; + } } + return `${failure.message} The exact commit was pushed, but the local remote-tracking branch needs reconciliation with Git.`; } function assertSafeAutomaticTransport(root, remote, options = {}) { diff --git a/packages/filegrc/test/git.test.js b/packages/filegrc/test/git.test.js index f5beee3..08ad5c1 100644 --- a/packages/filegrc/test/git.test.js +++ b/packages/filegrc/test/git.test.js @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { execFile } from "node:child_process"; +import { execFile, execFileSync } from "node:child_process"; import { access, chmod, mkdir, mkdtemp, readFile, rename, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -3299,3 +3299,64 @@ test("ignored JSON paths cannot declare authoritative evidence ownership", async assert.match(index.error.message, /unreadable data record.*malformed.json/); } }); + + +for (const mode of ["manual", "background"]) { + test(`${mode} pushes accept a tracking ref already updated to the pushed commit`, async (context) => { + const fixture = await makeTrunkGitFixture(context, "filegrc-push-tracking-equal-"); + let simulated = false; + const restore = setGitSubprocessObserverForTests(({ args }) => { + if (!simulated && args[0] === "update-ref" && args[3] === "refs/remotes/origin/main") { + simulated = true; + // Reproduce Git 2.56 updating a uniquely matching URL remote itself. + execFileSync("git", ["update-ref", args[3], args[4]], { cwd: fixture.root }); + } + }); + context.after(restore); + const ownerPath = join(fixture.root, "data/people/person-owner.json"); + const owner = JSON.parse(await readFile(ownerPath, "utf8")); + let commit; + if (mode === "manual") { + await writeJson(ownerPath, { ...owner, department: "Updated tracking" }); + await git(fixture.root, ["add", "data"]); + await git(fixture.root, ["commit", "-m", "Update source record"]); + const result = await pushWorkspace(fixture.root); + commit = result.commit; + assert.equal(result.trackingError, null); + assert.equal(result.trackingConfigured, true); + } else { + const mutation = await runBrowserMutation(fixture.root, { message: "Update source record" }, () => ( + updateResource(fixture.root, "person", owner.id, { ...owner, department: "Updated tracking" }) + )); + commit = mutation.synchronization.commit; + const synchronized = await waitForRepository(fixture.root, "synced"); + assert.equal(synchronized.ahead, 0); + } + assert.equal(simulated, true); + assert.equal((await git(fixture.root, ["rev-parse", "refs/remotes/origin/main"])).stdout.trim(), commit); + assert.equal((await git(fixture.remote, ["rev-parse", "refs/heads/main"])).stdout.trim(), commit); + }); +} + + +test("a first URL push cannot apply configured fetch mappings to unrelated local refs", async (context) => { + const fixture = await makeTrunkGitFixture(context, "filegrc-push-refmap-"); + const original = (await git(fixture.root, ["rev-parse", "HEAD"])).stdout.trim(); + await git(fixture.root, ["branch", "unrelated", original]); + await git(fixture.root, ["branch", "--unset-upstream"]); + await git(fixture.root, ["update-ref", "-d", "refs/remotes/origin/main"]); + await git(fixture.remote, ["update-ref", "-d", "refs/heads/main"]); + await git(fixture.root, ["config", "--replace-all", "remote.origin.fetch", "+refs/heads/main:refs/heads/unrelated"]); + await git(fixture.root, ["config", "--add", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*"]); + const ownerPath = join(fixture.root, "data/people/person-owner.json"); + const owner = JSON.parse(await readFile(ownerPath, "utf8")); + await writeJson(ownerPath, { ...owner, department: "Bounded push" }); + await git(fixture.root, ["add", "data"]); + await git(fixture.root, ["commit", "-m", "Create first pushed change"]); + const pushed = await pushWorkspace(fixture.root); + assert.notEqual(pushed.commit, original); + assert.equal(pushed.trackingError, null); + assert.equal((await git(fixture.root, ["rev-parse", "refs/heads/unrelated"])).stdout.trim(), original); + assert.equal((await git(fixture.root, ["rev-parse", "refs/remotes/origin/main"])).stdout.trim(), pushed.commit); + assert.equal((await git(fixture.remote, ["rev-parse", "refs/heads/main"])).stdout.trim(), pushed.commit); +});