addons/isl-server/src/commands.tsblame
View source
b69ab311/**
b69ab312 * Copyright (c) Meta Platforms, Inc. and affiliates.
b69ab313 *
b69ab314 * This source code is licensed under the MIT license found in the
b69ab315 * LICENSE file in the root directory of this source tree.
b69ab316 */
b69ab317
b69ab318import type {EjecaOptions, EjecaReturn} from 'shared/ejeca';
b69ab319import type {RepositoryContext} from './serverTypes';
b69ab3110
b69ab3111import {ConflictType, type AbsolutePath, type MergeConflicts} from 'isl/src/types';
b69ab3112import os from 'node:os';
b69ab3113import {ejeca} from 'shared/ejeca';
b69ab3114import {isEjecaError} from './utils';
b69ab3115
b69ab3116export const MAX_FETCHED_FILES_PER_COMMIT = 25;
b69ab3117export const MAX_SIMULTANEOUS_CAT_CALLS = 4;
b69ab3118/** Timeout for non-operation commands. Operations like goto and rebase are expected to take longer,
b69ab3119 * but status, log, cat, etc should typically take <10s. */
b69ab3120export const READ_COMMAND_TIMEOUT_MS = 60_000;
b69ab3121
b69ab3122export type ConflictFileData = {
b69ab3123 contents: string | null;
b69ab3124 exists: boolean;
b69ab3125 isexec: boolean;
b69ab3126 issymlink: boolean;
b69ab3127};
b69ab3128export type ResolveCommandConflictOutput = [
b69ab3129 | {
b69ab3130 command: null;
b69ab3131 conflicts: [];
b69ab3132 pathconflicts: [];
b69ab3133 }
b69ab3134 | {
b69ab3135 command: string;
b69ab3136 command_details: {cmd: string; to_abort: string; to_continue: string};
b69ab3137 conflicts: Array<{
b69ab3138 base: ConflictFileData;
b69ab3139 local: ConflictFileData;
b69ab3140 output: ConflictFileData;
b69ab3141 other: ConflictFileData;
b69ab3142 path: string;
b69ab3143 }>;
b69ab3144 pathconflicts: Array<never>;
b69ab3145 hashes?: {
b69ab3146 local?: string;
b69ab3147 other?: string;
b69ab3148 };
b69ab3149 },
b69ab3150];
b69ab3151
b69ab3152/** Run an sl command (without analytics). */
b69ab3153export async function runCommand(
b69ab3154 ctx: RepositoryContext,
b69ab3155 args_: Array<string>,
b69ab3156 options_?: EjecaOptions,
b69ab3157 timeout: number = READ_COMMAND_TIMEOUT_MS,
b69ab3158): Promise<EjecaReturn> {
b69ab3159 const {command, args, options} = getExecParams(ctx.cmd, args_, ctx.cwd, options_);
b69ab3160 ctx.logger.log('run command: ', ctx.cwd, command, args[0]);
b69ab3161 const result = ejeca(command, args, options);
b69ab3162
b69ab3163 let timedOut = false;
b69ab3164 let timeoutId: NodeJS.Timeout | undefined;
b69ab3165 if (timeout > 0) {
b69ab3166 timeoutId = setTimeout(() => {
b69ab3167 result.kill('SIGTERM', {forceKillAfterTimeout: 5_000});
b69ab3168 ctx.logger.error(`Timed out waiting for ${command} ${args[0]} to finish`);
b69ab3169 timedOut = true;
b69ab3170 }, timeout);
b69ab3171 result.on('exit', () => {
b69ab3172 clearTimeout(timeoutId);
b69ab3173 });
b69ab3174 }
b69ab3175
b69ab3176 try {
b69ab3177 const val = await result;
b69ab3178 return val;
b69ab3179 } catch (err: unknown) {
b69ab3180 if (isEjecaError(err)) {
b69ab3181 if (err.killed) {
b69ab3182 if (timedOut) {
b69ab3183 throw new Error('Timed out');
b69ab3184 }
b69ab3185 throw new Error('Killed');
b69ab3186 }
b69ab3187 }
b69ab3188 ctx.logger.error(`Error running ${command} ${args[0]}: ${err?.toString()}`);
b69ab3189 throw err;
b69ab3190 } finally {
b69ab3191 clearTimeout(timeoutId);
b69ab3192 }
b69ab3193}
b69ab3194
b69ab3195/**
b69ab3196 * Root of the repository where the .sl folder lives.
b69ab3197 * Throws only if `command` is invalid, so this check can double as validation of the `sl` command */
b69ab3198export async function findRoot(ctx: RepositoryContext): Promise<AbsolutePath | undefined> {
b69ab3199 try {
b69ab31100 return (await runCommand(ctx, ['root'])).stdout;
b69ab31101 } catch (error) {
b69ab31102 if (
b69ab31103 ['ENOENT', 'EACCES'].includes((error as {code: string}).code) ||
b69ab31104 // On Windows, we won't necessarily get an actual ENOENT error code in the error,
b69ab31105 // because execa does not attempt to detect this.
b69ab31106 // Other spawning libraries like node-cross-spawn do, which is the approach we can take.
b69ab31107 // We can do this because we know how `root` uses exit codes.
b69ab31108 // https://github.com/sindresorhus/execa/issues/469#issuecomment-859924543
b69ab31109 (os.platform() === 'win32' && (error as {exitCode: number}).exitCode === 1)
b69ab31110 ) {
b69ab31111 ctx.logger.error(`command ${ctx.cmd} not found`, error);
b69ab31112 throw error;
b69ab31113 }
b69ab31114 }
b69ab31115}
b69ab31116
b69ab31117/**
b69ab31118 * Ask sapling to recursively list all the repo roots up to the system root.
b69ab31119 */
b69ab31120export async function findRoots(ctx: RepositoryContext): Promise<AbsolutePath[] | undefined> {
b69ab31121 try {
b69ab31122 return (await runCommand(ctx, ['debugroots'])).stdout.split('\n').reverse();
b69ab31123 } catch (error) {
b69ab31124 ctx.logger.error(`Failed to find repository roots starting from ${ctx.cwd}`, error);
b69ab31125 return undefined;
b69ab31126 }
b69ab31127}
b69ab31128
b69ab31129export async function findDotDir(ctx: RepositoryContext): Promise<AbsolutePath | undefined> {
b69ab31130 try {
b69ab31131 return (await runCommand(ctx, ['root', '--dotdir'])).stdout;
b69ab31132 } catch (error) {
b69ab31133 ctx.logger.error(`Failed to find repository dotdir in ${ctx.cwd}`, error);
b69ab31134 return undefined;
b69ab31135 }
b69ab31136}
b69ab31137
b69ab31138/**
b69ab31139 * Read multiple configs.
b69ab31140 * Return a Map from config name to config value for present configs.
b69ab31141 * Missing configs will not be returned.
b69ab31142 * Errors are silenced.
b69ab31143 */
b69ab31144export async function getConfigs<T extends string>(
b69ab31145 ctx: RepositoryContext,
b69ab31146 configNames: ReadonlyArray<T>,
b69ab31147): Promise<Map<T, string>> {
b69ab31148 if (configOverride !== undefined) {
b69ab31149 // Use the override to answer config questions.
b69ab31150 const configMap = new Map(
b69ab31151 configNames.flatMap(name => {
b69ab31152 const value = configOverride?.get(name);
b69ab31153 return value === undefined ? [] : [[name, value]];
b69ab31154 }),
b69ab31155 );
b69ab31156 return configMap;
b69ab31157 }
b69ab31158 const configMap: Map<T, string> = new Map();
b69ab31159 try {
b69ab31160 // config command does not support multiple configs yet, but supports multiple sections.
b69ab31161 // (such limitation makes sense for non-JSON output, which can be ambiguous)
b69ab31162 // TODO: Remove this once we can validate that OSS users are using a new enough Sapling version.
b69ab31163 const sections = new Set<string>(configNames.flatMap(name => name.split('.').at(0) ?? []));
b69ab31164 const result = await runCommand(ctx, ['config', '-Tjson'].concat([...sections]));
b69ab31165 const configs: [{name: T; value: string}] = JSON.parse(result.stdout);
b69ab31166 for (const config of configs) {
b69ab31167 configMap.set(config.name, config.value);
b69ab31168 }
b69ab31169 } catch (e) {
b69ab31170 ctx.logger.error(`failed to read configs from ${ctx.cwd}: ${e}`);
b69ab31171 }
b69ab31172 ctx.logger.info(`loaded configs from ${ctx.cwd}:`, configMap);
b69ab31173 return configMap;
b69ab31174}
b69ab31175
b69ab31176export type ConfigLevel = 'user' | 'system' | 'local';
b69ab31177export async function setConfig(
b69ab31178 ctx: RepositoryContext,
b69ab31179 level: ConfigLevel,
b69ab31180 configName: string,
b69ab31181 configValue: string,
b69ab31182): Promise<void> {
b69ab31183 await runCommand(ctx, ['config', `--${level}`, configName, configValue]);
b69ab31184}
b69ab31185
b69ab31186export function getExecParams(
b69ab31187 command: string,
b69ab31188 args_: Array<string>,
b69ab31189 cwd: string,
b69ab31190 options_?: EjecaOptions,
b69ab31191 env?: NodeJS.ProcessEnv | Record<string, string>,
b69ab31192): {
b69ab31193 command: string;
b69ab31194 args: Array<string>;
b69ab31195 options: EjecaOptions;
b69ab31196} {
b69ab31197 let args = [...args_, '--noninteractive'];
b69ab31198 // expandHomeDir is not supported on windows
b69ab31199 if (process.platform !== 'win32') {
b69ab31200 // commit/amend have unconventional ways of escaping slashes from messages.
b69ab31201 // We have to 'unescape' to make it work correctly.
b69ab31202 args = args.map(arg => arg.replace(/\\\\/g, '\\'));
b69ab31203 }
b69ab31204 const [commandName] = args;
b69ab31205 if (EXCLUDE_FROM_BLACKBOX_COMMANDS.has(commandName)) {
b69ab31206 args.push('--config', 'extensions.blackbox=!');
b69ab31207 }
b69ab31208 // The command should be non-interactive, so do not even attempt to run an
b69ab31209 // (interactive) editor.
b69ab31210 const editor = os.platform() === 'win32' ? 'exit /b 1' : 'false';
b69ab31211 const newEnv = {
b69ab31212 ...options_?.env,
b69ab31213 ...env,
b69ab31214 // TODO: remove when SL_ENCODING is used everywhere
b69ab31215 HGENCODING: 'UTF-8',
b69ab31216 SL_ENCODING: 'UTF-8',
b69ab31217 // override any custom aliases a user has defined.
b69ab31218 SL_AUTOMATION: 'true',
b69ab31219 // allow looking up diff numbers even in plain mode.
b69ab31220 // allow constructing the `.git/sl` repo regardless of the identity.
b69ab31221 // allow automatically setting ui.username.
b69ab31222 SL_AUTOMATION_EXCEPT: 'ghrevset,phrevset,progress,sniff,username',
b69ab31223 EDITOR: undefined,
b69ab31224 VISUAL: undefined,
b69ab31225 HGUSER: undefined,
b69ab31226 HGEDITOR: editor,
b69ab31227 } as unknown as NodeJS.ProcessEnv;
b69ab31228 let langEnv = newEnv.LANG ?? process.env.LANG;
b69ab31229 if (langEnv === undefined || !langEnv.toUpperCase().endsWith('UTF-8')) {
b69ab31230 langEnv = 'C.UTF-8';
b69ab31231 }
b69ab31232 newEnv.LANG = langEnv;
b69ab31233 const options: EjecaOptions = {
b69ab31234 ...options_,
b69ab31235 env: newEnv,
b69ab31236 cwd,
b69ab31237 };
b69ab31238
b69ab31239 if (args_[0] === 'status') {
b69ab31240 // Take a lock when running status so that multiple status calls in parallel don't overload watchman
b69ab31241 args.push('--config', 'fsmonitor.watchman-query-lock=True');
b69ab31242 }
b69ab31243
b69ab31244 if (options.ipc) {
b69ab31245 args.push('--config', 'progress.renderer=nodeipc');
b69ab31246 }
b69ab31247
b69ab31248 // TODO: we could run with systemd for better OOM protection when on linux
b69ab31249 return {command, args, options};
b69ab31250}
b69ab31251
b69ab31252// Avoid spamming the blackbox with read-only commands.
5ffddc6253const EXCLUDE_FROM_BLACKBOX_COMMANDS = new Set(['cat', 'config', 'debugignore', 'diff', 'log', 'show', 'status']);
b69ab31254
b69ab31255/**
b69ab31256 * extract repo info from a remote url, typically for GitHub or GitHub Enterprise,
b69ab31257 * in various formats:
b69ab31258 * https://github.com/owner/repo
b69ab31259 * https://github.com/owner/repo.git
b69ab31260 * github.com/owner/repo.git
b69ab31261 * git@github.com:owner/repo.git
b69ab31262 * ssh:git@github.com:owner/repo.git
b69ab31263 * ssh://git@github.com/owner/repo.git
b69ab31264 * git+ssh:git@github.com:owner/repo.git
b69ab31265 *
b69ab31266 * or similar urls with GitHub Enterprise hostnames:
b69ab31267 * https://ghe.myCompany.com/owner/repo
b69ab31268 */
b69ab31269export function extractRepoInfoFromUrl(
b69ab31270 url: string,
b69ab31271): {repo: string; owner: string; hostname: string} | null {
b69ab31272 const match =
b69ab31273 /(?:https:\/\/(.*)\/|(?:git\+ssh:\/\/|ssh:\/\/)?(?:git@)?([^:/]*)[:/])([^/]+)\/(.+?)(?:\.git)?$/.exec(
b69ab31274 url,
b69ab31275 );
b69ab31276
b69ab31277 if (match == null) {
b69ab31278 return null;
b69ab31279 }
b69ab31280
b69ab31281 const [, hostname1, hostname2, owner, repo] = match;
b69ab31282 return {owner, repo, hostname: hostname1 ?? hostname2};
b69ab31283}
b69ab31284
b69ab31285export function computeNewConflicts(
b69ab31286 previousConflicts: MergeConflicts,
b69ab31287 commandOutput: ResolveCommandConflictOutput,
b69ab31288 fetchStartTimestamp: number,
b69ab31289): MergeConflicts | undefined {
b69ab31290 const newConflictData = commandOutput?.[0];
b69ab31291 if (newConflictData?.command == null) {
b69ab31292 return undefined;
b69ab31293 }
b69ab31294
b69ab31295 const conflicts: MergeConflicts = {
b69ab31296 state: 'loaded',
b69ab31297 command: newConflictData.command,
b69ab31298 toContinue: newConflictData.command_details.to_continue,
b69ab31299 toAbort: newConflictData.command_details.to_abort,
b69ab31300 files: [],
b69ab31301 fetchStartTimestamp,
b69ab31302 fetchCompletedTimestamp: Date.now(),
b69ab31303 hashes: newConflictData.hashes,
b69ab31304 };
b69ab31305
b69ab31306 const previousFiles = previousConflicts?.files ?? [];
b69ab31307
b69ab31308 const newConflictSet = new Set(newConflictData.conflicts.map(conflict => conflict.path));
b69ab31309 const conflictFileData = new Map(
b69ab31310 newConflictData.conflicts.map(conflict => [conflict.path, conflict]),
b69ab31311 );
b69ab31312 const previousFilesSet = new Set(previousFiles.map(file => file.path));
b69ab31313 const newlyAddedConflicts = new Set(
b69ab31314 [...newConflictSet].filter(file => !previousFilesSet.has(file)),
b69ab31315 );
b69ab31316 // we may have seen conflicts before, some of which might now be resolved.
b69ab31317 // Preserve previous ordering by first pulling from previous files
b69ab31318 conflicts.files = previousFiles.map(conflict =>
b69ab31319 newConflictSet.has(conflict.path)
b69ab31320 ? {...conflict, status: 'U'}
b69ab31321 : // 'R' is overloaded to mean "removed" for `sl status` but 'Resolved' for `sl resolve --list`
b69ab31322 // let's re-write this to make the UI layer simpler.
b69ab31323 {...conflict, status: 'Resolved'},
b69ab31324 );
b69ab31325 if (newlyAddedConflicts.size > 0) {
b69ab31326 conflicts.files.push(
b69ab31327 ...[...newlyAddedConflicts].map(conflict => ({
b69ab31328 path: conflict,
b69ab31329 status: 'U' as const,
b69ab31330 conflictType: getConflictType(conflictFileData.get(conflict)) ?? ConflictType.BothChanged,
b69ab31331 })),
b69ab31332 );
b69ab31333 }
b69ab31334
b69ab31335 return conflicts;
b69ab31336}
b69ab31337
b69ab31338function getConflictType(
b69ab31339 conflict?: ResolveCommandConflictOutput[number]['conflicts'][number],
b69ab31340): ConflictType | undefined {
b69ab31341 if (conflict == null) {
b69ab31342 return undefined;
b69ab31343 }
b69ab31344 let type;
b69ab31345 if (conflict.local.exists && conflict.other.exists) {
b69ab31346 type = ConflictType.BothChanged;
b69ab31347 } else if (conflict.other.exists) {
b69ab31348 type = ConflictType.DeletedInDest;
b69ab31349 } else {
b69ab31350 type = ConflictType.DeletedInSource;
b69ab31351 }
b69ab31352 return type;
b69ab31353}
b69ab31354
b69ab31355/**
b69ab31356 * By default, detect "jest" and enable config override to avoid shelling out.
b69ab31357 * See also `getConfigs`.
b69ab31358 */
b69ab31359let configOverride: undefined | Map<string, string> =
b69ab31360 typeof jest === 'undefined' ? undefined : new Map();
b69ab31361
b69ab31362/**
b69ab31363 * Set the "knownConfig" used by new repos.
b69ab31364 * This is useful in tests and prevents shelling out to config commands.
b69ab31365 */
b69ab31366export function setConfigOverrideForTests(configs: Iterable<[string, string]>, override = true) {
b69ab31367 if (override) {
b69ab31368 configOverride = new Map(configs);
b69ab31369 } else {
b69ab31370 configOverride ??= new Map();
b69ab31371 for (const [key, value] of configs) {
b69ab31372 configOverride.set(key, value);
b69ab31373 }
b69ab31374 }
b69ab31375}