Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,15 @@ jobs:
- name: Engine purity guard
run: npm run check-purity

# Every message the code asks for is in the English source bundle (a new
# l10n.t() that nobody re-extracted fails here), and every translation
# covers it with the same placeholders and no added markup.
- name: Translations are complete
run: |
node scripts/i18n/bundle-nls.mjs --write
git diff --exit-code -- apps/extension/l10n/bundle.l10n.json
npm run i18n

- name: Package extension (smoke-test the build)
run: |
npm run package
Expand Down
7 changes: 4 additions & 3 deletions apps/extension/l10n/bundle.l10n.json
Original file line number Diff line number Diff line change
Expand Up @@ -2446,7 +2446,11 @@
"Your changes to {0} were put back, but they conflict with what came in — ": "Your changes to {0} were put back, but they conflict with what came in — ",
"Your comment is back in its box.": "Your comment is back in its box.",
"Your commit a1b2c3d “{0}” from feature/session-hardening": "Your commit a1b2c3d “{0}” from feature/session-hardening",
"Your commit replaced the version the remote still has — amending a pushed commit does this. Pulling would bring the old one back.": "Your commit replaced the version the remote still has — amending a pushed commit does this. Pulling would bring the old one back.",
"Your commit replaced versions the remote still has — amending a pushed commit does this. Pulling would bring the old one back.": "Your commit replaced versions the remote still has — amending a pushed commit does this. Pulling would bring the old one back.",
"Your commit {0} “{1}” from {2}": "Your commit {0} “{1}” from {2}",
"Your commits replaced the version the remote still has — amending a pushed commit does this. Pulling would bring the old one back.": "Your commits replaced the version the remote still has — amending a pushed commit does this. Pulling would bring the old one back.",
"Your commits replaced versions the remote still has — amending a pushed commit does this. Pulling would bring the old one back.": "Your commits replaced versions the remote still has — amending a pushed commit does this. Pulling would bring the old one back.",
"Your commits stay. The PR's newer commits aren't brought in.": "Your commits stay. The PR's newer commits aren't brought in.",
"Your commits stay. The pull request's newer commits aren't brought in: pull to merge them.": "Your commits stay. The pull request's newer commits aren't brought in: pull to merge them.",
"Your history will appear here after the first commit.": "Your history will appear here after the first commit.",
Expand All @@ -2466,7 +2470,6 @@
"Your uncommitted changes to {0} are in the way of {1} — ": "Your uncommitted changes to {0} are in the way of {1} — ",
"Your uncommitted changes touch files the {0} changed. Commit or stash them, then undo.": "Your uncommitted changes touch files the {0} changed. Commit or stash them, then undo.",
"Your verdict": "Your verdict",
"Your {0} replaced the {1} the remote still has — amending a pushed commit does this. Pulling would bring the old one back.": "Your {0} replaced the {1} the remote still has — amending a pushed commit does this. Pulling would bring the old one back.",
"Yours": "Yours",
"Yours {0}, theirs {1}: the result keeps {2}.": "Yours {0}, theirs {1}: the result keeps {2}.",
"[$(arrow-up) Push](command:gitstudio.sync.push)": "[$(arrow-up) Push](command:gitstudio.sync.push)",
Expand Down Expand Up @@ -2895,8 +2898,6 @@
"use the commit as-is": "use the commit as-is",
"uses {0}": "uses {0}",
"v1.2.0 a1b2c3d origin/main~3": "v1.2.0 a1b2c3d origin/main~3",
"version": "version",
"versions": "versions",
"wants to merge": "wants to merge",
"week": "week",
"weeks": "weeks",
Expand Down
7 changes: 4 additions & 3 deletions apps/extension/l10n/bundle.l10n.zh-cn.json
Original file line number Diff line number Diff line change
Expand Up @@ -2446,7 +2446,11 @@
"Your changes to {0} were put back, but they conflict with what came in — ": "你对 {0} 的更改已放回,但它们与进来的内容冲突 — ",
"Your comment is back in its box.": "你的评论已回到输入框中。",
"Your commit a1b2c3d “{0}” from feature/session-hardening": "你在 feature/session-hardening 上的提交 a1b2c3d“{0}”",
"Your commit replaced the version the remote still has — amending a pushed commit does this. Pulling would bring the old one back.": "你的提交替换了远程仍持有的版本 — 修改已推送的提交会导致这种情况。拉取会带回旧的那个。",
"Your commit replaced versions the remote still has — amending a pushed commit does this. Pulling would bring the old one back.": "你的提交替换了远程仍持有的多个版本 — 修改已推送的提交会导致这种情况。拉取会带回旧的那个。",
"Your commit {0} “{1}” from {2}": "你的提交 {0} “{1}”,来自 {2}",
"Your commits replaced the version the remote still has — amending a pushed commit does this. Pulling would bring the old one back.": "你的多个提交替换了远程仍持有的版本 — 修改已推送的提交会导致这种情况。拉取会带回旧的那个。",
"Your commits replaced versions the remote still has — amending a pushed commit does this. Pulling would bring the old one back.": "你的多个提交替换了远程仍持有的多个版本 — 修改已推送的提交会导致这种情况。拉取会带回旧的那个。",
"Your commits stay. The PR's newer commits aren't brought in.": "你的提交会保留。该 PR 较新的提交不会被引入。",
"Your commits stay. The pull request's newer commits aren't brought in: pull to merge them.": "你的提交会保留。该拉取请求较新的提交不会被引入:请拉取以合并它们。",
"Your history will appear here after the first commit.": "第一次提交后,你的历史将显示在这里。",
Expand All @@ -2466,7 +2470,6 @@
"Your uncommitted changes to {0} are in the way of {1} — ": "你对 {0} 的未提交更改妨碍了{1} — ",
"Your uncommitted changes touch files the {0} changed. Commit or stash them, then undo.": "你的未提交更改涉及{0}所改动的文件。请先提交或贮藏它们,然后撤销。",
"Your verdict": "你的结论",
"Your {0} replaced the {1} the remote still has — amending a pushed commit does this. Pulling would bring the old one back.": "你的{0}替换了远程仍持有的{1} — 修改已推送的提交会导致这种情况。拉取会带回旧的那个。",
"Yours": "你的",
"Yours {0}, theirs {1}: the result keeps {2}.": "你的 {0},他们的 {1}:结果保留 {2}。",
"[$(arrow-up) Push](command:gitstudio.sync.push)": "[$(arrow-up) 推送](command:gitstudio.sync.push)",
Expand Down Expand Up @@ -2895,8 +2898,6 @@
"use the commit as-is": "按原样使用该提交",
"uses {0}": "使用 {0}",
"v1.2.0 a1b2c3d origin/main~3": "v1.2.0 a1b2c3d origin/main~3",
"version": "版本",
"versions": "版本",
"wants to merge": "想合并",
"week": "周",
"weeks": "周",
Expand Down
14 changes: 9 additions & 5 deletions apps/extension/src/statusBar/syncStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -521,11 +521,15 @@ export class SyncStatusItem implements vscode.Disposable {
}): Promise<boolean | undefined> {
const choice = await promptPick({
title: l10n.t("This branch was rewritten"),
hint: l10n.t(
"Your {0} replaced the {1} the remote still has — amending a pushed commit does this. Pulling would bring the old one back.",
ab.ahead === 1 ? l10n.t("commit") : l10n.t("commits"),
ab.behind === 1 ? l10n.t("version") : l10n.t("versions"),
),
// Whole sentences, one per count: a translation reorders the words.
hint:
ab.ahead === 1
? ab.behind === 1
? l10n.t("Your commit replaced the version the remote still has — amending a pushed commit does this. Pulling would bring the old one back.")
: l10n.t("Your commit replaced versions the remote still has — amending a pushed commit does this. Pulling would bring the old one back.")
: ab.behind === 1
? l10n.t("Your commits replaced the version the remote still has — amending a pushed commit does this. Pulling would bring the old one back.")
: l10n.t("Your commits replaced versions the remote still has — amending a pushed commit does this. Pulling would bring the old one back."),
choices: [
{
id: "force",
Expand Down
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/git-service/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"@gitstudio/ai": "*",
"@gitstudio/engine": "*",
"@gitstudio/host-bridge": "*",
"@gitstudio/l10n": "*",
"@vscode/l10n": "^0.0.18"
}
}
8 changes: 5 additions & 3 deletions packages/git-service/src/SnapshotProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
import { placeHolds, restoreStash, stashStack, type StashSlot } from "./stashRestore";
import { stashTitle } from "./StashProvider";
import * as l10n from "@vscode/l10n";
import { englishOf } from "@gitstudio/l10n/index";

/**
* A record of what an operation is about to change, and — once it has run and
Expand Down Expand Up @@ -319,7 +320,7 @@ export class SnapshotProvider {
let stashSha: string | null = null;
let uncopied: NoCopy | undefined;
if (!refsOnly && (await this.isDirty(opts))) {
const created = await this.process.run(["stash", "create", label], opts);
const created = await this.process.run(["stash", "create", englishOf(label)], opts);
if (created.code === 0) {
// `stash create` prints nothing (empty) when there's nothing to stash.
stashSha = created.stdout.trim() || null;
Expand Down Expand Up @@ -740,7 +741,7 @@ export class SnapshotProvider {
async execute(snap: Snapshot, steps: readonly RestoreStep[], opts?: GitRunOptions): Promise<void> {
// English on purpose: git writes this into the reflog, where `git reflog`
// shows it to whoever reads the repository, whatever their editor says.
const message = `GitStudio undo: ${snap.label}`;
const message = `GitStudio undo: ${englishOf(snap.label)}`;
for (const st of steps) {
switch (st.do) {
case "abort-rebase": {
Expand Down Expand Up @@ -836,7 +837,8 @@ export class SnapshotProvider {
}
// English on purpose: this is a commit message, stored in the repository
// itself and read by everyone who looks at the history.
const msg = `Revert "${snap.label}"\n\nThis puts back the files as they were before "${snap.label}" (${shortSha(p.from)}), which had already been pushed as ${shortSha(p.to)}.\n`;
const label = englishOf(snap.label);
const msg = `Revert "${label}"\n\nThis puts back the files as they were before "${label}" (${shortSha(p.from)}), which had already been pushed as ${shortSha(p.to)}.\n`;
const made = await this.process.run(["commit-tree", `${p.from}^{tree}`, "-p", p.to, "-F", "-"], { ...opts, input: msg });
if (made.code !== 0) return made;
// The new commit's parent is `p.to`: moving HEAD onto it from anywhere
Expand Down
3 changes: 2 additions & 1 deletion packages/git-service/src/dropCommit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
type RefMove,
} from "./refRestore";
import * as l10n from "@vscode/l10n";
import { englishOf } from "@gitstudio/l10n/index";

// Drop Commit (issue #32), for both products.
//
Expand Down Expand Up @@ -464,7 +465,7 @@ async function undoOnBranch(
/** The reflog entry an undo of `what` writes on each branch it puts back. */
function undoReflog(what: string): string {
// English on purpose: a reflog entry, stored in the repository itself.
return what === "drop" ? "GitStudio undo: drop commit" : `GitStudio undo: ${what}`;
return what === "drop" ? "GitStudio undo: drop commit" : `GitStudio undo: ${englishOf(what)}`;
}

/** The carried branches back, each by compare-and-swap. */
Expand Down
3 changes: 2 additions & 1 deletion packages/l10n/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"./*": "./src/*.ts"
},
"scripts": {
"check-types": "tsc --noEmit -p tsconfig.json"
"check-types": "tsc --noEmit -p tsconfig.json",
"test": "tsx --import ../../scripts/test/hermetic-git.mjs --test \"test/**/*.test.ts\""
},
"dependencies": {
"@vscode/l10n": "^0.0.18"
Expand Down
67 changes: 67 additions & 0 deletions packages/l10n/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export function configureL10n(uri: { fsPath: string } | undefined): void {
bundleText = undefined;
locale = undefined;
scriptCache = undefined;
reverse = undefined;
if (!uri) {
return;
}
Expand Down Expand Up @@ -158,3 +159,69 @@ export function currentBundle(): l10nJsonFormat | undefined {
return undefined;
}
}

/** Each translation as a pattern that gives its English message back. */
type Reverse = { exact: Map<string, string>; patterns: { re: RegExp; names: string[]; message: string }[] };
let reverse: Reverse | undefined;

/**
* The English a translated message was made from: "合并 feat" → "Merge feat".
*
* For what git stores in the repository — a reflog entry, a stash's message, a
* commit message — which everyone who reads the history sees, whatever
* language GitStudio was showing the person who ran the operation. The label
* those are built from is the one the UI showed, so it is turned back here
* rather than threading a second, English label through every caller.
*
* Arguments are matched as wildcards and turned back on their own when they are
* whole messages ("提交" → "commit"); a branch name stays as written. Text no
* translation produces (English, or no bundle at all) is handed back unchanged.
*/
export function englishOf(text: string): string {
if (!reverse) {
const bundle = currentBundle();
if (!bundle) return text;
reverse = buildReverse(bundle);
}
const exact = reverse.exact.get(text);
if (exact !== undefined) return exact;
for (const { re, names, message } of reverse.patterns) {
const m = re.exec(text);
if (!m) continue;
return message.replace(/\{([^}]+)\}/g, (whole, key: string) => {
const at = names.indexOf(key);
if (at < 0) return whole;
const value = m[at + 1] ?? "";
return reverse?.exact.get(value) ?? value;
});
}
return text;
}

function buildReverse(bundle: l10nJsonFormat): Reverse {
const exact = new Map<string, string>();
const patterns: Reverse["patterns"] = [];
for (const [message, entry] of Object.entries(bundle)) {
const translated = typeof entry === "string" ? entry : entry.message;
if (!translated || translated === message) continue;
const parts = translated.split(/\{([^}]+)\}/);
if (parts.length === 1) {
if (!exact.has(translated)) exact.set(translated, message);
continue;
}
const names: string[] = [];
let source = "^";
parts.forEach((part, i) => {
if (i % 2) {
names.push(part);
source += "([\\s\\S]+?)";
} else {
source += part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
});
patterns.push({ re: new RegExp(`${source}$`), names, message });
}
// The longest literal text first: "{0} 已推送" must not win over "{0} 已推送到 {1}".
patterns.sort((a, b) => b.re.source.length - a.re.source.length);
return { exact, patterns };
}
42 changes: 42 additions & 0 deletions packages/l10n/test/englishOf.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// englishOf turns what the UI showed back into the English git should store.
import assert from "node:assert/strict";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "node:test";

import { configureL10n, englishOf } from "../src/index";

function withBundle(bundle: Record<string, string>, t: { after(fn: () => void): void }): void {
const dir = mkdtempSync(join(tmpdir(), "gs-l10n-"));
const file = join(dir, "bundle.l10n.zh-cn.json");
writeFileSync(file, JSON.stringify(bundle));
configureL10n({ fsPath: file });
t.after(() => {
configureL10n(undefined);
rmSync(dir, { recursive: true, force: true });
});
}

test("English, or no bundle at all, comes back as it went in", () => {
configureL10n(undefined);
assert.equal(englishOf("Merge feat into main"), "Merge feat into main");
});

test("a translated label turns back into its English message, the names in it kept", (t) => {
withBundle({ "Merge {0} into {1}": "将 {0} 合并到 {1}", "Drop commit": "丢弃提交", "{0} pushed": "{0} 已推送", "{0} pushed to {1}": "{0} 已推送到 {1}" }, t);
assert.equal(englishOf("将 feat/登录 合并到 main"), "Merge feat/登录 into main");
assert.equal(englishOf("丢弃提交"), "Drop commit");
assert.equal(englishOf("main 已推送到 origin"), "main pushed to origin", "the longer pattern wins");
assert.equal(englishOf("main 已推送"), "main pushed");
});

test("an argument that is itself a whole message turns back too", (t) => {
withBundle({ "Reorder {0}": "重新排序{0}", "commits": "提交" }, t);
assert.equal(englishOf("重新排序提交"), "Reorder commits");
});

test("text no translation produced is handed back unchanged", (t) => {
withBundle({ "Merge {0} into {1}": "将 {0} 合并到 {1}" }, t);
assert.equal(englishOf("something a person typed"), "something a person typed");
});
2 changes: 1 addition & 1 deletion packages/merge-vscode/src/dashboardController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@ const NO_OP: ConflictsState["op"] = {
yours: { role: "yours", stage: 2, name: "", paneTitle: "", description: "" },
theirs: { role: "theirs", stage: 3, name: "", paneTitle: "", description: "" },
// English sentinel: abortLabel() replaces it with a specific, localized word.
verbs: { abort: l10n.t("Cancel") },
verbs: { abort: "Cancel" },
canContinue: false,
canSkip: false,
episode: "none",
Expand Down
13 changes: 12 additions & 1 deletion scripts/i18n/bundle-nls.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,18 @@ for (const file of files) {
report(`${file}: placeholders changed for ${JSON.stringify(key)}: expected ${placeholders(source[key]).map((p) => `{${p}}`).join(" ")} got ${placeholders(translated[key]).map((p) => `{${p}}`).join(" ")}`);
}
if (broken.length > 5) report(`${file}: ${broken.length - 5} more message(s) with changed placeholders`);
if (!missing.length && !extra.length && !blank.length && !broken.length) {

// Translations reach pages through innerHTML and quoted attributes, which
// trust the English source not to carry markup. A translation may not add a
// character that markup or an attribute would read: < > & " beyond what its
// English source already has.
const count = (text, ch) => String(text).split(ch).length - 1;
const markup = keys.filter((key) => ["<", ">", "&", '"'].some((ch) => count(translated[key], ch) > count(source[key], ch)));
for (const key of markup.slice(0, 5)) {
report(`${file}: adds markup characters (< > & ") the English does not have, in ${JSON.stringify(key)}: ${JSON.stringify(translated[key])}`);
}
if (markup.length > 5) report(`${file}: ${markup.length - 5} more message(s) adding markup characters`);
if (!missing.length && !extra.length && !blank.length && !broken.length && !markup.length) {
console.log(`bundle-nls: ${file} covers all ${keys.length} messages`);
}
}
Expand Down
Loading