[Bug]: Localized Git hides the explanation for a rejected non-fast-forward push
Maintainers usually reply within 1 day
Nobody has claimed this yet.
Assessment
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Newbie friendliness
- 48/100
- Issue type
- Bug
- Clarity
- Mostly clear
- Activity status
- Active
- Tech stack
- git, node.js, typescript
Research direction
Start with apps/server/src/vcs/GitVcsDriver.ts and the pushCurrentBranch behavior described in the reproduction; compare how localized Git output is handled with the LANGUAGE=C control. Run the focused reproduction script from the issue to check the error classification, and inspect the existing non-fast-forward handling mentioned in the expected behavior. Done means German-localized rejected pushes retain the non-fast-forward reason while hook rejections remain classified.
Written by the indexing model from the issue text.
Description
Before submitting
- I searched existing issues and did not find a duplicate.
- I included enough detail to reproduce or investigate the problem.
Area
apps/server
Steps to reproduce
Use a checkout of the commit under Version or commit with its locked dependencies installed, on a machine whose git on PATH ships its normal translations (the observed case is Homebrew Git 2.56.0 on macOS; no custom GIT_TEXTDOMAINDIR or catalog is used). From the checkout root, run the script below with env -u NODE_ENV sh repro.sh "$PWD". It creates private HOME, TMPDIR and T3CODE_HOME directories, a local bare remote and two clones, and a temporary focused test (removed on exit) that uses the exported production GitVcsDriver.layer. The test reads status while the local branch is ahead 1 and behind 0, checks that the production Push menu logic enables Push, advances the remote from the other clone, and calls pushCurrentBranch three times: with the inherited German environment (LANG=de_DE.UTF-8, LC_ALL=de_DE.UTF-8, LANGUAGE=de), with LANG=de_DE.UTF-8 alone, and with a LANGUAGE=C control. It round-trips each error through the production GitCommandError wire codec, the client's consumeVcsActionProgress error path and the stackedThreadToast payload builder with the same arguments GitActionsControl.tsx passes. On macOS it also pushes with Apple's /usr/bin/git under the German environment, and it checks that a pre-receive hook rejection stays classified in both locales. It makes no network connection. The script exits 0 when the German case loses the reason and the English control keeps it, so its assertions pin the current defect.
pushCurrentBranch is the method GitManager's push wrapper calls. In the app, the equivalent trigger is another user pushing after T3 Code last read an ahead/not-behind status, followed by Push in T3 Code; a branch the UI already knows is behind has Push disabled, so the script deliberately keeps the stale-status precondition. A running client was not driven and no rendered toast was captured.
Reproduction script: repro.sh
#!/bin/sh
# Run from a dependency-installed T3 Code checkout, or pass its path as argument 1.
set -eu
checkout=${1:-$PWD}
case "$checkout" in /*) ;; *) checkout="$PWD/$checkout" ;; esac
scratch=$(mktemp -d "${REPRO_TMP_ROOT:-${TMPDIR:-/tmp}}/push-locale.XXXXXX")
probe="$checkout/apps/server/src/vcs/localePushRepro.tmp.test.ts"
test ! -e "$probe"
cleanup() { rm -f "$probe"; rm -rf "$scratch"; }
trap cleanup EXIT INT TERM
mkdir -p "$scratch/userhome" "$scratch/tmp" "$scratch/t3"
export HOME="$scratch/userhome" TMPDIR="$scratch/tmp" T3CODE_HOME="$scratch/t3"
export PATH="$checkout/node_modules/.bin:$PATH"
export LANG=de_DE.UTF-8 LC_ALL=de_DE.UTF-8 LANGUAGE=de
export GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null GIT_TERMINAL_PROMPT=0
unset GIT_TEXTDOMAINDIR NODE_ENV GIT_DIR GIT_WORK_TREE GIT_COMMON_DIR GIT_INDEX_FILE
cat > "$probe" <<'TS'
import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Stream from "effect/Stream";
import * as Schema from "effect/Schema";
import { EnvironmentId, GitCommandError } from "@t3tools/contracts";
import { consumeVcsActionProgress } from "../../../../packages/client-runtime/src/state/vcsAction.ts";
import { buildMenuItems, resolveQuickAction } from "../../../web/src/components/GitActionsControl.logic.ts";
import { stackedThreadToast } from "../../../web/src/components/ui/toastHelpers.ts";
import { execFileSync, spawnSync } from "node:child_process";
import * as fs from "node:fs";
import * as path from "node:path";
import * as HostProcess from "@t3tools/shared/HostProcess";
import * as ServerConfig from "../config.ts";
import * as GitVcsDriver from "./GitVcsDriver.ts";
const setupEnv = { ...process.env, LANGUAGE: "C", GIT_AUTHOR_NAME: "Repro", GIT_AUTHOR_EMAIL: "[email protected]", GIT_COMMITTER_NAME: "Repro", GIT_COMMITTER_EMAIL: "[email protected]" };
const git = (cwd: string, ...args: string[]) => execFileSync("git", args, { cwd, env: setupEnv, encoding: "utf8", stdio: "pipe" }).trim();
const layer = GitVcsDriver.layer.pipe(
Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "locale-repro-" })),
Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
);
it.live("compares real rejected pushes using the inherited locale and a C control", () => Effect.gen(function* () {
const root = fs.mkdtempSync(path.join(process.env.TMPDIR!, "fixture-"));
const remote = path.join(root, "remote.git"), a = path.join(root, "a"), b = path.join(root, "b");
git(root, "init", "--bare", "--initial-branch=main", remote);
git(root, "clone", remote, a);
git(a, "commit", "--allow-empty", "-m", "base");
git(a, "push", "--set-upstream", "origin", "main");
git(root, "clone", remote, b);
git(a, "commit", "--allow-empty", "-m", "local advance");
const driver = yield* GitVcsDriver.GitVcsDriver;
console.log("RUNTIME", JSON.stringify({ git: git(root, "--version"), node: process.version, platform: process.platform, LANG: process.env.LANG, LC_ALL: process.env.LC_ALL, LANGUAGE: process.env.LANGUAGE, customCatalog: process.env.GIT_TEXTDOMAINDIR ?? null }));
const status = yield* driver.statusDetails(a);
const toUiStatus = (s: typeof status) => ({ isRepo: true, hasPrimaryRemote: true, isDefaultRef: true, refName: s.branch, hasWorkingTreeChanges: false, workingTree: { files: [], insertions: 0, deletions: 0 }, hasUpstream: s.hasUpstream, aheadCount: s.aheadCount, behindCount: s.behindCount, pr: null });
assert.equal(buildMenuItems(toUiStatus(status), false).find(x => x.id === "push")?.disabled, false);
assert.equal(status.aheadCount, 1);
assert.equal(status.behindCount, 0);
console.log("UI_STATUS_BEFORE_REMOTE_ADVANCES", JSON.stringify({ branch: status.branch, ahead: status.aheadCount, behind: status.behindCount, hasUpstream: status.hasUpstream }));
// Remote advances after the client last read status; Push was enabled with ahead=1, behind=0.
git(b, "commit", "--allow-empty", "-m", "remote advance");
git(b, "push", "origin", "main");
const originalLocal = git(a, "rev-parse", "HEAD"), originalRemote = git(remote, "rev-parse", "main");
const inherited = yield* driver.pushCurrentBranch(a, null).pipe(Effect.flip);
const langOnlyEnv = Object.fromEntries(Object.entries(process.env).filter(([name]) => name !== "LANGUAGE" && !name.startsWith("LC_")));
langOnlyEnv.LANG = "de_DE.UTF-8";
const langOnly = yield* driver.pushCurrentBranch(a, null).pipe(Effect.provideService(HostProcess.Environment, langOnlyEnv), Effect.flip);
console.log("LANG_ONLY", JSON.stringify({ reason: langOnly.reason ?? null, detail: langOnly.detail }));
assert.equal(langOnly.reason, undefined);
const control = yield* driver.pushCurrentBranch(a, null).pipe(Effect.provideService(HostProcess.Environment, { ...process.env, LANGUAGE: "C" }), Effect.flip);
for (const [label, error] of [["LOCALIZED", inherited], ["CONTROL", control]] as const) {
console.log(label, JSON.stringify({ operation: error.operation, reason: error.reason ?? null, exitCode: error.exitCode, detail: error.detail, message: error.message.replace(a, "<repo>") }));
}
for (const [label, error] of [["LOCALIZED", inherited], ["CONTROL", control]] as const) {
const wire = Schema.encodeSync(Schema.toCodecJson(GitCommandError))(error);
const decoded = Schema.decodeUnknownSync(Schema.toCodecJson(GitCommandError))(wire);
const received = yield* consumeVcsActionProgress(Stream.fail(decoded), { target: { environmentId: EnvironmentId.make("verify"), cwd: a }, transportActionId: "verify", actionId: "verify", action: "push", onProgress: () => Effect.void }).pipe(Effect.flip);
assert.equal(received.message, error.message);
// Exact error-toast input from GitActionsControl.tsx:1283-1291, then its production helper.
const toast = stackedThreadToast({ type: "error", title: "Action failed", description: received.message, timeout: 0 });
console.log("TOAST_PAYLOAD_" + label, JSON.stringify({ ...toast, description: String(toast.description).replace(a, "<repo>") }));
assert.notProperty(toast, "actionProps");
}
git(a, "fetch", "origin");
const afterFailure = yield* driver.statusDetailsLocal(a);
console.log("UI_AFTER_EXPLICIT_FETCH", JSON.stringify({ pushDisabled: buildMenuItems(toUiStatus(afterFailure), false).find(x => x.id === "push")?.disabled, quickAction: resolveQuickAction(toUiStatus(afterFailure), false) }));
assert.equal(afterFailure.behindCount, 1);
assert.equal(resolveQuickAction(toUiStatus(afterFailure), false).disabled, true);
if (process.platform === "darwin") {
const appleVersion = execFileSync("/usr/bin/git", ["--version"], { encoding: "utf8", env: process.env }).trim();
const apple = yield* driver.pushCurrentBranch(a, null).pipe(Effect.provideService(HostProcess.Environment, { ...process.env, PATH: "/usr/bin:/bin:/usr/sbin:/sbin" }), Effect.flip);
console.log("APPLE_GIT_GERMAN_ENV", JSON.stringify({ git: appleVersion, reason: apple.reason ?? null, detail: apple.detail }));
assert.equal(apple.reason, "push_rejected_non_fast_forward");
}
const plainLocalized = spawnSync("git", ["push", "origin", "HEAD:refs/heads/main"], { cwd: a, encoding: "utf8", env: process.env });
const plainControl = spawnSync("git", ["push", "origin", "HEAD:refs/heads/main"], { cwd: a, encoding: "utf8", env: { ...process.env, LANGUAGE: "C" } });
for (const [label, result] of [["GIT_LOCALIZED", plainLocalized], ["GIT_CONTROL", plainControl]] as const) {
console.log(label, JSON.stringify({ code: result.status, stderr: result.stderr.replaceAll(root, "<fixture>") }));
}
assert.equal(inherited.reason, undefined);
assert.equal(inherited.exitCode, 1);
assert.equal(inherited.detail, "Git command exited with a non-zero status.");
assert.equal(control.reason, "push_rejected_non_fast_forward");
assert.include(control.detail, "Pull or rebase");
assert.equal(plainLocalized.status, 1);
assert.include(plainLocalized.stderr, "Fehler");
assert.include(plainControl.stderr, "error: failed to push some refs");
assert.equal(git(a, "rev-parse", "HEAD"), originalLocal);
assert.equal(git(remote, "rev-parse", "main"), originalRemote);
// Separate real server-hook rejection; does not depend on any assumed translated output.
fs.writeFileSync(path.join(remote, "hooks", "pre-receive"), "#!/bin/sh\nexit 1\n", { mode: 0o755 });
git(a, "fetch", "origin");
git(a, "reset", "--hard", "origin/main");
git(a, "commit", "--allow-empty", "-m", "hook rejection");
const hookLocalized = yield* driver.pushCurrentBranch(a, null).pipe(Effect.flip);
const hookControl = yield* driver.pushCurrentBranch(a, null).pipe(Effect.provideService(HostProcess.Environment, { ...process.env, LANGUAGE: "C" }), Effect.flip);
console.log("HOOK_LOCALIZED", JSON.stringify({ reason: hookLocalized.reason ?? null, detail: hookLocalized.detail }));
console.log("HOOK_CONTROL", JSON.stringify({ reason: hookControl.reason ?? null, detail: hookControl.detail }));
assert.equal(hookLocalized.reason, "push_rejected_by_remote");
assert.equal(hookControl.reason, "push_rejected_by_remote");
}).pipe(Effect.provide(layer), Effect.scoped));
TS
cd "$checkout"
git rev-parse HEAD
vp test run apps/server/src/vcs/localePushRepro.tmp.test.ts --silent=false --project t3
printf '%s\n' 'DEFECT PRESENT: localized real pushes lose reasons; C control classifies non-fast-forward; both locales classify hook rejection.'
Expected behavior
#22904 (fixing #22430) made a non-fast-forward push rejection carry reason: push_rejected_non_fast_forward and the detail "The remote has commits this branch doesn't have. Pull or rebase, then push again.", which the web and mobile clients show as the error message. The push-rejection classifier and fixed details implement it and the existing classifier test pins it with English output. The same rejected push should get the same reason and detail whatever language the installed Git prints its messages in, as the LANGUAGE=C control and Apple Git do.
Actual behavior
Summary. When the server's Git prints translated messages (for example Homebrew Git with a German locale), a push that the remote rejects as non-fast-forward fails with the generic detail "Git command exited with a non-zero status." and no reason, instead of the fixed explanation "The remote has commits this branch doesn't have. Pull or rebase, then push again." and reason: push_rejected_non_fast_forward that #22904 added. The push still fails safely: local and remote refs are unchanged, a push the remote's hook declines keeps its push_rejected_by_remote explanation in both locales, and the status-based "Sync ref" / "Rebase/merge first" hint still appears once status sees the divergence. The loss is the explanation text and reason tag only. Reach is limited to Git builds that ship translations and to non-fast-forward rejections that reach the server, such as a remote that advanced after the client last read an ahead/not-behind status.
Both real pushes exit 1 at GitVcsDriver.pushCurrentBranch.pushUpstream and leave local and remote refs unchanged. With the German environment, reason is absent and detail is "Git command exited with a non-zero status."; the same happens with only LANG=de_DE.UTF-8 set and LANGUAGE and every LC_* variable unset. With LANGUAGE=C, reason is push_rejected_non_fast_forward and detail is the "Pull or rebase" explanation. Apple Git 2.54.0 (Apple Git-157) prints English under the same German environment and keeps the explanation. The error message survives the wire codec and client stream unchanged, and the production toast payload is titled "Action failed" with the generic text in the German case and the explanation in the control; neither payload has an action button. After an explicit fetch, production status and menu logic disable Push and show the disabled "Sync ref" quick action with "Branch has diverged from upstream. Rebase/merge first." in either locale. A pre-receive hook rejection keeps push_rejected_by_remote in both locales.
Git keeps the ! [rejected] ... (non-fast-forward) status line in English but translates the following error: failed to push some refs line (German: Fehler: Fehler beim Versenden einiger Referenzen nach ...). The non-fast-forward pattern requires both English lines, so it no longer matches; the hook-rejection pattern needs only the untranslated [remote rejected] line, which is why that case still classifies.
Code locators, as breadcrumbs rather than a prescribed fix: the push calls use plain runGit, which reaches executeGit's classification through runGit without the LANGUAGE=C override that executeGitWithStableDiagnostics adds, so Git inherits the server's locale from HostProcess.Environment. The LANGUAGE=C default that #32252 added to VcsProcess does not cover this separate spawner. The UI error handler shows error.message. By source reading, t3 serve and the desktop backend both pass the user's LANG/LC_* (and an inherited LANGUAGE) through to the server process; neither was launched for this report.
Evidence
- Expected source: the push-rejection classifier and
GIT_FAILURE_REASON_DETAILSinapps/server/src/vcs/GitVcsDriverCore.tslines 475-498 from #22904, its English-output test inapps/server/src/vcs/GitVcsDriverCore.test.tslines 1780-1810, and theLANGUAGE=Ccontrol in the reproduction. - Failure source: the reproduction output under Logs or stack traces, produced by the production
GitVcsDriver.layerpushing to a real local remote with the installed Homebrew Git and its stock German translations. - Evidence provenance: observed
- Local verification: reproduced
- Reproduction completeness: complete
The script above passed (one focused test, exit 0) from a clean checkout at the commit under Version or commit, and an independent verification run of the same test also reproduced it on upstream main at 4947db07d319df031028338e71128b5e8ff45b1f. On upstream main at 4fd40e1a16e3800418b8f6a32932b68179098c8d, a source read shows the push calls still use plain runGit and the classifier pattern is unchanged; the script was not rerun there. A Linux distribution Git and a rendered desktop, web or mobile client were not tested.
Related
#22430 was closed by #22904, which added push-rejection explanations; this report is the remaining translated-output case of the non-fast-forward explanation. #4077 introduced executeGitWithStableDiagnostics for three status and ref-listing calls, #17794 routed missing-ref operations through it, #27778 changed it to LANGUAGE=C so hooks keep the user's locale, and #32252 (fixing #32251) defaulted VcsProcess children to LANGUAGE=C; none of them changes the push calls. Open PRs #24047, #29619 and #33557 change push remote selection or publication without touching this classification, and open PRs #33029 and #23269 add LANGUAGE=C only to submodule updates.
Restoration check
With the script's fixture and the inherited German environment, and again with LANG=de_DE.UTF-8 alone, the rejected pushCurrentBranch fails with reason: push_rejected_non_fast_forward and the detail "The remote has commits this branch doesn't have. Pull or rebase, then push again.", matching the LANGUAGE=C control, and the toast payload description carries that explanation. (The script's German-case assertions encode the current defect and will fail once it is fixed.) The push still exits 1 with local and remote refs unchanged, the hook rejection keeps push_rejected_by_remote in both locales, and the existing English-output classifier test keeps passing.
Impact
Cosmetic issue
Version or commit
T3 Code 0.1.9 at d9805b2ca667be7caec8c9035950564ab96704c3, rerun from a clean checkout.
Environment
macOS arm64, Node v26.12.1, Homebrew Git 2.56.0 with its installed German translations; Apple Git 2.54.0 (Apple Git-157) as the English-only comparison. Production server driver, codec, client stream and toast payload functions only; no running server, desktop app, browser or mobile client.
Logs or stack traces
Excerpt of the script's output (<repo> and <fixture> replace temporary paths):
RUNTIME {"git":"git version 2.56.0","node":"v26.12.1","platform":"darwin","LANG":"de_DE.UTF-8","LC_ALL":"de_DE.UTF-8","LANGUAGE":"de","customCatalog":null}
UI_STATUS_BEFORE_REMOTE_ADVANCES {"branch":"main","ahead":1,"behind":0,"hasUpstream":true}
LANG_ONLY {"reason":null,"detail":"Git command exited with a non-zero status."}
LOCALIZED {"operation":"GitVcsDriver.pushCurrentBranch.pushUpstream","reason":null,"exitCode":1,"detail":"Git command exited with a non-zero status.","message":"Git command failed in GitVcsDriver.pushCurrentBranch.pushUpstream (<repo>): Git command exited with a non-zero status."}
CONTROL {"operation":"GitVcsDriver.pushCurrentBranch.pushUpstream","reason":"push_rejected_non_fast_forward","exitCode":1,"detail":"The remote has commits this branch doesn't have. Pull or rebase, then push again.","message":"Git command failed in GitVcsDriver.pushCurrentBranch.pushUpstream (<repo>): The remote has commits this branch doesn't have. Pull or rebase, then push again. (push_rejected_non_fast_forward)"}
TOAST_PAYLOAD_LOCALIZED {"type":"error","title":"Action failed","data":{"actionLayout":"stacked-end"},"description":"Git command failed in GitVcsDriver.pushCurrentBranch.pushUpstream (<repo>): Git command exited with a non-zero status.","timeout":0}
TOAST_PAYLOAD_CONTROL {"type":"error","title":"Action failed","data":{"actionLayout":"stacked-end"},"description":"Git command failed in GitVcsDriver.pushCurrentBranch.pushUpstream (<repo>): The remote has commits this branch doesn't have. Pull or rebase, then push again. (push_rejected_non_fast_forward)","timeout":0}
UI_AFTER_EXPLICIT_FETCH {"pushDisabled":true,"quickAction":{"label":"Sync ref","disabled":true,"kind":"show_hint","hint":"Branch has diverged from upstream. Rebase/merge first."}}
APPLE_GIT_GERMAN_ENV {"git":"git version 2.54.0 (Apple Git-157)","reason":"push_rejected_non_fast_forward","detail":"The remote has commits this branch doesn't have. Pull or rebase, then push again."}
GIT_LOCALIZED {"code":1,"stderr":"To <fixture>/remote.git\n ! [rejected] HEAD -> main (non-fast-forward)\nFehler: Fehler beim Versenden einiger Referenzen nach '<fixture>/remote.git'\nHinweis: Aktualisierungen wurden zurückgewiesen, ..."}
GIT_CONTROL {"code":1,"stderr":"To <fixture>/remote.git\n ! [rejected] HEAD -> main (non-fast-forward)\nerror: failed to push some refs to '<fixture>/remote.git'\nhint: Updates were rejected because the tip of your current branch is behind\n..."}
HOOK_LOCALIZED {"reason":"push_rejected_by_remote","detail":"The remote rejected the push. Push this branch from a terminal to see the server's message."}
HOOK_CONTROL {"reason":"push_rejected_by_remote","detail":"The remote rejected the push. Push this branch from a terminal to see the server's message."}
DEFECT PRESENT: localized real pushes lose reasons; C control classifies non-fast-forward; both locales classify hook rejection.
Screenshots, recordings, or supporting files
No response
Workaround
The LANGUAGE=C control at the driver level keeps the explanation, so starting the server with LANGUAGE=C should restore it; that follows from the source trace and was not tested by launching the server or app. In either locale the rejected push itself still needs a pull or rebase before retrying.
- Dominant language
- TypeScript
- Stars
- 24.8k
- Forks
- 6.4k
- Avg merge
- 4h 16m
- Merged PRs (30d)
- 352
Getting set up
Starts the project's dev container in your browser, under your own GitHub account.
- No Dockerfile or Docker Compose file
- Has a pull request template
- Read the contributing guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from pingdotgg/t3code
-
Difficulty 2/5 1-3 hours Newbie friendliness 84/100
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 67/100
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 82/100
Maintainers usually reply within 1 day
All issues in pingdotgg/t3code
Similar issues
-
Autocomplete still accepts an option after becoming disabledPossibly taken @Xapollon430 claimed this today. Open
Difficulty 2/5 1-3 hours Newbie friendliness 67/100
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
Maintainers usually reply within 1 day
-
Difficulty 1/5 1-3 hours Newbie friendliness 78/100
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
reduxjs/react-redux#2416 · 1 comment ·