[Bug]: Localized Git hides the explanation for a rejected non-fast-forward push

Đang mở
#35,355 0 bình luận 0 reaction 0 người được giao Xem trên GitHub

Maintainer thường phản hồi trong vòng 1 ngày

Chưa có ai nhận issue này.

Đánh giá

Độ khó
3/5
Thời gian dự kiến
1-2 ngày
Mức phù hợp với người mới
48/100
Loại issue
Lỗi
Độ rõ ràng
Khá rõ ràng
Mức độ hoạt động
Sôi nổi
Công nghệ
git, node.js, typescript
Lĩnh vực
backend, cli

Hướng nghiên cứu

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.

Do mô hình lập chỉ mục viết ra từ nội dung của issue.

Mô tả

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_DETAILS in apps/server/src/vcs/GitVcsDriverCore.ts lines 475-498 from #22904, its English-output test in apps/server/src/vcs/GitVcsDriverCore.test.ts lines 1780-1810, and the LANGUAGE=C control in the reproduction.
  • Failure source: the reproduction output under Logs or stack traces, produced by the production GitVcsDriver.layer pushing 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.

Ngôn ngữ chính
TypeScript
Star
24.8k
Fork
6.4k
Merge trung bình
4 giờ 16 phút
Pull request đã merge (30 ngày)
352

Chuẩn bị môi trường

Mở trong Codespaces

Khởi chạy dev container của dự án ngay trên trình duyệt, bằng tài khoản GitHub của bạn.

Bắt đầu từ đâu

  1. Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
  2. Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
  3. Fork repository và làm thay đổi trên một nhánh.
  4. Mở pull request có tham chiếu số hiệu của issue.

Issue khác của pingdotgg/t3code

Tất cả issue của pingdotgg/t3code

Issue tương tự

Thêm issue về TypeScript

Nhận issue mới trong hộp thư của bạn

Bản tóm tắt ngắn những issue GitHub phù hợp với người mới.