12.0 KB376 lines
Blame
1/**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 */
7
8import type {EjecaOptions, EjecaReturn} from 'shared/ejeca';
9import type {RepositoryContext} from './serverTypes';
10
11import {ConflictType, type AbsolutePath, type MergeConflicts} from 'isl/src/types';
12import os from 'node:os';
13import {ejeca} from 'shared/ejeca';
14import {isEjecaError} from './utils';
15
16export const MAX_FETCHED_FILES_PER_COMMIT = 25;
17export const MAX_SIMULTANEOUS_CAT_CALLS = 4;
18/** Timeout for non-operation commands. Operations like goto and rebase are expected to take longer,
19 * but status, log, cat, etc should typically take <10s. */
20export const READ_COMMAND_TIMEOUT_MS = 60_000;
21
22export type ConflictFileData = {
23 contents: string | null;
24 exists: boolean;
25 isexec: boolean;
26 issymlink: boolean;
27};
28export type ResolveCommandConflictOutput = [
29 | {
30 command: null;
31 conflicts: [];
32 pathconflicts: [];
33 }
34 | {
35 command: string;
36 command_details: {cmd: string; to_abort: string; to_continue: string};
37 conflicts: Array<{
38 base: ConflictFileData;
39 local: ConflictFileData;
40 output: ConflictFileData;
41 other: ConflictFileData;
42 path: string;
43 }>;
44 pathconflicts: Array<never>;
45 hashes?: {
46 local?: string;
47 other?: string;
48 };
49 },
50];
51
52/** Run an sl command (without analytics). */
53export async function runCommand(
54 ctx: RepositoryContext,
55 args_: Array<string>,
56 options_?: EjecaOptions,
57 timeout: number = READ_COMMAND_TIMEOUT_MS,
58): Promise<EjecaReturn> {
59 const {command, args, options} = getExecParams(ctx.cmd, args_, ctx.cwd, options_);
60 ctx.logger.log('run command: ', ctx.cwd, command, args[0]);
61 const result = ejeca(command, args, options);
62
63 let timedOut = false;
64 let timeoutId: NodeJS.Timeout | undefined;
65 if (timeout > 0) {
66 timeoutId = setTimeout(() => {
67 result.kill('SIGTERM', {forceKillAfterTimeout: 5_000});
68 ctx.logger.error(`Timed out waiting for ${command} ${args[0]} to finish`);
69 timedOut = true;
70 }, timeout);
71 result.on('exit', () => {
72 clearTimeout(timeoutId);
73 });
74 }
75
76 try {
77 const val = await result;
78 return val;
79 } catch (err: unknown) {
80 if (isEjecaError(err)) {
81 if (err.killed) {
82 if (timedOut) {
83 throw new Error('Timed out');
84 }
85 throw new Error('Killed');
86 }
87 }
88 ctx.logger.error(`Error running ${command} ${args[0]}: ${err?.toString()}`);
89 throw err;
90 } finally {
91 clearTimeout(timeoutId);
92 }
93}
94
95/**
96 * Root of the repository where the .sl folder lives.
97 * Throws only if `command` is invalid, so this check can double as validation of the `sl` command */
98export async function findRoot(ctx: RepositoryContext): Promise<AbsolutePath | undefined> {
99 try {
100 return (await runCommand(ctx, ['root'])).stdout;
101 } catch (error) {
102 if (
103 ['ENOENT', 'EACCES'].includes((error as {code: string}).code) ||
104 // On Windows, we won't necessarily get an actual ENOENT error code in the error,
105 // because execa does not attempt to detect this.
106 // Other spawning libraries like node-cross-spawn do, which is the approach we can take.
107 // We can do this because we know how `root` uses exit codes.
108 // https://github.com/sindresorhus/execa/issues/469#issuecomment-859924543
109 (os.platform() === 'win32' && (error as {exitCode: number}).exitCode === 1)
110 ) {
111 ctx.logger.error(`command ${ctx.cmd} not found`, error);
112 throw error;
113 }
114 }
115}
116
117/**
118 * Ask sapling to recursively list all the repo roots up to the system root.
119 */
120export async function findRoots(ctx: RepositoryContext): Promise<AbsolutePath[] | undefined> {
121 try {
122 return (await runCommand(ctx, ['debugroots'])).stdout.split('\n').reverse();
123 } catch (error) {
124 ctx.logger.error(`Failed to find repository roots starting from ${ctx.cwd}`, error);
125 return undefined;
126 }
127}
128
129export async function findDotDir(ctx: RepositoryContext): Promise<AbsolutePath | undefined> {
130 try {
131 return (await runCommand(ctx, ['root', '--dotdir'])).stdout;
132 } catch (error) {
133 ctx.logger.error(`Failed to find repository dotdir in ${ctx.cwd}`, error);
134 return undefined;
135 }
136}
137
138/**
139 * Read multiple configs.
140 * Return a Map from config name to config value for present configs.
141 * Missing configs will not be returned.
142 * Errors are silenced.
143 */
144export async function getConfigs<T extends string>(
145 ctx: RepositoryContext,
146 configNames: ReadonlyArray<T>,
147): Promise<Map<T, string>> {
148 if (configOverride !== undefined) {
149 // Use the override to answer config questions.
150 const configMap = new Map(
151 configNames.flatMap(name => {
152 const value = configOverride?.get(name);
153 return value === undefined ? [] : [[name, value]];
154 }),
155 );
156 return configMap;
157 }
158 const configMap: Map<T, string> = new Map();
159 try {
160 // config command does not support multiple configs yet, but supports multiple sections.
161 // (such limitation makes sense for non-JSON output, which can be ambiguous)
162 // TODO: Remove this once we can validate that OSS users are using a new enough Sapling version.
163 const sections = new Set<string>(configNames.flatMap(name => name.split('.').at(0) ?? []));
164 const result = await runCommand(ctx, ['config', '-Tjson'].concat([...sections]));
165 const configs: [{name: T; value: string}] = JSON.parse(result.stdout);
166 for (const config of configs) {
167 configMap.set(config.name, config.value);
168 }
169 } catch (e) {
170 ctx.logger.error(`failed to read configs from ${ctx.cwd}: ${e}`);
171 }
172 ctx.logger.info(`loaded configs from ${ctx.cwd}:`, configMap);
173 return configMap;
174}
175
176export type ConfigLevel = 'user' | 'system' | 'local';
177export async function setConfig(
178 ctx: RepositoryContext,
179 level: ConfigLevel,
180 configName: string,
181 configValue: string,
182): Promise<void> {
183 await runCommand(ctx, ['config', `--${level}`, configName, configValue]);
184}
185
186export function getExecParams(
187 command: string,
188 args_: Array<string>,
189 cwd: string,
190 options_?: EjecaOptions,
191 env?: NodeJS.ProcessEnv | Record<string, string>,
192): {
193 command: string;
194 args: Array<string>;
195 options: EjecaOptions;
196} {
197 let args = [...args_, '--noninteractive'];
198 // expandHomeDir is not supported on windows
199 if (process.platform !== 'win32') {
200 // commit/amend have unconventional ways of escaping slashes from messages.
201 // We have to 'unescape' to make it work correctly.
202 args = args.map(arg => arg.replace(/\\\\/g, '\\'));
203 }
204 const [commandName] = args;
205 if (EXCLUDE_FROM_BLACKBOX_COMMANDS.has(commandName)) {
206 args.push('--config', 'extensions.blackbox=!');
207 }
208 // The command should be non-interactive, so do not even attempt to run an
209 // (interactive) editor.
210 const editor = os.platform() === 'win32' ? 'exit /b 1' : 'false';
211 const newEnv = {
212 ...options_?.env,
213 ...env,
214 // TODO: remove when SL_ENCODING is used everywhere
215 HGENCODING: 'UTF-8',
216 SL_ENCODING: 'UTF-8',
217 // override any custom aliases a user has defined.
218 SL_AUTOMATION: 'true',
219 // allow looking up diff numbers even in plain mode.
220 // allow constructing the `.git/sl` repo regardless of the identity.
221 // allow automatically setting ui.username.
222 SL_AUTOMATION_EXCEPT: 'ghrevset,phrevset,progress,sniff,username',
223 EDITOR: undefined,
224 VISUAL: undefined,
225 HGUSER: undefined,
226 HGEDITOR: editor,
227 } as unknown as NodeJS.ProcessEnv;
228 let langEnv = newEnv.LANG ?? process.env.LANG;
229 if (langEnv === undefined || !langEnv.toUpperCase().endsWith('UTF-8')) {
230 langEnv = 'C.UTF-8';
231 }
232 newEnv.LANG = langEnv;
233 const options: EjecaOptions = {
234 ...options_,
235 env: newEnv,
236 cwd,
237 };
238
239 if (args_[0] === 'status') {
240 // Take a lock when running status so that multiple status calls in parallel don't overload watchman
241 args.push('--config', 'fsmonitor.watchman-query-lock=True');
242 }
243
244 if (options.ipc) {
245 args.push('--config', 'progress.renderer=nodeipc');
246 }
247
248 // TODO: we could run with systemd for better OOM protection when on linux
249 return {command, args, options};
250}
251
252// Avoid spamming the blackbox with read-only commands.
253const EXCLUDE_FROM_BLACKBOX_COMMANDS = new Set(['cat', 'config', 'debugignore', 'diff', 'log', 'show', 'status']);
254
255/**
256 * extract repo info from a remote url, typically for GitHub or GitHub Enterprise,
257 * in various formats:
258 * https://github.com/owner/repo
259 * https://github.com/owner/repo.git
260 * github.com/owner/repo.git
261 * git@github.com:owner/repo.git
262 * ssh:git@github.com:owner/repo.git
263 * ssh://git@github.com/owner/repo.git
264 * git+ssh:git@github.com:owner/repo.git
265 *
266 * or similar urls with GitHub Enterprise hostnames:
267 * https://ghe.myCompany.com/owner/repo
268 */
269export function extractRepoInfoFromUrl(
270 url: string,
271): {repo: string; owner: string; hostname: string} | null {
272 const match =
273 /(?:https:\/\/(.*)\/|(?:git\+ssh:\/\/|ssh:\/\/)?(?:git@)?([^:/]*)[:/])([^/]+)\/(.+?)(?:\.git)?$/.exec(
274 url,
275 );
276
277 if (match == null) {
278 return null;
279 }
280
281 const [, hostname1, hostname2, owner, repo] = match;
282 return {owner, repo, hostname: hostname1 ?? hostname2};
283}
284
285export function computeNewConflicts(
286 previousConflicts: MergeConflicts,
287 commandOutput: ResolveCommandConflictOutput,
288 fetchStartTimestamp: number,
289): MergeConflicts | undefined {
290 const newConflictData = commandOutput?.[0];
291 if (newConflictData?.command == null) {
292 return undefined;
293 }
294
295 const conflicts: MergeConflicts = {
296 state: 'loaded',
297 command: newConflictData.command,
298 toContinue: newConflictData.command_details.to_continue,
299 toAbort: newConflictData.command_details.to_abort,
300 files: [],
301 fetchStartTimestamp,
302 fetchCompletedTimestamp: Date.now(),
303 hashes: newConflictData.hashes,
304 };
305
306 const previousFiles = previousConflicts?.files ?? [];
307
308 const newConflictSet = new Set(newConflictData.conflicts.map(conflict => conflict.path));
309 const conflictFileData = new Map(
310 newConflictData.conflicts.map(conflict => [conflict.path, conflict]),
311 );
312 const previousFilesSet = new Set(previousFiles.map(file => file.path));
313 const newlyAddedConflicts = new Set(
314 [...newConflictSet].filter(file => !previousFilesSet.has(file)),
315 );
316 // we may have seen conflicts before, some of which might now be resolved.
317 // Preserve previous ordering by first pulling from previous files
318 conflicts.files = previousFiles.map(conflict =>
319 newConflictSet.has(conflict.path)
320 ? {...conflict, status: 'U'}
321 : // 'R' is overloaded to mean "removed" for `sl status` but 'Resolved' for `sl resolve --list`
322 // let's re-write this to make the UI layer simpler.
323 {...conflict, status: 'Resolved'},
324 );
325 if (newlyAddedConflicts.size > 0) {
326 conflicts.files.push(
327 ...[...newlyAddedConflicts].map(conflict => ({
328 path: conflict,
329 status: 'U' as const,
330 conflictType: getConflictType(conflictFileData.get(conflict)) ?? ConflictType.BothChanged,
331 })),
332 );
333 }
334
335 return conflicts;
336}
337
338function getConflictType(
339 conflict?: ResolveCommandConflictOutput[number]['conflicts'][number],
340): ConflictType | undefined {
341 if (conflict == null) {
342 return undefined;
343 }
344 let type;
345 if (conflict.local.exists && conflict.other.exists) {
346 type = ConflictType.BothChanged;
347 } else if (conflict.other.exists) {
348 type = ConflictType.DeletedInDest;
349 } else {
350 type = ConflictType.DeletedInSource;
351 }
352 return type;
353}
354
355/**
356 * By default, detect "jest" and enable config override to avoid shelling out.
357 * See also `getConfigs`.
358 */
359let configOverride: undefined | Map<string, string> =
360 typeof jest === 'undefined' ? undefined : new Map();
361
362/**
363 * Set the "knownConfig" used by new repos.
364 * This is useful in tests and prevents shelling out to config commands.
365 */
366export function setConfigOverrideForTests(configs: Iterable<[string, string]>, override = true) {
367 if (override) {
368 configOverride = new Map(configs);
369 } else {
370 configOverride ??= new Map();
371 for (const [key, value] of configs) {
372 configOverride.set(key, value);
373 }
374 }
375}
376