upstreamfix 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,296 @@
1
+ import { execFile as execFileCallback } from 'node:child_process';
2
+ import { mkdtemp, readFile, rm } from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { promisify } from 'node:util';
6
+ import { runMigration } from './runner.js';
7
+ import { applyProposedFiles, verifyProposedMigration } from './verify.js';
8
+ import { registerTemporaryCleanup } from './process-cleanup.js';
9
+ const execFile = promisify(execFileCallback);
10
+ /**
11
+ * Builds and verifies a migration in a detached worktree, then commits only the
12
+ * migration's changed paths on a new branch. The caller must enforce the
13
+ * `WRITES_ENABLED` safety gate before calling this operation.
14
+ */
15
+ export async function writeMigrationOnBranch(migration, options) {
16
+ const cwd = path.resolve(options.cwd);
17
+ const repositoryRoot = await git(cwd, ['rev-parse', '--show-toplevel']).then((value) => value.trim()).catch(() => {
18
+ throw new Error('--write requires the project directory to be inside a Git repository.');
19
+ });
20
+ const projectRelative = path.relative(repositoryRoot, cwd);
21
+ if (path.isAbsolute(projectRelative) || projectRelative === '..' || projectRelative.startsWith(`..${path.sep}`)) {
22
+ throw new Error('The project directory must be inside the Git repository.');
23
+ }
24
+ await assertSafeLocalGitConfig(repositoryRoot);
25
+ const status = await git(repositoryRoot, ['status', '--porcelain', '--untracked-files=all']);
26
+ if (status.trim() && !options.allowDirty) {
27
+ throw new Error('The Git working tree has uncommitted changes. Commit or stash them, or pass --allow-dirty to migrate the committed HEAD in an isolated worktree.');
28
+ }
29
+ const safeId = migration.id.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
30
+ const branch = `upstreamfix/${safeId}`;
31
+ if ((await git(repositoryRoot, ['branch', '--list', '--format=%(refname:short)', branch])).trim()) {
32
+ throw new Error(`Branch ${branch} already exists; remove or rename it before retrying.`);
33
+ }
34
+ const head = (await git(repositoryRoot, ['rev-parse', 'HEAD'])).trim();
35
+ const worktreeParent = await mkdtemp(path.join(os.tmpdir(), 'upstreamfix-write-'));
36
+ const worktree = path.join(worktreeParent, 'repo');
37
+ let worktreeAdded = false;
38
+ let branchCreated = false;
39
+ let committed = false;
40
+ const unregisterCleanup = registerTemporaryCleanup(async () => {
41
+ if (worktreeAdded)
42
+ await git(repositoryRoot, ['worktree', 'remove', '--force', worktree]).catch(() => undefined);
43
+ await rm(worktreeParent, { recursive: true, force: true });
44
+ if (branchCreated && !committed)
45
+ await git(repositoryRoot, ['branch', '-D', branch]).catch(() => undefined);
46
+ });
47
+ try {
48
+ await git(repositoryRoot, ['worktree', 'add', '--detach', worktree, head]);
49
+ worktreeAdded = true;
50
+ const projectCopy = projectRelative ? path.join(worktree, projectRelative) : worktree;
51
+ const outcome = await runMigration(migration, { cwd: projectCopy, dryRun: true, nodeRuntime: options.nodeRuntime });
52
+ if (outcome.manual.length > 0) {
53
+ throw new Error(`Migration has ${outcome.manual.length} manual review item(s); no branch or commit was created.`);
54
+ }
55
+ if (outcome.proposedDiff.length === 0)
56
+ throw new Error('Migration produced no changes; no branch or commit was created.');
57
+ await assertChangedSourcesUseTargetSdk(projectCopy, migration, outcome);
58
+ const verification = await verifyProposedMigration(projectCopy, outcome.proposedDiff, {
59
+ packageName: migration.packageName,
60
+ fileChanges: outcome.proposedFiles,
61
+ timeoutMs: options.timeoutMs,
62
+ ...options.verificationOptions,
63
+ });
64
+ if (verification.status !== 'passed') {
65
+ throw new Error(`Migration verification did not pass (${verification.status}); no branch or commit was created.${verification.note ? ` ${verification.note}` : ''}`);
66
+ }
67
+ await git(worktree, ['switch', '-c', branch]);
68
+ branchCreated = true;
69
+ await applyProposedFiles(projectCopy, outcome.proposedFiles);
70
+ const stagePaths = outcome.filesChanged.map((file) => {
71
+ const absolute = path.resolve(projectCopy, file);
72
+ const relativeToProject = path.relative(projectCopy, absolute);
73
+ if (path.isAbsolute(relativeToProject) || relativeToProject === '..' || relativeToProject.startsWith(`..${path.sep}`)) {
74
+ throw new Error(`Migration returned a changed path outside the project: ${file}`);
75
+ }
76
+ const relativeToRepo = path.relative(worktree, absolute);
77
+ if (path.isAbsolute(relativeToRepo) || relativeToRepo === '..' || relativeToRepo.startsWith(`..${path.sep}`)) {
78
+ throw new Error(`Migration returned a changed path outside the worktree: ${file}`);
79
+ }
80
+ return relativeToRepo || '.';
81
+ });
82
+ if (stagePaths.length === 0)
83
+ throw new Error('Migration returned no files to commit.');
84
+ await git(worktree, ['add', '--', ...stagePaths]);
85
+ const stagedPaths = (await git(worktree, ['diff', '--cached', '--name-only', '-z']))
86
+ .split('\0').filter(Boolean).sort();
87
+ if (JSON.stringify(stagedPaths) !== JSON.stringify([...stagePaths].sort())) {
88
+ throw new Error('The Git index contains unexpected paths; no migration commit was created.');
89
+ }
90
+ for (const relativePath of stagePaths) {
91
+ const stagedHash = (await git(worktree, ['rev-parse', `:${relativePath}`])).trim();
92
+ const workingHash = (await git(worktree, ['hash-object', '--', relativePath])).trim();
93
+ if (stagedHash !== workingHash) {
94
+ throw new Error(`Git filters changed ${relativePath} after verification; no migration commit was created.`);
95
+ }
96
+ }
97
+ await git(worktree, ['commit', '-m', `Apply ${migration.id} migration with UpstreamFix`]);
98
+ const commit = (await git(worktree, ['rev-parse', 'HEAD'])).trim();
99
+ committed = true;
100
+ return {
101
+ branch,
102
+ commit,
103
+ outcome,
104
+ verification,
105
+ ...(status.trim() ? { note: 'The branch was created from committed HEAD; the original dirty working tree was left untouched.' } : {}),
106
+ };
107
+ }
108
+ finally {
109
+ unregisterCleanup();
110
+ if (worktreeAdded) {
111
+ await git(repositoryRoot, ['worktree', 'remove', '--force', worktree]).catch(() => undefined);
112
+ }
113
+ await rm(worktreeParent, { recursive: true, force: true });
114
+ if (branchCreated && !committed)
115
+ await git(repositoryRoot, ['branch', '-D', branch]).catch(() => undefined);
116
+ }
117
+ }
118
+ /** Do not commit vNext source code while its containing manifest still installs vOld. */
119
+ async function assertChangedSourcesUseTargetSdk(projectRoot, migration, outcome) {
120
+ const proposed = new Map(outcome.proposedFiles.map((file) => [path.resolve(projectRoot, file.relativePath), file.after]));
121
+ const targetPattern = new RegExp(`^\\s*[~^]?${migration.toMajor}(?:\\.(?:\\d+|x|\\*)){0,2}\\s*$`, 'i');
122
+ for (const relativeFile of outcome.filesChanged) {
123
+ if (!/\.(?:[cm]?jsx?|[cm]?tsx?)$/i.test(relativeFile) || relativeFile.endsWith('.d.ts'))
124
+ continue;
125
+ const source = path.resolve(projectRoot, relativeFile);
126
+ const sourceRelative = path.relative(projectRoot, source);
127
+ if (sourceRelative === '..' || sourceRelative.startsWith(`..${path.sep}`) || path.isAbsolute(sourceRelative)) {
128
+ throw new Error(`Migration returned a changed path outside the project: ${relativeFile}`);
129
+ }
130
+ let directory = path.dirname(source);
131
+ let foundRange;
132
+ while (directory === projectRoot || directory.startsWith(`${projectRoot}${path.sep}`)) {
133
+ const manifestFile = path.join(directory, 'package.json');
134
+ const raw = proposed.get(manifestFile) ?? await readFile(manifestFile, 'utf8').catch(() => undefined);
135
+ if (raw) {
136
+ const manifest = JSON.parse(raw);
137
+ const ranges = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']
138
+ .map((section) => manifest[section]?.[migration.packageName])
139
+ .filter((range) => typeof range === 'string');
140
+ if (ranges.length > 0) {
141
+ foundRange = ranges[0];
142
+ if (ranges.some((range) => !targetPattern.test(range))) {
143
+ if (migration.id === 'firebase-admin@14' && !manifest.engines?.node) {
144
+ throw new Error(`${relativeFile} cannot be committed: firebase-admin v14 requires Node.js 22 or newer. Add \"engines\": { \"node\": \">=22\" } to ${path.relative(projectRoot, manifestFile)}, or pass --node-runtime 22 to confirm the deployment runtime and include that declaration in the migration commit.`);
145
+ }
146
+ if (migration.id === 'firebase-admin@14') {
147
+ throw new Error(`${relativeFile} cannot be committed: ${path.relative(projectRoot, manifestFile)} still declares ${migration.packageName} ${ranges.join(', ')}. Its engines.node range (${manifest.engines?.node}) may include Node.js versions below 22; review and update the range to guarantee Node.js 22 or newer, then preview again.`);
148
+ }
149
+ throw new Error(`${relativeFile} cannot be committed: ${path.relative(projectRoot, manifestFile)} does not declare ${migration.packageName} v${migration.toMajor} in every dependency section.`);
150
+ }
151
+ break;
152
+ }
153
+ }
154
+ if (directory === projectRoot)
155
+ break;
156
+ directory = path.dirname(directory);
157
+ }
158
+ if (!foundRange)
159
+ throw new Error(`${relativeFile} cannot be committed: no containing package.json declares ${migration.packageName} v${migration.toMajor}.`);
160
+ }
161
+ }
162
+ async function git(cwd, args) {
163
+ try {
164
+ const { stdout } = await execFile('git', [
165
+ '-c', `core.hooksPath=${os.devNull}`,
166
+ '-c', 'core.fsmonitor=false',
167
+ '-c', 'core.untrackedCache=false',
168
+ '-c', 'protocol.file.allow=never',
169
+ ...args,
170
+ ], {
171
+ cwd,
172
+ env: await gitEnvironment(cwd),
173
+ timeout: 30_000,
174
+ maxBuffer: 2 * 1024 * 1024,
175
+ windowsHide: true,
176
+ });
177
+ return stdout;
178
+ }
179
+ catch (cause) {
180
+ const error = cause;
181
+ const message = `${error.stdout ?? ''}${error.stderr ?? ''}`.trim() || error.message;
182
+ throw Object.assign(new Error(message), { code: error.code });
183
+ }
184
+ }
185
+ const preservedGitConfigKeys = [
186
+ 'commit.gpgsign',
187
+ 'gpg.format',
188
+ 'gpg.program',
189
+ 'gpg.ssh.program',
190
+ 'user.signingkey',
191
+ ];
192
+ async function gitEnvironment(cwd) {
193
+ const settings = await readPreservedGitSettings(cwd);
194
+ const env = {
195
+ PATH: process.env.PATH ?? '/usr/bin:/bin',
196
+ HOME: process.env.HOME ?? os.homedir(),
197
+ GIT_CONFIG_NOSYSTEM: '1',
198
+ GIT_CONFIG_GLOBAL: os.devNull,
199
+ GIT_TERMINAL_PROMPT: '0',
200
+ };
201
+ for (const key of ['GNUPGHOME', 'GPG_TTY', 'SSH_AUTH_SOCK', 'XDG_CONFIG_HOME', 'GIT_COMMITTER_NAME', 'GIT_COMMITTER_EMAIL', 'GIT_AUTHOR_NAME', 'GIT_AUTHOR_EMAIL']) {
202
+ if (process.env[key])
203
+ env[key] = process.env[key];
204
+ }
205
+ if (!env.GIT_AUTHOR_NAME && settings.get('user.name'))
206
+ env.GIT_AUTHOR_NAME = settings.get('user.name');
207
+ if (!env.GIT_COMMITTER_NAME && settings.get('user.name'))
208
+ env.GIT_COMMITTER_NAME = settings.get('user.name');
209
+ if (!env.GIT_AUTHOR_EMAIL && settings.get('user.email'))
210
+ env.GIT_AUTHOR_EMAIL = settings.get('user.email');
211
+ if (!env.GIT_COMMITTER_EMAIL && settings.get('user.email'))
212
+ env.GIT_COMMITTER_EMAIL = settings.get('user.email');
213
+ const preserved = preservedGitConfigKeys.flatMap((key) => {
214
+ const value = settings.get(key);
215
+ return value === undefined ? [] : [key, value];
216
+ });
217
+ if (preserved.length > 0) {
218
+ env.GIT_CONFIG_COUNT = String(preserved.length / 2);
219
+ for (let index = 0; index < preserved.length; index += 2) {
220
+ env[`GIT_CONFIG_KEY_${index / 2}`] = preserved[index];
221
+ env[`GIT_CONFIG_VALUE_${index / 2}`] = preserved[index + 1];
222
+ }
223
+ }
224
+ return env;
225
+ }
226
+ async function readPreservedGitSettings(cwd) {
227
+ const env = {
228
+ PATH: process.env.PATH ?? '/usr/bin:/bin',
229
+ HOME: process.env.HOME ?? os.homedir(),
230
+ GIT_CONFIG_NOSYSTEM: '1',
231
+ GIT_TERMINAL_PROMPT: '0',
232
+ };
233
+ const settings = new Map();
234
+ const identity = await readGitConfig(cwd, env, ['user.name', 'user.email']);
235
+ const signing = await readGitConfig(cwd, env, preservedGitConfigKeys, true);
236
+ for (const [key, value] of [...identity, ...signing])
237
+ settings.set(key, value);
238
+ return settings;
239
+ }
240
+ async function readGitConfig(cwd, env, keys, globalOnly = false) {
241
+ const pattern = `^(${keys.map((key) => key.replaceAll('.', String.raw `\.`)).join('|')})$`;
242
+ const result = await execFile('git', [
243
+ '-c', 'core.fsmonitor=false', '-c', 'core.untrackedCache=false', '-c', 'protocol.file.allow=never',
244
+ 'config', ...(globalOnly ? ['--global'] : []), '--null', '--get-regexp', pattern,
245
+ ], {
246
+ cwd,
247
+ env,
248
+ timeout: 10_000,
249
+ maxBuffer: 1024 * 1024,
250
+ windowsHide: true,
251
+ }).catch((error) => {
252
+ if (String(error.code) !== '1')
253
+ throw error;
254
+ return { stdout: error.stdout ?? '' };
255
+ });
256
+ const settings = new Map();
257
+ for (const record of result.stdout.split('\0').filter(Boolean)) {
258
+ const separator = record.indexOf('\n');
259
+ if (separator > 0)
260
+ settings.set(record.slice(0, separator).toLowerCase(), record.slice(separator + 1));
261
+ }
262
+ return settings;
263
+ }
264
+ async function assertSafeLocalGitConfig(repositoryRoot) {
265
+ const { stdout } = await execFile('git', [
266
+ '-c', 'core.fsmonitor=false', '-c', 'core.untrackedCache=false', '-c', 'protocol.file.allow=never',
267
+ 'config', '--local', '--get-regexp', String.raw `^(core\.fsmonitor|filter\..*\.(clean|smudge|process|required)|diff\.external|diff\..*\.command)$`,
268
+ ], {
269
+ cwd: repositoryRoot,
270
+ env: {
271
+ PATH: process.env.PATH ?? '/usr/bin:/bin',
272
+ HOME: process.env.HOME ?? os.homedir(),
273
+ GIT_CONFIG_NOSYSTEM: '1',
274
+ GIT_CONFIG_GLOBAL: os.devNull,
275
+ GIT_TERMINAL_PROMPT: '0',
276
+ },
277
+ timeout: 10_000,
278
+ maxBuffer: 1024 * 1024,
279
+ windowsHide: true,
280
+ }).catch((error) => {
281
+ if (String(error.code) !== '1')
282
+ throw error;
283
+ return { stdout: error.stdout ?? '' };
284
+ });
285
+ const dangerous = stdout.split(/\r?\n/).filter((line) => {
286
+ const [key, ...value] = line.trim().split(/\s+/);
287
+ if (!key)
288
+ return false;
289
+ if (key.toLowerCase() === 'core.fsmonitor')
290
+ return value.join(' ') !== '' && !/^false$/i.test(value.join(' '));
291
+ return value.join(' ').trim() !== '';
292
+ });
293
+ if (dangerous.length > 0) {
294
+ throw new Error('The repository has configured Git filters, diff commands, or fsmonitor hooks. Remove them before using --write.');
295
+ }
296
+ }