| 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 | |
| 8 | import type {EjecaOptions, EjecaReturn} from 'shared/ejeca'; |
| 9 | import type {RepositoryContext} from './serverTypes'; |
| 10 | |
| 11 | import {ConflictType, type AbsolutePath, type MergeConflicts} from 'isl/src/types'; |
| 12 | import os from 'node:os'; |
| 13 | import {ejeca} from 'shared/ejeca'; |
| 14 | import {isEjecaError} from './utils'; |
| 15 | |
| 16 | export const MAX_FETCHED_FILES_PER_COMMIT = 25; |
| 17 | export 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. */ |
| 20 | export const READ_COMMAND_TIMEOUT_MS = 60_000; |
| 21 | |
| 22 | export type ConflictFileData = { |
| 23 | contents: string | null; |
| 24 | exists: boolean; |
| 25 | isexec: boolean; |
| 26 | issymlink: boolean; |
| 27 | }; |
| 28 | export 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). */ |
| 53 | export 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 */ |
| 98 | export 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 | */ |
| 120 | export 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 | |
| 129 | export 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 | */ |
| 144 | export 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 | |
| 176 | export type ConfigLevel = 'user' | 'system' | 'local'; |
| 177 | export 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 | |
| 186 | export 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. |
| 253 | const 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 | */ |
| 269 | export 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 | |
| 285 | export 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 | |
| 338 | function 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 | */ |
| 359 | let 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 | */ |
| 366 | export 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 | |