From a8bdedee2e5bc14a51986a95b7379227a4a37517 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:31:53 -0700 Subject: [PATCH 1/2] Avoid unnecessary localization branch rebases Daily localization updates were rewriting the bot branch whenever main advanced, even when the localization files had not changed. This adds unrelated commits to force-pushes and creates unnecessary review churn. --- Herebyfile.mjs | 1 + tools/pipelines/localization-update.yml | 22 ++- tools/scripts/preserve-localization-base.mjs | 48 +++++++ .../preserve-localization-base.test.mjs | 125 ++++++++++++++++++ 4 files changed, 190 insertions(+), 6 deletions(-) create mode 100644 tools/scripts/preserve-localization-base.mjs create mode 100644 tools/scripts/preserve-localization-base.test.mjs diff --git a/Herebyfile.mjs b/Herebyfile.mjs index bf3010588e80a..14b75b99f92e3 100644 --- a/Herebyfile.mjs +++ b/Herebyfile.mjs @@ -1002,6 +1002,7 @@ export const testBenchmarks = task({ async function runTestTools() { await gotestsumTool.run([...gotestsumArgs("tools"), "./..."], { env: goTestEnv, cwd: path.join(__dirname, "tools") }); + await run("node", ["--test", "./tools/scripts/*.test.mjs"]); } async function runTestAPI() { diff --git a/tools/pipelines/localization-update.yml b/tools/pipelines/localization-update.yml index 704ae14876e47..8849d85eaa0ff 100644 --- a/tools/pipelines/localization-update.yml +++ b/tools/pipelines/localization-update.yml @@ -26,7 +26,7 @@ jobs: steps: - checkout: self clean: true - fetchDepth: 1 + fetchDepth: 0 fetchTags: false submodules: false @@ -207,16 +207,27 @@ jobs: git config --local http.https://github.com/.extraheader "AUTHORIZATION: basic $basicAuth" git remote set-url origin https://github.com/microsoft/TypeScript.git + $existingPr = gh pr list --repo microsoft/TypeScript --base main --head $branch --state open --json url --jq '.[0].url // empty' $remoteBranch = git ls-remote --heads origin $branch $branchExists = [bool] $remoteBranch + $remoteCommit = "" if ($branchExists) { - git fetch --depth=1 --no-tags --progress origin "refs/heads/${branch}:refs/remotes/origin/${branch}" + git fetch --no-tags --progress origin "refs/heads/${branch}:refs/remotes/origin/${branch}" + $remoteCommit = git rev-parse "origin/${branch}" + } + + $candidate = git rev-parse HEAD + if ($existingPr) { + if (!$branchExists) { + throw "The open localization pull request no longer has a remote branch." + } + $candidate = node tools/scripts/preserve-localization-base.mjs $remoteCommit } $shouldPush = $true if ($branchExists) { - $localTree = git rev-parse 'HEAD^{tree}' - $remoteTree = git rev-parse "origin/${branch}^{tree}" + $localTree = git rev-parse "${candidate}^{tree}" + $remoteTree = git rev-parse "${remoteCommit}^{tree}" if ($localTree -eq $remoteTree) { Write-Host "Remote branch already contains the same files." $shouldPush = $false @@ -224,10 +235,9 @@ jobs: } if ($shouldPush) { - git push --force-with-lease --set-upstream origin $branch + git push "--force-with-lease=refs/heads/${branch}:${remoteCommit}" origin "${candidate}:refs/heads/${branch}" } - $existingPr = gh pr list --repo microsoft/TypeScript --base main --head $branch --state open --json url --jq '.[0].url // empty' if ($existingPr) { Write-Host "Updated existing pull request: $existingPr" } diff --git a/tools/scripts/preserve-localization-base.mjs b/tools/scripts/preserve-localization-base.mjs new file mode 100644 index 0000000000000..c1679c0061369 --- /dev/null +++ b/tools/scripts/preserve-localization-base.mjs @@ -0,0 +1,48 @@ +import { + execFileSync, + spawnSync, +} from "node:child_process"; + +const [previous] = process.argv.slice(2); +if (!previous || process.argv.length !== 3) { + throw new Error("Usage: node tools/scripts/preserve-localization-base.mjs "); +} + +console.log(prepareUpdate()); + +function prepareUpdate() { + const generated = git("rev-parse", "HEAD"); + const main = git("rev-parse", `${generated}^`); + const previousHead = git("rev-parse", "--verify", `${previous}^{commit}`); + const base = git("merge-base", main, previousHead); + if (base === main) return generated; + + // Apply only the generated delta to the old base, without changing the checkout. + const tree = mergeTree(`--merge-base=${main}`, base, generated); + if (!tree) { + console.error("Localization changes conflict with the existing PR base; using current main."); + return generated; + } + + const candidate = git("commit-tree", tree, "-p", base, "-m", "Update localization files"); + if (mergeTree(main, candidate) !== git("rev-parse", `${generated}^{tree}`)) { + console.error("Preserving the PR base would conflict or change the generated merge result; using current main."); + return generated; + } + + return tree === git("rev-parse", `${previousHead}^{tree}`) ? previousHead : candidate; +} + +function git(...args) { + return execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim(); +} + +function mergeTree(...args) { + const result = spawnSync("git", ["merge-tree", "--write-tree", ...args], { encoding: "utf8" }); + if (result.error) throw result.error; + if (result.status === 1) return undefined; + if (result.status !== 0) { + throw new Error(`git merge-tree failed (${result.status}): ${result.stderr}`); + } + return result.stdout.trim().split(/\r?\n/, 1)[0]; +} diff --git a/tools/scripts/preserve-localization-base.test.mjs b/tools/scripts/preserve-localization-base.test.mjs new file mode 100644 index 0000000000000..415cd4273bfe6 --- /dev/null +++ b/tools/scripts/preserve-localization-base.test.mjs @@ -0,0 +1,125 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; +import { gzipSync } from "node:zlib"; + +const script = fileURLToPath(new URL("./preserve-localization-base.mjs", import.meta.url)); +const locales = "tsc/internal/diagnostics/loc"; +const generatedGo = "tsc/internal/diagnostics/loc_generated.go"; + +function repository(context) { + const directory = mkdtempSync(path.join(tmpdir(), "localization-base-")); + context.after(() => rmSync(directory, { recursive: true, force: true })); + const git = (...args) => execFileSync("git", args, { cwd: directory, encoding: "utf8" }).trim(); + const write = (file, content) => { + const destination = path.join(directory, file); + mkdirSync(path.dirname(destination), { recursive: true }); + writeFileSync(destination, content); + }; + const localize = text => { + write(`${locales}/cs-CZ.generated.json`, JSON.stringify({ message: text }) + "\n"); + write(`${locales}/cs-CZ.json.gz`, gzipSync(text)); + }; + const commit = message => { + git("add", "--all"); + git("commit", "--quiet", "-m", message); + return git("rev-parse", "HEAD"); + }; + const prepare = previous => execFileSync(process.execPath, [script, previous], { cwd: directory, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim(); + + git("init", "--quiet", "--initial-branch=main"); + git("config", "user.name", "Test"); + git("config", "user.email", "test@example.com"); + git("config", "commit.gpgsign", "false"); + git("config", "core.autocrlf", "false"); + git("config", "core.hooksPath", path.join(directory, "hooks")); + write("source.txt", "original source\n"); + write(generatedGo, "original locales\n"); + write(`${locales}/fr-FR.generated.json`, "{}\n"); + localize("original translation"); + const base = commit("Initial files"); + git("switch", "--quiet", "-c", "localization-update"); + localize("previous translation"); + const previous = commit("Previous localization update"); + git("switch", "--quiet", "main"); + + return { directory, git, write, localize, commit, prepare, base, previous }; +} + +test("main-only changes leave the existing localization commit unchanged", context => { + const { git, write, localize, commit, prepare, previous } = repository(context); + write("source.txt", "new source\n"); + commit("Advance main"); + localize("previous translation"); + const generated = commit("Generate localization"); + + assert.equal(prepare(previous), previous); + assert.equal(git("rev-parse", "HEAD"), generated); + assert.equal(git("status", "--porcelain"), ""); +}); + +test("new localization outputs retain the old base without bringing in main", context => { + const { directory, git, write, localize, commit, prepare, base, previous } = repository(context); + write(`${locales}/obsolete.generated.json`, "{}\n"); + commit("Add an obsolete locale on main"); + write("source.txt", "new source\n"); + commit("Advance main"); + localize("new translation"); + write(`${locales}/de-DE.generated.json`, '{"message":"new locale"}\n'); + write(`${locales}/de-DE.json.gz`, gzipSync("new locale")); + write(generatedGo, "updated locales\n"); + rmSync(path.join(directory, locales, "fr-FR.generated.json")); + const generated = commit("Generate localization"); + + const selected = prepare(previous); + assert.equal(git("rev-parse", `${selected}^`), base); + assert.equal(git("show", `${selected}:source.txt`), "original source"); + assert.equal(git("show", `${selected}:${generatedGo}`), "updated locales"); + assert.equal(git("show", `${selected}:${locales}/de-DE.generated.json`), '{"message":"new locale"}'); + assert.equal(git("ls-tree", selected, "--", `${locales}/fr-FR.generated.json`), ""); + assert.equal(git("merge-tree", "--write-tree", `${generated}^`, selected), git("rev-parse", `${generated}^{tree}`)); + assert.equal(git("rev-parse", "HEAD"), generated); + + // Files unchanged by generation should come from main at merge time. + assert.equal(git("ls-tree", selected, "--", `${locales}/obsolete.generated.json`), ""); +}); + +test("conflicting localization changes fall back to the current main base", context => { + const { git, localize, commit, prepare, previous } = repository(context); + localize("translation changed on main"); + const main = commit("Change localization on main"); + localize("new translation"); + const generated = commit("Generate localization"); + + assert.equal(prepare(previous), generated); + assert.equal(git("rev-parse", `${generated}^`), main); +}); + +test("a clean merge with different generated output also refreshes the base", context => { + const { directory, git, write, localize, commit, prepare, previous } = repository(context); + write(`${locales}/obsolete.generated.json`, "{}\n"); + const main = commit("Add an obsolete locale on main"); + rmSync(path.join(directory, locales, "obsolete.generated.json")); + localize("previous translation"); + const generated = commit("Generate localization"); + + assert.notEqual(git("merge-tree", "--write-tree", main, previous), git("rev-parse", `${generated}^{tree}`)); + assert.equal(prepare(previous), generated); +}); + +test("Git errors are not treated as merge conflicts", context => { + const { localize, commit, prepare } = repository(context); + localize("new translation"); + commit("Generate localization"); + + assert.throws(() => prepare("missing-localization-branch"), /missing-localization-branch/); +}); From 52ec0a598f63b95b1c661423c56cf1e4d63c70f5 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:33:14 -0700 Subject: [PATCH 2/2] Remove localization build-script tests --- Herebyfile.mjs | 1 - .../preserve-localization-base.test.mjs | 125 ------------------ 2 files changed, 126 deletions(-) delete mode 100644 tools/scripts/preserve-localization-base.test.mjs diff --git a/Herebyfile.mjs b/Herebyfile.mjs index 14b75b99f92e3..bf3010588e80a 100644 --- a/Herebyfile.mjs +++ b/Herebyfile.mjs @@ -1002,7 +1002,6 @@ export const testBenchmarks = task({ async function runTestTools() { await gotestsumTool.run([...gotestsumArgs("tools"), "./..."], { env: goTestEnv, cwd: path.join(__dirname, "tools") }); - await run("node", ["--test", "./tools/scripts/*.test.mjs"]); } async function runTestAPI() { diff --git a/tools/scripts/preserve-localization-base.test.mjs b/tools/scripts/preserve-localization-base.test.mjs deleted file mode 100644 index 415cd4273bfe6..0000000000000 --- a/tools/scripts/preserve-localization-base.test.mjs +++ /dev/null @@ -1,125 +0,0 @@ -import assert from "node:assert/strict"; -import { execFileSync } from "node:child_process"; -import { - mkdirSync, - mkdtempSync, - rmSync, - writeFileSync, -} from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { test } from "node:test"; -import { fileURLToPath } from "node:url"; -import { gzipSync } from "node:zlib"; - -const script = fileURLToPath(new URL("./preserve-localization-base.mjs", import.meta.url)); -const locales = "tsc/internal/diagnostics/loc"; -const generatedGo = "tsc/internal/diagnostics/loc_generated.go"; - -function repository(context) { - const directory = mkdtempSync(path.join(tmpdir(), "localization-base-")); - context.after(() => rmSync(directory, { recursive: true, force: true })); - const git = (...args) => execFileSync("git", args, { cwd: directory, encoding: "utf8" }).trim(); - const write = (file, content) => { - const destination = path.join(directory, file); - mkdirSync(path.dirname(destination), { recursive: true }); - writeFileSync(destination, content); - }; - const localize = text => { - write(`${locales}/cs-CZ.generated.json`, JSON.stringify({ message: text }) + "\n"); - write(`${locales}/cs-CZ.json.gz`, gzipSync(text)); - }; - const commit = message => { - git("add", "--all"); - git("commit", "--quiet", "-m", message); - return git("rev-parse", "HEAD"); - }; - const prepare = previous => execFileSync(process.execPath, [script, previous], { cwd: directory, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim(); - - git("init", "--quiet", "--initial-branch=main"); - git("config", "user.name", "Test"); - git("config", "user.email", "test@example.com"); - git("config", "commit.gpgsign", "false"); - git("config", "core.autocrlf", "false"); - git("config", "core.hooksPath", path.join(directory, "hooks")); - write("source.txt", "original source\n"); - write(generatedGo, "original locales\n"); - write(`${locales}/fr-FR.generated.json`, "{}\n"); - localize("original translation"); - const base = commit("Initial files"); - git("switch", "--quiet", "-c", "localization-update"); - localize("previous translation"); - const previous = commit("Previous localization update"); - git("switch", "--quiet", "main"); - - return { directory, git, write, localize, commit, prepare, base, previous }; -} - -test("main-only changes leave the existing localization commit unchanged", context => { - const { git, write, localize, commit, prepare, previous } = repository(context); - write("source.txt", "new source\n"); - commit("Advance main"); - localize("previous translation"); - const generated = commit("Generate localization"); - - assert.equal(prepare(previous), previous); - assert.equal(git("rev-parse", "HEAD"), generated); - assert.equal(git("status", "--porcelain"), ""); -}); - -test("new localization outputs retain the old base without bringing in main", context => { - const { directory, git, write, localize, commit, prepare, base, previous } = repository(context); - write(`${locales}/obsolete.generated.json`, "{}\n"); - commit("Add an obsolete locale on main"); - write("source.txt", "new source\n"); - commit("Advance main"); - localize("new translation"); - write(`${locales}/de-DE.generated.json`, '{"message":"new locale"}\n'); - write(`${locales}/de-DE.json.gz`, gzipSync("new locale")); - write(generatedGo, "updated locales\n"); - rmSync(path.join(directory, locales, "fr-FR.generated.json")); - const generated = commit("Generate localization"); - - const selected = prepare(previous); - assert.equal(git("rev-parse", `${selected}^`), base); - assert.equal(git("show", `${selected}:source.txt`), "original source"); - assert.equal(git("show", `${selected}:${generatedGo}`), "updated locales"); - assert.equal(git("show", `${selected}:${locales}/de-DE.generated.json`), '{"message":"new locale"}'); - assert.equal(git("ls-tree", selected, "--", `${locales}/fr-FR.generated.json`), ""); - assert.equal(git("merge-tree", "--write-tree", `${generated}^`, selected), git("rev-parse", `${generated}^{tree}`)); - assert.equal(git("rev-parse", "HEAD"), generated); - - // Files unchanged by generation should come from main at merge time. - assert.equal(git("ls-tree", selected, "--", `${locales}/obsolete.generated.json`), ""); -}); - -test("conflicting localization changes fall back to the current main base", context => { - const { git, localize, commit, prepare, previous } = repository(context); - localize("translation changed on main"); - const main = commit("Change localization on main"); - localize("new translation"); - const generated = commit("Generate localization"); - - assert.equal(prepare(previous), generated); - assert.equal(git("rev-parse", `${generated}^`), main); -}); - -test("a clean merge with different generated output also refreshes the base", context => { - const { directory, git, write, localize, commit, prepare, previous } = repository(context); - write(`${locales}/obsolete.generated.json`, "{}\n"); - const main = commit("Add an obsolete locale on main"); - rmSync(path.join(directory, locales, "obsolete.generated.json")); - localize("previous translation"); - const generated = commit("Generate localization"); - - assert.notEqual(git("merge-tree", "--write-tree", main, previous), git("rev-parse", `${generated}^{tree}`)); - assert.equal(prepare(previous), generated); -}); - -test("Git errors are not treated as merge conflicts", context => { - const { localize, commit, prepare } = repository(context); - localize("new translation"); - commit("Generate localization"); - - assert.throws(() => prepare("missing-localization-branch"), /missing-localization-branch/); -});