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