From a06ae2a4f0d941064d95590642d13d12bfe9b26f Mon Sep 17 00:00:00 2001 From: Sebastian <5648131+polyrainbow@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:29:41 +0100 Subject: [PATCH] fix: retry a rejected push even when git prints no hints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `git push --porcelain` reports a rejected ref on stdout; stderr only carries the "Updates were rejected" hints, and those can be switched off with advice.* — as on the CI runner. The classifier read stderr alone, so there a rejected push was "other" instead of "rejected": no pull, no retry, and the commits waited for the next scheduled pull. Classify on both streams. Also report UTC as a timezone offset of 0 rather than -0. Co-Authored-By: Claude Opus 5.5 --- electron/git/GitRepo.spec.ts | 16 ++++++++++++++++ electron/git/GitRepo.ts | 3 ++- electron/git/GitSync.spec.ts | 28 ++++++++++++++++++++++++++++ electron/git/classifyError.ts | 2 +- electron/git/runGit.ts | 16 +++++++++++++++- 5 files changed, 62 insertions(+), 3 deletions(-) diff --git a/electron/git/GitRepo.spec.ts b/electron/git/GitRepo.spec.ts index 0fab0678..451a1aa4 100644 --- a/electron/git/GitRepo.spec.ts +++ b/electron/git/GitRepo.spec.ts @@ -195,6 +195,22 @@ describe("GitRepo", () => { expect(log[0].timezoneOffset).toBe(new Date().getTimezoneOffset()); }); + it("reports UTC as a timezone offset of 0, not -0", async () => { + await writeFile(dir, "a.subtext", "A"); + await repo.commit({ + canonicalNoteSlugs: ["a"], + aliases: [], + arbitraryFiles: [], + flushPins: false, + }, AUTHOR); + git(dir, "commit", "--quiet", "--amend", "--no-edit", + "--date=1700000000 +0000"); + + const [commit] = await repo.log({ limit: 1, offset: 0 }); + + expect(Object.is(commit.timezoneOffset, 0)).toBe(true); + }); + it("pages through the history", async () => { await save("a", "1"); await save("a", "2"); diff --git a/electron/git/GitRepo.ts b/electron/git/GitRepo.ts index d9937fd1..23963961 100644 --- a/electron/git/GitRepo.ts +++ b/electron/git/GitRepo.ts @@ -54,7 +54,8 @@ function toTimezoneOffset(zone: string): number { const match = /^([+-])(\d{2})(\d{2})$/.exec(zone ?? ""); if (!match) return 0; const minutes = Number(match[2]) * 60 + Number(match[3]); - return match[1] === "+" ? -minutes : minutes; + // 0 - x, not -x: UTC must come out as 0, not -0. + return match[1] === "+" ? 0 - minutes : minutes; } /** Splits NUL-separated output into its non-empty fields. */ diff --git a/electron/git/GitSync.spec.ts b/electron/git/GitSync.spec.ts index 3643ef20..eb547cb5 100644 --- a/electron/git/GitSync.spec.ts +++ b/electron/git/GitSync.spec.ts @@ -182,6 +182,34 @@ describe("GitSync", { timeout: 30_000 }, () => { }); }); + it("retries a rejected push when git prints no hints", async () => { + /* + With --porcelain, the "[rejected]" verdict goes to stdout; stderr + only carries the hints, which git config can switch off. + */ + const sync = makeSync(a, { + repo: new GitRepo({ + dir: a, + gitPath: GIT, + extraConfig: [ + "advice.pushUpdateRejected=false", + "advice.pushFetchFirst=false", + "advice.pushNonFFCurrent=false", + ], + }), + }); + await sync.start(); + await sync.setAutoSync(true); + await pushFromB("from-b.subtext", "B"); + + await saveOnA(sync, "from-a", "A"); + await sync.whenIdle(); + + expect(git(remote, "log", "-1", "--format=%s", "main")) + .toBe("create: from-a"); + expect(sync.getStatus().unpushedCommits).toBe(0); + }); + it("waits while the editor has unsaved changes", async () => { let dirty = true; const sync = makeSync(a, { isEditorDirty: () => dirty }); diff --git a/electron/git/classifyError.ts b/electron/git/classifyError.ts index e470f93f..ba1eade6 100644 --- a/electron/git/classifyError.ts +++ b/electron/git/classifyError.ts @@ -42,7 +42,7 @@ const OFFLINE = [ export default function classifyError(error: unknown): NetworkFailure { if (!(error instanceof GitError)) return "other"; if (error.killed) return "offline"; - const text = error.stderr; + const text = `${error.stderr}\n${error.stdout}`; if (AUTH.some((pattern) => pattern.test(text))) return "auth"; if (REJECTED.some((pattern) => pattern.test(text))) return "rejected"; if (OFFLINE.some((pattern) => pattern.test(text))) return "offline"; diff --git a/electron/git/runGit.ts b/electron/git/runGit.ts index 6ce5570d..92732e78 100644 --- a/electron/git/runGit.ts +++ b/electron/git/runGit.ts @@ -38,6 +38,12 @@ export class GitError extends Error { readonly args: string[]; readonly exitCode: number | null; readonly stderr: string; + /* + Some verdicts only appear here: `push --porcelain` reports a rejected + ref on stdout, and stderr gets nothing but hints, which git config + can switch off. + */ + readonly stdout: string; /** Killed for running over its time limit or by an abort. */ readonly killed: boolean; @@ -46,6 +52,7 @@ export class GitError extends Error { exitCode: number | null, stderr: string, killed: boolean, + stdout = "", ) { const summary = stderr.trim().split("\n").slice(-3).join("\n"); super( @@ -56,6 +63,7 @@ export class GitError extends Error { this.args = args; this.exitCode = exitCode; this.stderr = stderr; + this.stdout = stdout; this.killed = killed; } } @@ -186,7 +194,13 @@ export function createGitRunner(options: { const stderrText = Buffer.concat(stderr).toString("utf8"); const okCodes = runOptions.okCodes ?? [0]; if (killed || code === null || !okCodes.includes(code)) { - reject(new GitError(args, code, stderrText, killed)); + reject(new GitError( + args, + code, + stderrText, + killed, + Buffer.concat(stdout).toString("utf8"), + )); return; } resolve({ code, stdout: Buffer.concat(stdout), stderr: stderrText });