diff --git a/extensions/git/src/git.ts b/extensions/git/src/git.ts new file mode 100644 index 0000000..0ad28a7 --- /dev/null +++ b/extensions/git/src/git.ts @@ -0,0 +1,3487 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { promises as fs, exists, realpath } from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import * as cp from 'child_process'; +import { fileURLToPath } from 'url'; +import which from 'which'; +import { EventEmitter } from 'events'; +import { fileTypeFromBuffer } from 'file-type'; +import { assign, groupBy, IDisposable, toDisposable, dispose, mkdirp, readBytes, detectUnicodeEncoding, Encoding, onceEvent, splitInChunks, Limiter, Versions, isWindows, pathEquals, isMacintosh, isDescendant, relativePathWithNoFallback, Mutable } from './util'; +import { CancellationError, CancellationToken, ConfigurationChangeEvent, LogOutputChannel, Progress, Uri, workspace } from 'vscode'; +import type { Commit as ApiCommit, Ref, Branch, Remote, LogOptions, Change, CommitOptions, RefQuery as ApiRefQuery, InitOptions, DiffChange, Worktree as ApiWorktree } from './api/git'; +import { RefType, ForcePushMode, GitErrorCodes, Status } from './api/git.constants'; +import * as byline from 'byline'; +import { StringDecoder } from 'string_decoder'; + +// https://github.com/microsoft/vscode/issues/65693 +const MAX_CLI_LENGTH = 30000; + +function assertValidObjectId(sha: string, allowZero = false): void { + if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(sha) || (!allowZero && /^0+$/.test(sha))) { + throw new Error(allowZero ? 'Expected a full Git object ID' : 'Expected a full, non-zero Git object ID'); + } +} + +export interface IGit { + path: string; + version: string; +} + +export interface IDotGit { + readonly path: string; + readonly commonPath?: string; + readonly superProjectPath?: string; + readonly isBare: boolean; +} + +export interface IFileStatus { + x: string; + y: string; + path: string; + rename?: string; +} + +export interface Stash { + readonly hash: string; + readonly parents: string[]; + readonly index: number; + readonly description: string; + readonly branchName?: string; + readonly authorDate?: Date; + readonly commitDate?: Date; +} + +interface MutableRemote extends Remote { + fetchUrl?: string; + pushUrl?: string; + isReadOnly: boolean; +} + +// TODO@eamodio: Move to git.d.ts once we are good with the api +/** + * Log file options. + */ +export interface LogFileOptions { + /** Optional. Continue listing the history of a file beyond renames */ + readonly follow?: boolean; + /** Optional. The maximum number of log entries to retrieve. */ + readonly maxEntries?: number | string; + /** Optional. The Git sha (hash) to start retrieving log entries from. */ + readonly hash?: string; + /** Optional. Specifies whether to start retrieving log entries in reverse order. */ + readonly reverse?: boolean; + readonly sortByAuthorDate?: boolean; + readonly shortStats?: boolean; +} + +function parseVersion(raw: string): string { + return raw.replace(/^git version /, ''); +} + +function findSpecificGit(path: string, onValidate: (path: string) => boolean): Promise { + return new Promise((c, e) => { + if (!onValidate(path)) { + return e(new Error(`Path "${path}" is invalid.`)); + } + + const buffers: Buffer[] = []; + const child = cp.spawn(path, ['--version']); + child.stdout.on('data', (b: Buffer) => buffers.push(b)); + child.on('error', cpErrorHandler(e)); + child.on('close', code => code ? e(new Error(`Not found. Code: ${code}`)) : c({ path, version: parseVersion(Buffer.concat(buffers).toString('utf8').trim()) })); + }); +} + +function findGitDarwin(onValidate: (path: string) => boolean): Promise { + return new Promise((c, e) => { + cp.exec('which git', (err, gitPathBuffer) => { + if (err) { + return e(new Error(`Executing "which git" failed: ${err.message}`)); + } + + const path = gitPathBuffer.toString().trim(); + + function getVersion(path: string) { + if (!onValidate(path)) { + return e(new Error(`Path "${path}" is invalid.`)); + } + + // make sure git executes + cp.exec('git --version', (err, stdout) => { + + if (err) { + return e(new Error(`Executing "git --version" failed: ${err.message}`)); + } + + return c({ path, version: parseVersion(stdout.trim()) }); + }); + } + + if (path !== '/usr/bin/git') { + return getVersion(path); + } + + // must check if XCode is installed + cp.exec('xcode-select -p', (err) => { + if (err && err.code === 2) { + // git is not installed, and launching /usr/bin/git + // will prompt the user to install it + + return e(new Error('Executing "xcode-select -p" failed with error code 2.')); + } + + getVersion(path); + }); + }); + }); +} + +function findSystemGitWin32(base: string, onValidate: (path: string) => boolean): Promise { + if (!base) { + return Promise.reject('Not found'); + } + + return findSpecificGit(path.join(base, 'Git', 'cmd', 'git.exe'), onValidate); +} + +async function findGitWin32InPath(onValidate: (path: string) => boolean): Promise { + const path = await which('git.exe'); + return findSpecificGit(path, onValidate); +} + +function findGitWin32(onValidate: (path: string) => boolean): Promise { + return findSystemGitWin32(process.env['ProgramW6432'] as string, onValidate) + .then(undefined, () => findSystemGitWin32(process.env['ProgramFiles(x86)'] as string, onValidate)) + .then(undefined, () => findSystemGitWin32(process.env['ProgramFiles'] as string, onValidate)) + .then(undefined, () => findSystemGitWin32(path.join(process.env['LocalAppData'] as string, 'Programs'), onValidate)) + .then(undefined, () => findGitWin32InPath(onValidate)); +} + +export async function findGit(hints: string[], onValidate: (path: string) => boolean, logger: LogOutputChannel): Promise { + for (const hint of hints) { + try { + return await findSpecificGit(hint, onValidate); + } catch (err) { + // noop + logger.info(`Unable to find git on the PATH: "${hint}". Error: ${err.message}`); + } + } + + try { + switch (process.platform) { + case 'darwin': return await findGitDarwin(onValidate); + case 'win32': return await findGitWin32(onValidate); + default: return await findSpecificGit('git', onValidate); + } + } catch (err) { + // noop + logger.warn(`Unable to find git. Error: ${err.message}`); + } + + throw new Error('Git installation not found.'); +} + +export interface IExecutionResult { + exitCode: number; + stdout: T; + stderr: string; +} + +function cpErrorHandler(cb: (reason?: any) => void): (reason?: any) => void { + return err => { + if (/ENOENT/.test(err.message)) { + err = new GitError({ + error: err, + message: 'Failed to execute git (ENOENT)', + gitErrorCode: GitErrorCodes.NotAGitRepository + }); + } + + cb(err); + }; +} + +export interface SpawnOptions extends cp.SpawnOptions { + input?: string; + log?: boolean; + cancellationToken?: CancellationToken; + onSpawn?: (childProcess: cp.ChildProcess) => void; +} + +async function exec(child: cp.ChildProcess, cancellationToken?: CancellationToken): Promise> { + if (!child.stdout || !child.stderr) { + throw new GitError({ message: 'Failed to get stdout or stderr from git process.' }); + } + + if (cancellationToken && cancellationToken.isCancellationRequested) { + throw new CancellationError(); + } + + const disposables: IDisposable[] = []; + + const once = (ee: NodeJS.EventEmitter, name: string, fn: (...args: any[]) => void) => { + ee.once(name, fn); + disposables.push(toDisposable(() => ee.removeListener(name, fn))); + }; + + const on = (ee: NodeJS.EventEmitter, name: string, fn: (...args: any[]) => void) => { + ee.on(name, fn); + disposables.push(toDisposable(() => ee.removeListener(name, fn))); + }; + + let result = Promise.all([ + new Promise((c, e) => { + once(child, 'error', cpErrorHandler(e)); + once(child, 'exit', c); + }), + new Promise(c => { + const buffers: Buffer[] = []; + on(child.stdout!, 'data', (b: Buffer) => buffers.push(b)); + once(child.stdout!, 'close', () => c(Buffer.concat(buffers))); + }), + new Promise(c => { + const buffers: Buffer[] = []; + on(child.stderr!, 'data', (b: Buffer) => buffers.push(b)); + once(child.stderr!, 'close', () => c(Buffer.concat(buffers).toString('utf8'))); + }) + ]) as Promise<[number, Buffer, string]>; + + if (cancellationToken) { + const cancellationPromise = new Promise<[number, Buffer, string]>((_, e) => { + onceEvent(cancellationToken.onCancellationRequested)(() => { + try { + child.kill(); + } catch (err) { + // noop + } + + e(new CancellationError()); + }); + }); + + result = Promise.race([result, cancellationPromise]); + } + + try { + const [exitCode, stdout, stderr] = await result; + return { exitCode, stdout, stderr }; + } finally { + dispose(disposables); + } +} + +export interface IGitErrorData { + error?: Error; + message?: string; + stdout?: string; + stderr?: string; + exitCode?: number; + gitErrorCode?: string; + gitCommand?: string; + gitArgs?: string[]; +} + +export class GitError extends Error { + + error?: Error; + stdout?: string; + stderr?: string; + exitCode?: number; + gitErrorCode?: string; + gitCommand?: string; + gitArgs?: string[]; + + constructor(data: IGitErrorData) { + super(data.error?.message || data.message || 'Git error'); + + this.error = data.error; + this.stdout = data.stdout; + this.stderr = data.stderr; + this.exitCode = data.exitCode; + this.gitErrorCode = data.gitErrorCode; + this.gitCommand = data.gitCommand; + this.gitArgs = data.gitArgs; + } + + override toString(): string { + let result = this.message + ' ' + JSON.stringify({ + exitCode: this.exitCode, + gitErrorCode: this.gitErrorCode, + gitCommand: this.gitCommand, + stdout: this.stdout, + stderr: this.stderr + }, null, 2); + + if (this.error?.stack) { + result += this.error.stack; + } + + return result; + } +} + +export interface IGitOptions { + gitPath: string; + userAgent: string; + version: string; + env?: { [key: string]: string }; +} + +function getGitErrorCode(stderr: string): string | undefined { + if (/Another git process seems to be running in this repository|If no other git process is currently running/.test(stderr)) { + return GitErrorCodes.RepositoryIsLocked; + } else if (/Authentication failed/i.test(stderr)) { + return GitErrorCodes.AuthenticationFailed; + } else if (/Not a git repository/i.test(stderr)) { + return GitErrorCodes.NotAGitRepository; + } else if (/bad config file/.test(stderr)) { + return GitErrorCodes.BadConfigFile; + } else if (/cannot make pipe for command substitution|cannot create standard input pipe/.test(stderr)) { + return GitErrorCodes.CantCreatePipe; + } else if (/Repository not found/.test(stderr)) { + return GitErrorCodes.RepositoryNotFound; + } else if (/unable to access/.test(stderr)) { + return GitErrorCodes.CantAccessRemote; + } else if (/branch '.+' is not fully merged/.test(stderr)) { + return GitErrorCodes.BranchNotFullyMerged; + } else if (/Couldn\'t find remote ref/.test(stderr)) { + return GitErrorCodes.NoRemoteReference; + } else if (/A branch named '.+' already exists/.test(stderr)) { + return GitErrorCodes.BranchAlreadyExists; + } else if (/'.+' is not a valid branch name/.test(stderr)) { + return GitErrorCodes.InvalidBranchName; + } else if (/Please,? commit your changes or stash them/.test(stderr)) { + return GitErrorCodes.DirtyWorkTree; + } else if (/detected dubious ownership in repository at/.test(stderr)) { + return GitErrorCodes.NotASafeGitRepository; + } else if (/contains modified or untracked files|use --force to delete it/.test(stderr)) { + return GitErrorCodes.WorktreeContainsChanges; + } else if (/fatal: '[^']+' already exists/.test(stderr)) { + return GitErrorCodes.WorktreeAlreadyExists; + } else if (/is already used by worktree at/.test(stderr)) { + return GitErrorCodes.WorktreeBranchAlreadyUsed; + } + return undefined; +} + +// https://github.com/microsoft/vscode/issues/89373 +// https://github.com/git-for-windows/git/issues/2478 +function sanitizePath(path: string): string { + return path.replace(/^([a-z]):\\/i, (_, letter) => `${letter.toUpperCase()}:\\`); +} + +function sanitizeRelativePath(path: string): string { + return path.replace(/\\/g, '/'); +} + +const COMMIT_FORMAT = '%H%n%aN%n%aE%n%at%n%ct%n%P%n%D%n%B'; +const STASH_FORMAT = '%H%n%P%n%gd%n%gs%n%at%n%ct'; + +export interface ICloneOptions { + readonly parentPath: string; + readonly targetName?: string; + readonly progress: Progress<{ increment: number }>; + readonly recursive?: boolean; + readonly ref?: string; +} + +export class Git { + + readonly path: string; + readonly userAgent: string; + readonly version: string; + readonly env: { [key: string]: string }; + + private commandsToLog: string[] = []; + + private _onOutput = new EventEmitter(); + get onOutput(): EventEmitter { return this._onOutput; } + + constructor(options: IGitOptions) { + this.path = options.gitPath; + this.version = options.version; + this.userAgent = options.userAgent; + this.env = options.env || {}; + + const onConfigurationChanged = (e?: ConfigurationChangeEvent) => { + if (e !== undefined && !e.affectsConfiguration('git.commandsToLog')) { + return; + } + + const config = workspace.getConfiguration('git'); + this.commandsToLog = config.get('commandsToLog', []); + }; + + workspace.onDidChangeConfiguration(onConfigurationChanged, this); + onConfigurationChanged(); + } + + compareGitVersionTo(version: string): -1 | 0 | 1 { + return Versions.compare(Versions.fromString(this.version), Versions.fromString(version)); + } + + open(repositoryRoot: string, repositoryRootRealPath: string | undefined, dotGit: IDotGit, logger: LogOutputChannel): Repository { + return new Repository(this, repositoryRoot, repositoryRootRealPath, dotGit, logger); + } + + async init(repository: string, options: InitOptions = {}): Promise { + const args = ['init']; + + if (options.defaultBranch && options.defaultBranch !== '' && this.compareGitVersionTo('2.28.0') !== -1) { + args.push('-b', options.defaultBranch); + } + + await this.exec(repository, args); + } + + async clone(url: string, options: ICloneOptions, cancellationToken?: CancellationToken): Promise { + const baseFolderName = options.targetName || decodeURI(url).replace(/[\/]+$/, '').replace(/^.*[\/\\]/, '').replace(/\.git$/, '') || 'repository'; + let folderName = baseFolderName; + let folderPath = path.join(options.parentPath, folderName); + let count = 1; + + if (!options.targetName) { + while (count < 20 && await new Promise(c => exists(folderPath, c))) { + folderName = `${baseFolderName}-${count++}`; + folderPath = path.join(options.parentPath, folderName); + } + } + + await mkdirp(options.parentPath); + + const onSpawn = (child: cp.ChildProcess) => { + const decoder = new StringDecoder('utf8'); + const lineStream = new byline.LineStream({ encoding: 'utf8' }); + child.stderr!.on('data', (buffer: Buffer) => lineStream.write(decoder.write(buffer))); + + let totalProgress = 0; + let previousProgress = 0; + + lineStream.on('data', (line: string) => { + let match: RegExpExecArray | null = null; + + if (match = /Counting objects:\s*(\d+)%/i.exec(line)) { + totalProgress = Math.floor(parseInt(match[1]) * 0.1); + } else if (match = /Compressing objects:\s*(\d+)%/i.exec(line)) { + totalProgress = 10 + Math.floor(parseInt(match[1]) * 0.1); + } else if (match = /Receiving objects:\s*(\d+)%/i.exec(line)) { + totalProgress = 20 + Math.floor(parseInt(match[1]) * 0.4); + } else if (match = /Resolving deltas:\s*(\d+)%/i.exec(line)) { + totalProgress = 60 + Math.floor(parseInt(match[1]) * 0.4); + } + + if (totalProgress !== previousProgress) { + options.progress.report({ increment: totalProgress - previousProgress }); + previousProgress = totalProgress; + } + }); + }; + + try { + const command = ['clone', url.includes(' ') ? encodeURI(url) : url, folderPath, '--progress']; + if (options.recursive) { + command.push('--recursive'); + } + if (options.ref) { + command.push('--branch', options.ref); + } + await this.exec(options.parentPath, command, { + cancellationToken, + env: { 'GIT_HTTP_USER_AGENT': this.userAgent }, + onSpawn, + }); + } catch (err) { + if (err.stderr) { + err.stderr = err.stderr.replace(/^Cloning.+$/m, '').trim(); + err.stderr = err.stderr.replace(/^ERROR:\s+/, '').trim(); + } + + throw err; + } + + return folderPath; + } + + async getRepositoryRoot(pathInsidePossibleRepository: string): Promise { + const result = await this.exec(pathInsidePossibleRepository, ['rev-parse', '--show-toplevel']); + + // Keep trailing spaces which are part of the directory name + const repositoryRootPath = path.normalize(result.stdout.trimStart().replace(/[\r\n]+$/, '')); + + // Handle symbolic links and UNC paths + // Git 2.31 added the `--path-format` flag to rev-parse which + // allows us to get the relative path of the repository root + if (!pathEquals(pathInsidePossibleRepository, repositoryRootPath) && + !isDescendant(repositoryRootPath, pathInsidePossibleRepository) && + !isDescendant(pathInsidePossibleRepository, repositoryRootPath) && + this.compareGitVersionTo('2.31.0') !== -1) { + const relativePathResult = await this.exec(pathInsidePossibleRepository, ['rev-parse', '--path-format=relative', '--show-toplevel',]); + return path.resolve(pathInsidePossibleRepository, relativePathResult.stdout.trimStart().replace(/[\r\n]+$/, '')); + } + + if (isWindows) { + // On Git 2.25+ if you call `rev-parse --show-toplevel` on a mapped drive, instead of getting the mapped + // drive path back, you get the UNC path for the mapped drive. So we will try to normalize it back to the + // mapped drive path, if possible + const repoUri = Uri.file(repositoryRootPath); + const pathUri = Uri.file(pathInsidePossibleRepository); + if (repoUri.authority.length !== 0 && pathUri.authority.length === 0) { + const match = /^[\/]?([a-zA-Z])[:\/]/.exec(pathUri.path); + if (match !== null) { + const [, letter] = match; + + try { + const networkPath = await new Promise(resolve => + realpath.native(`${letter}:\\`, { encoding: 'utf8' }, (err, resolvedPath) => + resolve(err !== null ? undefined : resolvedPath), + ), + ); + if (networkPath !== undefined) { + // If the repository is at the root of the mapped drive then we + // have to append `\` (ex: D:\) otherwise the path is not valid. + const isDriveRoot = pathEquals(repoUri.fsPath, networkPath); + + return path.normalize( + repoUri.fsPath.replace( + networkPath, + `${letter.toLowerCase()}:${isDriveRoot || networkPath.endsWith('\\') ? '\\' : ''}` + ), + ); + } + } catch { } + } + + return path.normalize(pathUri.fsPath); + } + } + + return repositoryRootPath; + } + + async getRepositoryDotGit(repositoryPath: string): Promise { + let dotGitPath: string | undefined, commonDotGitPath: string | undefined, superProjectPath: string | undefined; + + const args = ['rev-parse', '--git-dir', '--git-common-dir']; + if (this.compareGitVersionTo('2.13.0') >= 0) { + args.push('--show-superproject-working-tree'); + } + + const result = await this.exec(repositoryPath, args); + [dotGitPath, commonDotGitPath, superProjectPath] = result.stdout.split('\n').map(r => r.trim()); + + if (!path.isAbsolute(dotGitPath)) { + dotGitPath = path.join(repositoryPath, dotGitPath); + } + dotGitPath = path.normalize(dotGitPath); + + if (commonDotGitPath) { + if (!path.isAbsolute(commonDotGitPath)) { + commonDotGitPath = path.join(repositoryPath, commonDotGitPath); + } + commonDotGitPath = path.normalize(commonDotGitPath); + } + + const raw = await fs.readFile(path.join(commonDotGitPath ?? dotGitPath, 'config'), 'utf8'); + const coreSections = GitConfigParser.parse(raw).find(s => s.name === 'core'); + const isBare = coreSections?.properties['bare'] === 'true'; + + return { + isBare, + path: dotGitPath, + commonPath: commonDotGitPath !== dotGitPath ? commonDotGitPath : undefined, + superProjectPath: superProjectPath ? path.normalize(superProjectPath) : undefined + }; + } + + async exec(cwd: string, args: string[], options: SpawnOptions = {}): Promise> { + options = assign({ cwd }, options || {}); + return await this._exec(args, options); + } + + async exec2(args: string[], options: SpawnOptions = {}): Promise> { + return await this._exec(args, options); + } + + stream(cwd: string, args: string[], options: SpawnOptions = {}): cp.ChildProcess { + options = assign({ cwd }, options || {}); + const child = this.spawn(args, options); + + if (options.log !== false) { + const startTime = Date.now(); + child.on('exit', (_) => { + this.log(`> git ${args.join(' ')} [${Date.now() - startTime}ms]${child.killed ? ' (cancelled)' : ''}\n`); + }); + } + + return child; + } + + private async _exec(args: string[], options: SpawnOptions = {}): Promise> { + const child = this.spawn(args, options); + + options.onSpawn?.(child); + + if (options.input) { + child.stdin!.end(options.input, 'utf8'); + } + + const startExec = Date.now(); + let bufferResult: IExecutionResult; + + try { + bufferResult = await exec(child, options.cancellationToken); + } catch (ex) { + if (ex instanceof CancellationError) { + this.log(`> git ${args.join(' ')} [${Date.now() - startExec}ms] (cancelled)\n`); + } + + throw ex; + } + + if (options.log !== false) { + // command + this.log(`> git ${args.join(' ')} [${Date.now() - startExec}ms]\n`); + + // stdout + if (bufferResult.stdout.length > 0 && args.find(a => this.commandsToLog.includes(a))) { + this.log(`${bufferResult.stdout}\n`); + } + + // stderr + if (bufferResult.stderr.length > 0) { + this.log(`${bufferResult.stderr}\n`); + } + } + + const result: IExecutionResult = { + exitCode: bufferResult.exitCode, + stdout: bufferResult.stdout.toString('utf8'), + stderr: bufferResult.stderr + }; + + if (bufferResult.exitCode) { + return Promise.reject>(new GitError({ + message: 'Failed to execute git', + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode, + gitErrorCode: getGitErrorCode(result.stderr), + gitCommand: args[0], + gitArgs: args + })); + } + + return result; + } + + spawn(args: string[], options: SpawnOptions = {}): cp.ChildProcess { + if (!this.path) { + throw new Error('git could not be found in the system.'); + } + + if (!options) { + options = {}; + } + + if (!options.stdio && !options.input) { + options.stdio = ['ignore', null, null]; // Unless provided, ignore stdin and leave default streams for stdout and stderr + } + + options.env = assign({}, process.env, this.env, options.env || {}, { + VSCODE_GIT_COMMAND: args[0], + LANGUAGE: 'en', + LC_ALL: 'en_US.UTF-8', + LANG: 'en_US.UTF-8', + GIT_PAGER: 'cat' + }); + + const cwd = this.getCwd(options); + if (cwd) { + options.cwd = sanitizePath(cwd); + } + + return cp.spawn(this.path, args, options); + } + + private getCwd(options: SpawnOptions): string | undefined { + const cwd = options.cwd; + if (typeof cwd === 'undefined' || typeof cwd === 'string') { + return cwd; + } + + if (cwd.protocol === 'file:') { + return fileURLToPath(cwd); + } + + return undefined; + } + + private log(output: string): void { + this._onOutput.emit('log', output); + } + + async mergeFile(options: { input1Path: string; input2Path: string; basePath: string; diff3?: boolean }): Promise { + const args = ['merge-file', '-p', options.input1Path, options.basePath, options.input2Path]; + if (options.diff3) { + args.push('--diff3'); + } else { + args.push('--no-diff3'); + } + + try { + const result = await this.exec(os.homedir(), args); + return result.stdout; + } catch (err) { + if (typeof err.stdout === 'string') { + // The merge had conflicts, stdout still contains the merged result (with conflict markers) + return err.stdout; + } else { + throw err; + } + } + } + + async addSafeDirectory(repositoryPath: string): Promise { + await this.exec(os.homedir(), ['config', '--global', '--add', 'safe.directory', repositoryPath]); + return; + } +} + +export interface CommitShortStat { + readonly files: number; + readonly insertions: number; + readonly deletions: number; +} + +export interface CoAuthor { + readonly name: string; + readonly email: string; +} + +export interface Commit { + hash: string; + message: string; + parents: string[]; + authorDate?: Date; + authorName?: string; + authorEmail?: string; + commitDate?: Date; + refNames: string[]; + shortStat?: CommitShortStat; + coAuthors?: CoAuthor[]; +} + +export interface RefQuery extends ApiRefQuery { + readonly includeCommitDetails?: boolean; +} + +interface GitConfigSection { + name: string; + subSectionName?: string; + properties: { [key: string]: string }; +} + +class GitConfigParser { + private static readonly _lineSeparator = /\r?\n/; + + private static readonly _propertyRegex = /^\s*(\w+)\s*=\s*"?([^"]+)"?$/; + private static readonly _sectionRegex = /^\s*\[\s*([^\]]+?)\s*(\"[^"]+\")*\]\s*$/; + + static parse(raw: string): GitConfigSection[] { + const config: { sections: GitConfigSection[] } = { sections: [] }; + let section: GitConfigSection = { name: 'DEFAULT', properties: {} }; + + const addSection = (section?: GitConfigSection) => { + if (!section) { return; } + config.sections.push(section); + }; + + for (const line of raw.split(GitConfigParser._lineSeparator)) { + // Section + const sectionMatch = line.match(GitConfigParser._sectionRegex); + if (sectionMatch?.length === 3) { + addSection(section); + section = { name: sectionMatch[1], subSectionName: sectionMatch[2]?.replaceAll('"', ''), properties: {} }; + + continue; + } + + // Property + const propertyMatch = line.match(GitConfigParser._propertyRegex); + if (propertyMatch?.length === 3 && !Object.keys(section.properties).includes(propertyMatch[1])) { + section.properties[propertyMatch[1]] = propertyMatch[2]; + } + } + + addSection(section); + + return config.sections; + } +} + +export class GitStatusParser { + + private lastRaw = ''; + private result: IFileStatus[] = []; + + get status(): IFileStatus[] { + return this.result; + } + + update(raw: string): void { + let i = 0; + let nextI: number | undefined; + + raw = this.lastRaw + raw; + + while ((nextI = this.parseEntry(raw, i)) !== undefined) { + i = nextI; + } + + this.lastRaw = raw.substr(i); + } + + private parseEntry(raw: string, i: number): number | undefined { + if (i + 4 >= raw.length) { + return; + } + + let lastIndex: number; + const entry: IFileStatus = { + x: raw.charAt(i++), + y: raw.charAt(i++), + rename: undefined, + path: '' + }; + + // space + i++; + + if (entry.x === 'R' || entry.y === 'R' || entry.x === 'C') { + lastIndex = raw.indexOf('\0', i); + + if (lastIndex === -1) { + return; + } + + entry.rename = raw.substring(i, lastIndex); + i = lastIndex + 1; + } + + lastIndex = raw.indexOf('\0', i); + + if (lastIndex === -1) { + return; + } + + entry.path = raw.substring(i, lastIndex); + + // If path ends with slash, it must be a nested git repo + if (entry.path[entry.path.length - 1] !== '/') { + this.result.push(entry); + } + + return lastIndex + 1; + } +} + +export interface Submodule { + name: string; + path: string; + url: string; +} + +export function parseGitmodules(raw: string): Submodule[] { + const result: Submodule[] = []; + + for (const submoduleSection of GitConfigParser.parse(raw).filter(s => s.name === 'submodule')) { + if (submoduleSection.subSectionName && submoduleSection.properties['path'] && submoduleSection.properties['url']) { + result.push({ + name: submoduleSection.subSectionName, + path: submoduleSection.properties['path'], + url: submoduleSection.properties['url'] + }); + } + } + + return result; +} + +export function parseGitRemotes(raw: string): MutableRemote[] { + const remotes: MutableRemote[] = []; + + for (const remoteSection of GitConfigParser.parse(raw).filter(s => s.name === 'remote')) { + if (remoteSection.subSectionName) { + remotes.push({ + name: remoteSection.subSectionName, + fetchUrl: remoteSection.properties['url'], + pushUrl: remoteSection.properties['pushurl'] ?? remoteSection.properties['url'], + isReadOnly: false + }); + } + } + + return remotes; +} + +const commitRegex = /([0-9a-f]{40})\n(.*)\n(.*)\n(.*)\n(.*)\n(.*)\n(.*)(?:\n([^]*?))?(?:\x00)(?:\n((?:.*)files? changed(?:.*))$)?/gm; + +export function parseGitCommits(data: string): Commit[] { + const commits: Commit[] = []; + + let ref; + let authorName; + let authorEmail; + let authorDate; + let commitDate; + let parents; + let refNames; + let message; + let shortStat; + let match; + + do { + match = commitRegex.exec(data); + if (match === null) { + break; + } + + [, ref, authorName, authorEmail, authorDate, commitDate, parents, refNames, message, shortStat] = match; + + if (message[message.length - 1] === '\n') { + message = message.substr(0, message.length - 1); + } + + // Stop excessive memory usage by using substr -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 + commits.push({ + hash: ` ${ref}`.substr(1), + message: ` ${message}`.substr(1), + parents: parents ? parents.split(' ') : [], + authorDate: new Date(Number(authorDate) * 1000), + authorName: ` ${authorName}`.substr(1), + authorEmail: ` ${authorEmail}`.substr(1), + commitDate: new Date(Number(commitDate) * 1000), + refNames: refNames.split(',').map(s => s.trim()), + shortStat: shortStat ? parseGitDiffShortStat(shortStat) : undefined, + coAuthors: parseCoAuthors(message) + }); + } while (true); + + return commits; +} + +const coAuthorRegex = /^Co-authored-by:\s*(.+?)\s*<([^>]+)>\s*$/gim; + +export function parseCoAuthors(message: string): CoAuthor[] { + const coAuthors: CoAuthor[] = []; + const emails = new Set(); + let match; + + coAuthorRegex.lastIndex = 0; + while ((match = coAuthorRegex.exec(message)) !== null) { + const name = match[1].trim(); + const email = match[2].trim(); + + // Squash merge commit messages repeat the trailers of each squashed commit + const emailKey = email.toLowerCase(); + if (name && email && !emails.has(emailKey)) { + emails.add(emailKey); + coAuthors.push({ name, email }); + } + } + + return coAuthors; +} + +const diffShortStatRegex = /(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?/; + +function parseGitDiffShortStat(data: string): CommitShortStat { + const matches = data.trim().match(diffShortStatRegex); + + if (!matches) { + return { files: 0, insertions: 0, deletions: 0 }; + } + + const [, files, insertions = undefined, deletions = undefined] = matches; + return { files: parseInt(files), insertions: parseInt(insertions ?? '0'), deletions: parseInt(deletions ?? '0') }; +} + +export interface LsTreeElement { + mode: string; + type: string; + object: string; + size: string; + file: string; +} + +export function parseLsTree(raw: string): LsTreeElement[] { + return raw.split('\n') + .filter(l => !!l) + .map(line => /^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(.*)$/.exec(line)!) + .filter(m => !!m) + .map(([, mode, type, object, size, file]) => ({ mode, type, object, size, file })); +} + +interface LsFilesElement { + mode: string; + object: string; + stage: string; + file: string; +} + +export function parseLsFiles(raw: string): LsFilesElement[] { + return raw.split('\n') + .filter(l => !!l) + .map(line => /^(\S+)\s+(\S+)\s+(\S+)\s+(.*)$/.exec(line)!) + .filter(m => !!m) + .map(([, mode, object, stage, file]) => ({ mode, object, stage, file })); +} + +const stashRegex = /([0-9a-f]{40})\n(.*)\nstash@{(\d+)}\n(WIP\s)?on\s([^:]+):\s(.*)\n(\d+)\n(\d+)(?:\x00)/gmi; + +function parseGitStashes(raw: string): Stash[] { + const result: Stash[] = []; + + let match, hash, parents, index, wip, branchName, description, authorDate, commitDate; + + do { + match = stashRegex.exec(raw); + if (match === null) { + break; + } + + [, hash, parents, index, wip, branchName, description, authorDate, commitDate] = match; + result.push({ + hash, + parents: parents.split(' '), + index: parseInt(index), + branchName: branchName.trim(), + description: wip ? `WIP (${description.trim()})` : description.trim(), + authorDate: authorDate ? new Date(Number(authorDate) * 1000) : undefined, + commitDate: commitDate ? new Date(Number(commitDate) * 1000) : undefined, + }); + } while (true); + + return result; +} + +function parseGitChanges(repositoryRoot: string, raw: string): Change[] { + let index = 0; + const result: Change[] = []; + const segments = raw.trim().split('\x00').filter(s => s); + + segmentsLoop: + while (index < segments.length - 1) { + const change = segments[index++]; + const resourcePath = segments[index++]; + + if (!change || !resourcePath) { + break; + } + + const originalUri = Uri.file(path.isAbsolute(resourcePath) ? resourcePath : path.join(repositoryRoot, resourcePath)); + + let uri = originalUri; + let renameUri = originalUri; + let status: Status = Status.UNTRACKED; + + // Copy or Rename status comes with a number (ex: 'R100'). + // We don't need the number, we use only first character of the status. + switch (change[0]) { + case 'A': + status = Status.INDEX_ADDED; + break; + + case 'M': + status = Status.MODIFIED; + break; + + case 'D': + status = Status.DELETED; + break; + + // Rename contains two paths, the second one is what the file is renamed/copied to. + case 'R': { + if (index >= segments.length) { + break; + } + + const newPath = segments[index++]; + if (!newPath) { + break; + } + + status = Status.INDEX_RENAMED; + uri = renameUri = Uri.file(path.isAbsolute(newPath) ? newPath : path.join(repositoryRoot, newPath)); + break; + } + default: + // Unknown status + break segmentsLoop; + } + + result.push({ status, uri, originalUri, renameUri }); + } + + return result; +} + +function parseGitChangesRaw(repositoryRoot: string, raw: string): DiffChange[] { + const changes: Change[] = []; + const numStats = new Map(); + + let index = 0; + const segments = raw.trim().split('\x00').filter(s => s); + + segmentsLoop: + while (index < segments.length) { + const segment = segments[index++]; + if (!segment) { + break; + } + + if (segment.startsWith(':')) { + // Parse --raw output + const [, , , , change] = segment.split(' '); + const filePath = segments[index++]; + const originalUri = Uri.file(path.isAbsolute(filePath) ? filePath : path.join(repositoryRoot, filePath)); + + let uri = originalUri; + let renameUri = originalUri; + let status: Status = Status.UNTRACKED; + + switch (change[0]) { + case 'A': + status = Status.INDEX_ADDED; + break; + case 'M': + status = Status.MODIFIED; + break; + case 'D': + status = Status.DELETED; + break; + case 'R': { + if (index >= segments.length) { + break; + } + const newPath = segments[index++]; + if (!newPath) { + break; + } + + status = Status.INDEX_RENAMED; + uri = renameUri = Uri.file(path.isAbsolute(newPath) ? newPath : path.join(repositoryRoot, newPath)); + break; + } + default: + // Unknown status + break segmentsLoop; + } + + changes.push({ status, uri, originalUri, renameUri }); + } else { + // Parse --numstat output + const [insertions, deletions, filePath] = segment.split('\t'); + + let numstatPath: string; + if (filePath === '') { + // For renamed files, filePath is empty and the old/new paths + // are in the next two null-terminated segments. We skip the + // old path and use the new path for the stats key. + index++; + + const renamePath = segments[index++]; + numstatPath = path.isAbsolute(renamePath) ? renamePath : path.join(repositoryRoot, renamePath); + } else { + numstatPath = path.isAbsolute(filePath) ? filePath : path.join(repositoryRoot, filePath); + } + + numStats.set(numstatPath, { + insertions: insertions === '-' ? 0 : parseInt(insertions), + deletions: deletions === '-' ? 0 : parseInt(deletions), + }); + } + } + + return changes.map(change => ({ + ...change, + insertions: numStats.get(change.uri.fsPath)?.insertions ?? 0, + deletions: numStats.get(change.uri.fsPath)?.deletions ?? 0, + })); +} + +export interface BlameInformation { + readonly hash: string; + readonly subject?: string; + readonly authorName?: string; + readonly authorEmail?: string; + readonly authorDate?: number; + readonly ranges: { + readonly startLineNumber: number; + readonly endLineNumber: number; + }[]; +} + +function parseGitBlame(data: string): BlameInformation[] { + const lineSeparator = /\r?\n/; + const commitRegex = /^([0-9a-f]{40})/gm; + + const blameInformation = new Map(); + + let commitHash: string | undefined = undefined; + let authorName: string | undefined = undefined; + let authorEmail: string | undefined = undefined; + let authorTime: number | undefined = undefined; + let message: string | undefined = undefined; + let startLineNumber: number | undefined = undefined; + let endLineNumber: number | undefined = undefined; + + for (const line of data.split(lineSeparator)) { + // Commit + const commitMatch = line.match(commitRegex); + if (!commitHash && commitMatch) { + const segments = line.split(' '); + + commitHash = commitMatch[0]; + startLineNumber = Number(segments[2]); + endLineNumber = Number(segments[2]) + Number(segments[3]) - 1; + } + + // Commit properties + if (commitHash && line.startsWith('author ')) { + authorName = line.substring('author '.length); + } + if (commitHash && line.startsWith('author-mail ')) { + authorEmail = line.substring('author-mail <'.length, line.length - 1); + } + if (commitHash && line.startsWith('author-time ')) { + authorTime = Number(line.substring('author-time '.length)) * 1000; + } + if (commitHash && line.startsWith('summary ')) { + message = line.substring('summary '.length); + } + + // Commit end + if (commitHash && startLineNumber && endLineNumber && line.startsWith('filename ')) { + const existingCommit = blameInformation.get(commitHash); + if (existingCommit) { + existingCommit.ranges.push({ startLineNumber, endLineNumber }); + blameInformation.set(commitHash, existingCommit); + } else { + blameInformation.set(commitHash, { + hash: commitHash, authorName, authorEmail, authorDate: authorTime, subject: message, ranges: [{ startLineNumber, endLineNumber }] + }); + } + + commitHash = authorName = authorEmail = authorTime = message = startLineNumber = endLineNumber = undefined; + } + } + + return Array.from(blameInformation.values()); +} + +const REFS_FORMAT = '%(refname)%00%(objectname)%00%(*objectname)'; +const REFS_WITH_DETAILS_FORMAT = `${REFS_FORMAT}%00%(parent)%00%(*parent)%00%(authorname)%00%(*authorname)%00%(committerdate:unix)%00%(*committerdate:unix)%00%(subject)%00%(*subject)`; + +function parseRefs(data: string): (Ref | Branch)[] { + const refRegex = /^(refs\/[^\0]+)\0([0-9a-f]{40})\0([0-9a-f]{40})?(?:\0(.*))?$/gm; + + const headRegex = /^refs\/heads\/([^ ]+)$/; + const remoteHeadRegex = /^refs\/remotes\/([^/]+)\/([^ ]+)$/; + const tagRegex = /^refs\/tags\/([^ ]+)$/; + const statusRegex = /\[(?:ahead ([0-9]+))?[,\s]*(?:behind ([0-9]+))?]|\[gone]/; + + let ref: string | undefined; + let commitHash: string | undefined; + let tagCommitHash: string | undefined; + let details: string | undefined; + let commitParents: string | undefined; + let tagCommitParents: string | undefined; + let commitSubject: string | undefined; + let tagCommitSubject: string | undefined; + let authorName: string | undefined; + let tagAuthorName: string | undefined; + let committerDate: string | undefined; + let tagCommitterDate: string | undefined; + let status: string | undefined; + + const refs: (Ref | Branch)[] = []; + + let match: RegExpExecArray | null; + let refMatch: RegExpExecArray | null; + + do { + match = refRegex.exec(data); + if (match === null) { + break; + } + + [, ref, commitHash, tagCommitHash, details] = match; + [commitParents, tagCommitParents, authorName, tagAuthorName, committerDate, tagCommitterDate, commitSubject, tagCommitSubject, status] = details?.split('\0') ?? []; + + const parents = tagCommitParents || commitParents; + const subject = tagCommitSubject || commitSubject; + const author = tagAuthorName || authorName; + const date = tagCommitterDate || committerDate; + + const commitDetails = parents && subject && author && date + ? { + hash: commitHash, + message: subject, + parents: parents.split(' '), + authorName: author, + commitDate: date ? new Date(Number(date) * 1000) : undefined, + } satisfies ApiCommit : undefined; + + if (refMatch = headRegex.exec(ref)) { + const [, aheadCount, behindCount] = statusRegex.exec(status) ?? []; + const ahead = status ? aheadCount ? Number(aheadCount) : 0 : undefined; + const behind = status ? behindCount ? Number(behindCount) : 0 : undefined; + refs.push({ name: refMatch[1], commit: commitHash, commitDetails, ahead, behind, type: RefType.Head }); + } else if (refMatch = remoteHeadRegex.exec(ref)) { + const name = `${refMatch[1]}/${refMatch[2]}`; + refs.push({ name, remote: refMatch[1], commit: commitHash, commitDetails, type: RefType.RemoteHead }); + } else if (refMatch = tagRegex.exec(ref)) { + refs.push({ name: refMatch[1], commit: tagCommitHash ?? commitHash, commitDetails, type: RefType.Tag }); + } + } while (true); + + return refs; +} + +export interface PullOptions { + readonly unshallow?: boolean; + readonly tags?: boolean; + readonly autoStash?: boolean; + readonly cancellationToken?: CancellationToken; +} + +export interface Worktree extends ApiWorktree { + readonly commitDetails?: ApiCommit; +} + +export class Repository { + private _isUsingRefTable = false; + + constructor( + private _git: Git, + private repositoryRoot: string, + private repositoryRootRealPath: string | undefined, + readonly dotGit: IDotGit, + private logger: LogOutputChannel + ) { + this._kind = this.dotGit.commonPath + ? 'worktree' + : this.dotGit.superProjectPath + ? 'submodule' + : 'repository'; + } + + private readonly _kind: 'repository' | 'submodule' | 'worktree'; + get kind(): 'repository' | 'submodule' | 'worktree' { + return this._kind; + } + + get git(): Git { + return this._git; + } + + get root(): string { + return this.repositoryRoot; + } + + get rootRealPath(): string | undefined { + return this.repositoryRootRealPath; + } + + async exec(args: string[], options: SpawnOptions = {}): Promise> { + return await this.git.exec(this.repositoryRoot, args, options); + } + + stream(args: string[], options: SpawnOptions = {}): cp.ChildProcess { + return this.git.stream(this.repositoryRoot, args, options); + } + + spawn(args: string[], options: SpawnOptions = {}): cp.ChildProcess { + return this.git.spawn(args, options); + } + + async config(command: string, scope: string, key: string, value: any = null, options: SpawnOptions = {}): Promise { + const args = ['config', `--${command}`]; + + if (scope) { + args.push(`--${scope}`); + } + + args.push(key); + + if (value) { + args.push(value); + } + + try { + const result = await this.exec(args, options); + return result.stdout.trim(); + } + catch (err) { + this.logger.warn(`[Git][config] git config failed: ${err.message}`); + return ''; + } + } + + async getConfigs(scope: string): Promise<{ key: string; value: string }[]> { + const args = ['config']; + + if (scope) { + args.push('--' + scope); + } + + args.push('-l'); + + const result = await this.exec(args); + const lines = result.stdout.trim().split(/\r|\r\n|\n/); + + return lines.map(entry => { + const equalsIndex = entry.indexOf('='); + return { key: entry.substr(0, equalsIndex), value: entry.substr(equalsIndex + 1) }; + }); + } + + async log(options?: LogOptions, cancellationToken?: CancellationToken): Promise { + const spawnOptions: SpawnOptions = { cancellationToken }; + // Depending on the user's Git settings (i18n.commitEncoding, i18n.logOutputEncoding), + // commit messages may appear garbled, + // so the output encoding for commit messages is forced to UTF-8. + // This setting was applied not only here, but also in other places where commit messages are retrieved. + const args = ['-c', 'i18n.logOutputEncoding=UTF-8', 'log', `--format=${COMMIT_FORMAT}`, '-z']; + + if (options?.shortStats) { + args.push('--shortstat'); + + if (this._git.compareGitVersionTo('2.31') !== -1) { + args.push('--diff-merges=first-parent'); + } + } + + if (options?.reverse) { + args.push('--reverse', '--ancestry-path'); + } + + if (options?.sortByAuthorDate) { + args.push('--author-date-order'); + } + + if (options?.range) { + args.push(options.range); + } else { + args.push(`-n${options?.maxEntries ?? 32}`); + } + + if (options?.author) { + args.push(`--author=${options.author}`); + } + + if (options?.grep) { + args.push(`--grep=${options.grep}`); + args.push('--extended-regexp'); + args.push('--regexp-ignore-case'); + } + + if (typeof options?.maxParents === 'number') { + args.push(`--max-parents=${options.maxParents}`); + } + + if (typeof options?.skip === 'number') { + args.push(`--skip=${options.skip}`); + } + + if (options?.refNames) { + args.push('--topo-order'); + args.push('--decorate=full'); + + // In order to avoid hitting the command line limit due to large number of reference + // names (can happen when the `all` filter is used in the Source Control Graph view), + // we are passing the reference names via stdin. + spawnOptions.input = options.refNames.join('\n'); + args.push('--stdin'); + } + + if (options?.path) { + args.push('--', options.path); + } + + const result = await this.exec(args, spawnOptions); + if (result.exitCode) { + // An empty repo + return []; + } + + return parseGitCommits(result.stdout); + } + + async logFile(uri: Uri, options?: LogFileOptions, cancellationToken?: CancellationToken): Promise { + const args = ['-c', 'i18n.logOutputEncoding=UTF-8', 'log', `--format=${COMMIT_FORMAT}`, '-z']; + + if (options?.maxEntries && !options?.reverse) { + args.push(`-n${options.maxEntries}`); + } + + if (options?.hash) { + // If we are reversing, we must add a range (with HEAD) because we are using --ancestry-path for better reverse walking + if (options?.reverse) { + args.push('--reverse', '--ancestry-path', `${options.hash}..HEAD`); + } else { + args.push(options.hash); + } + } + + if (options?.shortStats) { + args.push('--shortstat'); + } + + if (options?.sortByAuthorDate) { + args.push('--author-date-order'); + } + + if (options?.follow) { + args.push('--follow'); + } + + args.push('--', uri.fsPath); + + try { + const result = await this.exec(args, { cancellationToken }); + if (result.exitCode) { + // No file history, e.g. a new file or untracked + return []; + } + + return parseGitCommits(result.stdout); + } catch (err) { + // Repository has no commits yet + if (/does not have any commits yet/.test(err.stderr)) { + return []; + } + + throw err; + } + } + + async reflog(ref: string, pattern: string): Promise { + const args = ['reflog', ref, `--grep-reflog=${pattern}`]; + const result = await this.exec(args); + if (result.exitCode) { + return []; + } + + return result.stdout.split('\n') + .filter(entry => !!entry); + } + + async buffer(ref: string, filePath: string): Promise { + const relativePath = this.sanitizeRelativePath(filePath); + const child = this.stream(['show', '--textconv', `${ref}:${relativePath}`]); + + if (!child.stdout) { + return Promise.reject('Can\'t open file from git'); + } + + const { exitCode, stdout, stderr } = await exec(child); + + if (exitCode) { + const err = new GitError({ + message: 'Could not show object.', + exitCode + }); + + if (/exists on disk, but not in/.test(stderr)) { + err.gitErrorCode = GitErrorCodes.WrongCase; + } + + return Promise.reject(err); + } + + return stdout; + } + + async getObjectDetails(treeish: string, path: string): Promise<{ mode: string; object: string; size: number }> { + if (!treeish || treeish === ':1' || treeish === ':2' || treeish === ':3') { // index + const elements = await this.lsfiles(path); + + if (elements.length === 0) { + throw new GitError({ message: 'Path not known by git', gitErrorCode: GitErrorCodes.UnknownPath }); + } + + const { mode, object } = treeish !== '' + ? elements.find(e => e.stage === treeish.substring(1)) ?? elements[0] + : elements[0]; + + const catFile = await this.exec(['cat-file', '-s', object]); + const size = parseInt(catFile.stdout); + + return { mode, object, size }; + } + + const elements = await this.lstree(treeish, path); + + if (elements.length === 0) { + throw new GitError({ message: 'Path not known by git', gitErrorCode: GitErrorCodes.UnknownPath }); + } + + const { mode, object, size } = elements[0]; + return { mode, object, size: parseInt(size) || 0 }; + } + + async lstree(treeish: string, path?: string, options?: { recursive?: boolean }): Promise { + const args = ['ls-tree', '-l']; + + if (options?.recursive) { + args.push('-r'); + } + + args.push(treeish); + + if (path) { + args.push('--', this.sanitizeRelativePath(path)); + } + + const { stdout } = await this.exec(args); + return parseLsTree(stdout); + } + + async lsfiles(path: string): Promise { + const args = ['ls-files', '--stage']; + const relativePath = this.sanitizeRelativePath(path); + + if (relativePath) { + args.push('--', relativePath); + } + + const { stdout } = await this.exec(args); + return parseLsFiles(stdout); + } + + async getGitFilePath(ref: string, filePath: string): Promise { + const elements: { file: string }[] = ref + ? await this.lstree(ref, undefined, { recursive: true }) + : await this.lsfiles(this.repositoryRoot); + + const relativePathLowercase = this.sanitizeRelativePath(filePath).toLowerCase(); + const element = elements.find(file => file.file.toLowerCase() === relativePathLowercase); + + if (!element) { + throw new GitError({ + message: `Git relative path not found. Was looking for ${relativePathLowercase} among ${JSON.stringify(elements.map(({ file }) => file), null, 2)}`, + }); + } + + return path.join(this.repositoryRoot, element.file); + } + + async detectObjectType(object: string): Promise<{ mimetype: string; encoding?: string }> { + const child = await this.stream(['show', '--textconv', object]); + const buffer = await readBytes(child.stdout!, 4100); + + try { + child.kill(); + } catch (err) { + // noop + } + + const encoding = detectUnicodeEncoding(buffer); + let isText = true; + + if (encoding !== Encoding.UTF16be && encoding !== Encoding.UTF16le) { + for (let i = 0; i < buffer.length; i++) { + if (buffer.readInt8(i) === 0) { + isText = false; + break; + } + } + } + + if (!isText) { + const result = await fileTypeFromBuffer(buffer); + + if (!result) { + return { mimetype: 'application/octet-stream' }; + } else { + return { mimetype: result.mime }; + } + } + + if (encoding) { + return { mimetype: 'text/plain', encoding }; + } else { + // TODO@JOAO: read the setting OUTSIDE! + return { mimetype: 'text/plain' }; + } + } + + async apply(patch: string, options?: { reverse?: boolean; threeWay?: boolean; allowEmpty?: boolean }): Promise { + const args = ['apply', patch]; + + if (options?.allowEmpty) { + args.push('--allow-empty'); + } + + if (options?.reverse) { + args.push('--reverse'); + } + + if (options?.threeWay) { + args.push('--3way'); + } + + try { + await this.exec(args); + } catch (err) { + if (/patch does not apply/.test(err.stderr)) { + err.gitErrorCode = GitErrorCodes.PatchDoesNotApply; + } + + throw err; + } + } + + async diff(cached = false): Promise { + const args = ['diff']; + + if (cached) { + args.push('--cached'); + } + + const result = await this.exec(args); + return result.stdout; + } + + diffWithHEAD(): Promise; + diffWithHEAD(path: string): Promise; + diffWithHEAD(path?: string | undefined): Promise; + async diffWithHEAD(path?: string | undefined): Promise { + if (!path) { + return await this.diffFiles(undefined, { cached: false }); + } + + const args = ['diff', '--', this.sanitizeRelativePath(path)]; + const result = await this.exec(args); + return result.stdout; + } + + async diffWithHEADShortStats(path?: string): Promise { + return this.diffFilesShortStat(undefined, { cached: false, path }); + } + + diffWith(ref: string): Promise; + diffWith(ref: string, path: string): Promise; + diffWith(ref: string, path?: string | undefined): Promise; + async diffWith(ref: string, path?: string): Promise { + if (!path) { + return await this.diffFiles(ref, { cached: false }); + } + + const args = ['diff', ref, '--', this.sanitizeRelativePath(path)]; + const result = await this.exec(args); + return result.stdout; + } + + diffIndexWithHEAD(): Promise; + diffIndexWithHEAD(path: string): Promise; + diffIndexWithHEAD(path?: string | undefined): Promise; + async diffIndexWithHEAD(path?: string): Promise { + if (!path) { + return await this.diffFiles(undefined, { cached: true }); + } + + const args = ['diff', '--cached', '--', this.sanitizeRelativePath(path)]; + const result = await this.exec(args); + return result.stdout; + } + + async diffIndexWithHEADShortStats(path?: string): Promise { + return this.diffFilesShortStat(undefined, { cached: true, path }); + } + + diffIndexWith(ref: string): Promise; + diffIndexWith(ref: string, path: string): Promise; + diffIndexWith(ref: string, path?: string | undefined): Promise; + async diffIndexWith(ref: string, path?: string): Promise { + if (!path) { + return await this.diffFiles(ref, { cached: true }); + } + + const args = ['diff', '--cached', ref, '--', this.sanitizeRelativePath(path)]; + const result = await this.exec(args); + return result.stdout; + } + + async diffBlobs(object1: string, object2: string): Promise { + const args = ['diff', object1, object2]; + const result = await this.exec(args); + return result.stdout; + } + + diffBetween(ref1: string, ref2: string): Promise; + diffBetween(ref1: string, ref2: string, path: string): Promise; + diffBetween(ref1: string, ref2: string, path?: string | undefined): Promise; + async diffBetween(ref1: string, ref2: string, path?: string): Promise { + const range = `${ref1}...${ref2}`; + if (!path) { + return await this.diffFiles(range, { cached: false }); + } + + const args = ['diff', range, '--', this.sanitizeRelativePath(path)]; + const result = await this.exec(args); + + return result.stdout.trim(); + } + + async diffBetweenPatch(ref: string, options: { path?: string }): Promise { + const args = ['diff', ref, '--']; + + if (options.path) { + args.push(this.sanitizeRelativePath(options.path)); + } + + const result = await this.exec(args); + return result.stdout; + } + + async diffBetweenWithStats(ref: string, options: { path?: string; similarityThreshold?: number }): Promise { + const args = ['diff', '--raw', '--numstat', '--diff-filter=ADMR', '-z',]; + + if (options.similarityThreshold) { + args.push(`--find-renames=${options.similarityThreshold}%`); + } + + args.push(...[ref, '--']); + if (options.path) { + args.push(this.sanitizeRelativePath(options.path)); + } + + const gitResult = await this.exec(args); + if (gitResult.exitCode) { + return []; + } + + return parseGitChangesRaw(this.repositoryRoot, gitResult.stdout); + } + + private async diffFiles(ref: string | undefined, options: { cached: boolean; similarityThreshold?: number }): Promise { + const args = ['diff', '--name-status', '-z', '--diff-filter=ADMR']; + + if (options.cached) { + args.push('--cached'); + } + + if (options.similarityThreshold) { + args.push(`--find-renames=${options.similarityThreshold}%`); + } + + if (ref) { + args.push(ref); + } + + args.push('--'); + + const gitResult = await this.exec(args); + if (gitResult.exitCode) { + return []; + } + + return parseGitChanges(this.repositoryRoot, gitResult.stdout); + } + + private async diffFilesShortStat(ref: string | undefined, options: { cached: boolean; path?: string }): Promise { + const args = ['diff', '--shortstat']; + + if (options.cached) { + args.push('--cached'); + } + + if (ref !== undefined) { + args.push(ref); + } + + args.push('--'); + + if (options.path) { + args.push(this.sanitizeRelativePath(options.path)); + } + + const result = await this.exec(args); + if (result.exitCode) { + return { files: 0, insertions: 0, deletions: 0 }; + } + + return parseGitDiffShortStat(result.stdout.trim()); + } + + + async diffTrees(treeish1: string, treeish2?: string, options?: { similarityThreshold?: number }): Promise { + const args = ['diff-tree', '-r', '--raw', '--numstat', '--diff-filter=ADMR', '-z']; + + if (options?.similarityThreshold) { + args.push(`--find-renames=${options.similarityThreshold}%`); + } + + args.push(treeish1); + + if (treeish2) { + args.push(treeish2); + } + + args.push('--'); + + const gitResult = await this.exec(args); + if (gitResult.exitCode) { + return []; + } + + return parseGitChangesRaw(this.repositoryRoot, gitResult.stdout); + } + + async getMergeBase(ref1: string, ref2: string, ...refs: string[]): Promise { + try { + const args = ['merge-base']; + if (refs.length !== 0) { + args.push('--octopus'); + args.push(...refs); + } + + args.push(ref1, ref2); + + const result = await this.exec(args); + + return result.stdout.trim(); + } + catch (err) { + return undefined; + } + } + + async hashObject(data: string): Promise { + const args = ['hash-object', '-w', '--stdin']; + const result = await this.exec(args, { input: data }); + + return result.stdout.trim(); + } + + async add(paths: string[], opts?: { update?: boolean }): Promise { + const args = ['add']; + + if (opts && opts.update) { + args.push('-u'); + } else { + args.push('-A'); + } + + if (paths && paths.length) { + for (const chunk of splitInChunks(paths.map(p => this.sanitizeRelativePath(p)), MAX_CLI_LENGTH)) { + await this.exec([...args, '--', ...chunk]); + } + } else { + await this.exec([...args, '--', '.']); + } + } + + async rm(paths: string[]): Promise { + const args = ['rm', '--']; + + if (!paths || !paths.length) { + return; + } + + args.push(...paths.map(p => this.sanitizeRelativePath(p))); + + await this.exec(args); + } + + async stage(path: string, data: Uint8Array): Promise { + const relativePath = this.sanitizeRelativePath(path); + const child = this.stream(['hash-object', '--stdin', '-w', '--path', relativePath], { stdio: [null, null, null] }); + + if (!child.stdin) { + throw new GitError({ + message: 'Failed to spawn git process', + exitCode: -1 + }); + } + + child.stdin.end(data); + + const { exitCode, stdout } = await exec(child); + const hash = stdout.toString('utf8'); + + if (exitCode) { + throw new GitError({ + message: 'Could not hash object.', + exitCode: exitCode + }); + } + + const treeish = await this.getCommit('HEAD').then(() => 'HEAD', () => ''); + let mode: string; + let add: string = ''; + + try { + const details = await this.getObjectDetails(treeish, path); + mode = details.mode; + } catch (err) { + if (err.gitErrorCode !== GitErrorCodes.UnknownPath) { + throw err; + } + + mode = '100644'; + add = '--add'; + } + + await this.exec(['update-index', add, '--cacheinfo', mode, hash, relativePath]); + } + + async checkout(treeish: string, paths: string[], opts: { track?: boolean; detached?: boolean } = Object.create(null)): Promise { + const args = ['checkout', '-q']; + + if (opts.track) { + args.push('--track'); + } + + if (opts.detached) { + args.push('--detach'); + } + + if (treeish) { + args.push(treeish); + } + + try { + if (paths && paths.length > 0) { + for (const chunk of splitInChunks(paths.map(p => this.sanitizeRelativePath(p)), MAX_CLI_LENGTH)) { + await this.exec([...args, '--', ...chunk]); + } + } else { + await this.exec(args); + } + } catch (err) { + if (/Please,? commit your changes or stash them/.test(err.stderr || '')) { + err.gitErrorCode = GitErrorCodes.DirtyWorkTree; + err.gitTreeish = treeish; + } else if (/You are on a branch yet to be born/.test(err.stderr || '')) { + err.gitErrorCode = GitErrorCodes.BranchNotYetBorn; + } + + throw err; + } + } + + async commit(message: string | undefined, opts: CommitOptions = Object.create(null)): Promise { + const args = ['commit', '--quiet']; + const options: SpawnOptions = {}; + + if (message) { + options.input = message; + args.push('--allow-empty-message', '--file', '-'); + } + + if (opts.verbose) { + args.push('--verbose'); + } + + if (opts.all) { + args.push('--all'); + } + + if (opts.amend) { + args.push('--amend'); + } + + if (!opts.useEditor) { + if (!message) { + if (opts.amend) { + args.push('--no-edit'); + } else { + options.input = ''; + args.push('--file', '-'); + } + } + + args.push('--allow-empty-message'); + } + + if (opts.signoff) { + args.push('--signoff'); + } + + if (opts.signCommit !== undefined) { + if (opts.signCommit) { + args.push('-S'); + } else { + args.push('--no-gpg-sign'); + } + } + + if (opts.empty) { + args.push('--allow-empty'); + } + + if (opts.noVerify) { + args.push('--no-verify'); + } + + if (opts.requireUserConfig ?? true) { + // Stops git from guessing at user/email + args.splice(0, 0, '-c', 'user.useConfigOnly=true'); + } + + try { + await this.exec(args, options); + } catch (commitErr) { + await this.handleCommitError(commitErr); + } + } + + async rebaseAbort(): Promise { + await this.exec(['rebase', '--abort']); + } + + async rebaseContinue(): Promise { + const args = ['rebase', '--continue']; + + try { + await this.exec(args, { env: { GIT_EDITOR: 'true' } }); + } catch (commitErr) { + await this.handleCommitError(commitErr); + } + } + + + private async handleCommitError(commitErr: unknown): Promise { + if (commitErr instanceof GitError && /not possible because you have unmerged files/.test(commitErr.stderr || '')) { + commitErr.gitErrorCode = GitErrorCodes.UnmergedChanges; + throw commitErr; + } else if (commitErr instanceof GitError && /Aborting commit due to empty commit message/.test(commitErr.stderr || '')) { + commitErr.gitErrorCode = GitErrorCodes.EmptyCommitMessage; + throw commitErr; + } + + try { + await this.exec(['config', '--get-all', 'user.name']); + } catch (err) { + err.gitErrorCode = GitErrorCodes.NoUserNameConfigured; + throw err; + } + + try { + await this.exec(['config', '--get-all', 'user.email']); + } catch (err) { + err.gitErrorCode = GitErrorCodes.NoUserEmailConfigured; + throw err; + } + + throw commitErr; + } + + async branch(name: string, checkout: boolean, ref?: string): Promise { + const args = checkout ? ['checkout', '-q', '-b', name, '--no-track'] : ['branch', '-q', name]; + + if (ref) { + args.push(ref); + } + + await this.exec(args); + } + + async deleteBranch(name: string, force?: boolean): Promise { + const args = ['branch', force ? '-D' : '-d', name]; + await this.exec(args); + } + + async renameBranch(name: string): Promise { + const args = ['branch', '-m', name]; + await this.exec(args); + } + + async move(from: string, to: string): Promise { + const args = ['mv', from, to]; + await this.exec(args); + } + + async setBranchUpstream(name: string, upstream: string): Promise { + const args = ['branch', '--set-upstream-to', upstream, name]; + await this.exec(args); + } + + async deleteRef(ref: string): Promise { + const args = ['update-ref', '-d', ref]; + await this.exec(args); + } + + async updateRef(ref: string, newSha: string, oldSha: string): Promise { + const branch = ref.slice('refs/heads/'.length); + if (!ref.startsWith('refs/heads/') || !branch || branch.startsWith('-')) { + throw new Error('Expected a local branch ref'); + } + + assertValidObjectId(newSha); + assertValidObjectId(oldSha, true); + await this.exec(['check-ref-format', '--branch', branch]); + await this.exec(['update-ref', '--no-deref', ref, newSha, oldSha]); + } + + async merge(ref: string): Promise { + const args = ['merge', ref]; + + try { + await this.exec(args); + } catch (err) { + if (/^CONFLICT /m.test(err.stdout || '')) { + err.gitErrorCode = GitErrorCodes.Conflict; + } + + throw err; + } + } + + async mergeAbort(): Promise { + await this.exec(['merge', '--abort']); + } + + async tag(options: { name: string; message?: string; ref?: string }): Promise { + let args = ['tag']; + + if (options.message) { + args = [...args, '-a', options.name, '-m', options.message]; + } else { + args = [...args, options.name]; + } + + if (options.ref) { + args.push(options.ref); + } + + await this.exec(args); + } + + async deleteTag(name: string): Promise { + const args = ['tag', '-d', name]; + await this.exec(args); + } + + async addWorktree(options: { path: string; commitish: string; branch?: string; noTrack?: boolean }): Promise { + const args = ['worktree', 'add']; + + if (options.branch) { + args.push('-b', options.branch); + } + + if (options.noTrack) { + args.push('--no-track'); + } + + args.push(options.path, options.commitish); + + await this.exec(args); + } + + async deleteWorktree(path: string, options?: { force?: boolean }): Promise { + const args = ['worktree', 'remove']; + + if (options?.force) { + args.push('--force'); + } + + args.push(path); + await this.exec(args); + } + + async deleteRemoteRef(remoteName: string, refName: string, options?: { force?: boolean }): Promise { + const args = ['push', remoteName, '--delete']; + + if (options?.force) { + args.push('--force'); + } + + args.push(refName); + await this.exec(args); + } + + async clean(paths: string[]): Promise { + const pathsByGroup = groupBy(paths.map(sanitizePath), p => path.dirname(p)); + const groups = Object.keys(pathsByGroup).map(k => pathsByGroup[k]); + + const limiter = new Limiter>(5); + const promises: Promise>[] = []; + const args = ['clean', '-f', '-q']; + + for (const paths of groups) { + for (const chunk of splitInChunks(paths.map(p => this.sanitizeRelativePath(p)), MAX_CLI_LENGTH)) { + promises.push(limiter.queue(() => this.exec([...args, '--', ...chunk]))); + } + } + + await Promise.all(promises); + } + + async undo(): Promise { + await this.exec(['clean', '-fd']); + + try { + await this.exec(['checkout', '--', '.']); + } catch (err) { + if (/did not match any file\(s\) known to git\./.test(err.stderr || '')) { + return; + } + + throw err; + } + } + + async reset(treeish: string, hard: boolean = false): Promise { + const args = ['reset', hard ? '--hard' : '--soft', treeish]; + await this.exec(args); + } + + async resetKeep(ref: string): Promise { + if (!ref || ref.startsWith('-')) { + throw new Error('Expected a commit reference'); + } + + await this.exec(['reset', '--keep', ref]); + } + + async revert(treeish: string, paths: string[]): Promise { + const result = await this.exec(['branch']); + let args: string[]; + + // In case there are no branches, we must use rm --cached + if (!result.stdout) { + args = ['rm', '--cached', '-r']; + } else { + args = ['reset', '-q', treeish]; + } + + try { + if (paths && paths.length > 0) { + for (const chunk of splitInChunks(paths.map(p => this.sanitizeRelativePath(p)), MAX_CLI_LENGTH)) { + await this.exec([...args, '--', ...chunk]); + } + } else { + await this.exec([...args, '--', '.']); + } + } catch (err) { + // In case there are merge conflicts to be resolved, git reset will output + // some "needs merge" data. We try to get around that. + if (/([^:]+: needs merge\n)+/m.test(err.stdout || '')) { + return; + } + + throw err; + } + } + + async restore(paths: string[], options?: { staged?: boolean; ref?: string }): Promise { + const args = ['restore']; + + if (options?.staged) { + args.push('--staged'); + } + + if (options?.ref) { + args.push('--source', options.ref); + } + + if (paths.length > 0) { + for (const chunk of splitInChunks(paths.map(p => this.sanitizeRelativePath(p)), MAX_CLI_LENGTH)) { + await this.exec([...args, '--', ...chunk]); + } + } else { + await this.exec([...args, '--', '.']); + } + } + + async addRemote(name: string, url: string): Promise { + const args = ['remote', 'add', name, url]; + await this.exec(args); + } + + async removeRemote(name: string): Promise { + const args = ['remote', 'remove', name]; + await this.exec(args); + } + + async renameRemote(name: string, newName: string): Promise { + const args = ['remote', 'rename', name, newName]; + await this.exec(args); + } + + async fetch(options: { remote?: string; ref?: string; all?: boolean; prune?: boolean; depth?: number; silent?: boolean; readonly cancellationToken?: CancellationToken } = {}): Promise { + const args = ['fetch']; + const spawnOptions: SpawnOptions = { + cancellationToken: options.cancellationToken, + env: { 'GIT_HTTP_USER_AGENT': this.git.userAgent } + }; + + if (options.remote) { + args.push(options.remote); + + if (options.ref) { + args.push(options.ref); + } + } else if (options.all) { + args.push('--all'); + } + + if (options.prune) { + args.push('--prune'); + } + + if (typeof options.depth === 'number') { + args.push(`--depth=${options.depth}`); + } + + if (options.silent) { + spawnOptions.env!['VSCODE_GIT_FETCH_SILENT'] = 'true'; + } + + try { + await this.exec(args, spawnOptions); + } catch (err) { + if (/No remote repository specified\./.test(err.stderr || '')) { + err.gitErrorCode = GitErrorCodes.NoRemoteRepositorySpecified; + } else if (/Could not read from remote repository/.test(err.stderr || '')) { + err.gitErrorCode = GitErrorCodes.RemoteConnectionError; + } else if (/! \[rejected\].*\(non-fast-forward\)/m.test(err.stderr || '')) { + // The local branch has outgoing changes and it cannot be fast-forwarded. + err.gitErrorCode = GitErrorCodes.BranchFastForwardRejected; + } + + throw err; + } + } + + async fetchTags(options: { remote: string; tags: string[]; force?: boolean }): Promise { + const args = ['fetch']; + const spawnOptions: SpawnOptions = { + env: { 'GIT_HTTP_USER_AGENT': this.git.userAgent } + }; + + args.push(options.remote); + + for (const tag of options.tags) { + args.push(`refs/tags/${tag}:refs/tags/${tag}`); + } + + if (options.force) { + args.push('--force'); + } + + await this.exec(args, spawnOptions); + } + + async pull(rebase?: boolean, remote?: string, branch?: string, options: PullOptions = {}): Promise { + const args = ['pull']; + + if (options.tags) { + args.push('--tags'); + } + + if (options.unshallow) { + args.push('--unshallow'); + } + + // --auto-stash option is only available `git pull --merge` starting with git 2.27.0 + if (options.autoStash && this._git.compareGitVersionTo('2.27.0') !== -1) { + args.push('--autostash'); + } + + if (rebase) { + args.push('-r'); + } + + if (remote && branch) { + args.push(remote); + args.push(branch); + } + + try { + const result = await this.exec(args, { + cancellationToken: options.cancellationToken, + env: { 'GIT_HTTP_USER_AGENT': this.git.userAgent } + }); + return !/Already up to date/i.test(result.stdout); + } catch (err) { + if (/^CONFLICT \([^)]+\): \b/m.test(err.stdout || '')) { + err.gitErrorCode = GitErrorCodes.Conflict; + } else if (/Please tell me who you are\./.test(err.stderr || '')) { + err.gitErrorCode = GitErrorCodes.NoUserNameConfigured; + } else if (/Could not read from remote repository/.test(err.stderr || '')) { + err.gitErrorCode = GitErrorCodes.RemoteConnectionError; + } else if (/Pull(?:ing)? is not possible because you have unmerged files|Cannot pull with rebase: You have unstaged changes|Your local changes to the following files would be overwritten|Please, commit your changes before you can merge/i.test(err.stderr)) { + err.stderr = err.stderr.replace(/Cannot pull with rebase: You have unstaged changes/i, 'Cannot pull with rebase, you have unstaged changes'); + err.gitErrorCode = GitErrorCodes.DirtyWorkTree; + } else if (/cannot lock ref|unable to update local ref/i.test(err.stderr || '')) { + err.gitErrorCode = GitErrorCodes.CantLockRef; + } else if (/cannot rebase onto multiple branches/i.test(err.stderr || '')) { + err.gitErrorCode = GitErrorCodes.CantRebaseMultipleBranches; + } else if (/! \[rejected\].*\(would clobber existing tag\)/m.test(err.stderr || '')) { + err.gitErrorCode = GitErrorCodes.TagConflict; + } + + throw err; + } + } + + async rebase(branch: string, options: { onto?: string; rebaseMerges?: boolean } = {}): Promise { + const args = ['rebase']; + + if (options.rebaseMerges) { + args.push('--rebase-merges'); + } + + if (options.onto !== undefined) { + args.push('--onto', options.onto); + } + + args.push(branch); + + try { + await this.exec(args); + } catch (err) { + if (/^CONFLICT \([^)]+\): \b/m.test(err.stdout || '')) { + err.gitErrorCode = GitErrorCodes.Conflict; + } else if (/cannot rebase onto multiple branches/i.test(err.stderr || '')) { + err.gitErrorCode = GitErrorCodes.CantRebaseMultipleBranches; + } + + throw err; + } + } + + async push(remote?: string, name?: string, setUpstream: boolean = false, followTags = false, forcePushMode?: ForcePushMode, tags = false, lease?: { branch: string; expectedSha: string }): Promise { + const args = ['push']; + + if (forcePushMode === ForcePushMode.ForceWithLease || forcePushMode === ForcePushMode.ForceWithLeaseIfIncludes) { + args.push(lease ? `--force-with-lease=refs/heads/${lease.branch}:${lease.expectedSha}` : '--force-with-lease'); + if (forcePushMode === ForcePushMode.ForceWithLeaseIfIncludes && this._git.compareGitVersionTo('2.30') !== -1) { + args.push('--force-if-includes'); + } + } else if (forcePushMode === ForcePushMode.Force) { + args.push('--force'); + } + + if (setUpstream) { + args.push('-u'); + } + + if (followTags) { + args.push('--follow-tags'); + } else if (lease) { + args.push('--no-follow-tags'); + } + + if (tags) { + args.push('--tags'); + } + + if (remote) { + args.push(remote); + } + + if (name) { + args.push(name); + } + + try { + await this.exec(args, { env: { 'GIT_HTTP_USER_AGENT': this.git.userAgent } }); + } catch (err) { + if (/^error: failed to push some refs to\b/m.test(err.stderr || '')) { + if (forcePushMode === ForcePushMode.ForceWithLease && /! \[rejected\].*\(stale info\)/m.test(err.stderr || '')) { + err.gitErrorCode = GitErrorCodes.ForcePushWithLeaseRejected; + } else if (forcePushMode === ForcePushMode.ForceWithLeaseIfIncludes && /! \[rejected\].*\(remote ref updated since checkout\)/m.test(err.stderr || '')) { + err.gitErrorCode = GitErrorCodes.ForcePushWithLeaseIfIncludesRejected; + } else { + err.gitErrorCode = GitErrorCodes.PushRejected; + } + } else if (/Permission.*denied/.test(err.stderr || '')) { + err.gitErrorCode = GitErrorCodes.PermissionDenied; + } else if (/Could not read from remote repository/.test(err.stderr || '')) { + err.gitErrorCode = GitErrorCodes.RemoteConnectionError; + } else if (/^fatal: The current branch .* has no upstream branch/.test(err.stderr || '')) { + err.gitErrorCode = GitErrorCodes.NoUpstreamBranch; + } + + throw err; + } + } + + async pushRefWithLease(remote: string, branch: string, newSha: string, expectedSha: string): Promise { + if (!remote || remote.startsWith('-') || !branch || branch.startsWith('-')) { + throw new Error('Expected a remote and a branch name'); + } + + assertValidObjectId(newSha); + assertValidObjectId(expectedSha, true); + await this.exec(['check-ref-format', '--branch', branch]); + await this.push(remote, `${newSha}:refs/heads/${branch}`, false, false, ForcePushMode.ForceWithLease, false, { branch, expectedSha }); + } + + async cherryPick(commitHash: string): Promise { + try { + await this.exec(['cherry-pick', commitHash]); + } catch (err) { + if (/The previous cherry-pick is now empty, possibly due to conflict resolution./.test(err.stderr ?? '')) { + // Abort cherry-pick + await this.cherryPickAbort(); + + err.gitErrorCode = GitErrorCodes.CherryPickEmpty; + } else { + // Conflict during cherry-pick + err.gitErrorCode = GitErrorCodes.CherryPickConflict; + } + + throw err; + } + } + + async cherryPickAbort(): Promise { + await this.exec(['cherry-pick', '--abort']); + } + + async blame(path: string): Promise { + try { + const args = ['blame', '--', this.sanitizeRelativePath(path)]; + const result = await this.exec(args); + return result.stdout.trim(); + } catch (err) { + if (/^fatal: no such path/.test(err.stderr || '')) { + err.gitErrorCode = GitErrorCodes.NoPathFound; + } + + throw err; + } + } + + async blame2(path: string, ref?: string, ignoreWhitespace?: boolean): Promise { + try { + const args = ['-c', 'i18n.logOutputEncoding=UTF-8', 'blame', '--root', '--incremental']; + + if (ignoreWhitespace) { + args.push('-w'); + } + + if (ref) { + args.push(ref); + } + + args.push('--', this.sanitizeRelativePath(path)); + + const result = await this.exec(args); + + return parseGitBlame(result.stdout.trim()); + } + catch (err) { + return undefined; + } + } + + async createStash(message?: string, includeUntracked?: boolean, staged?: boolean): Promise { + try { + const args = ['stash', 'push']; + + if (includeUntracked) { + args.push('-u'); + } + + if (staged) { + args.push('-S'); + } + + if (message) { + args.push('-m', message); + } + + await this.exec(args); + } catch (err) { + if (/No local changes to save/.test(err.stderr || '')) { + err.gitErrorCode = GitErrorCodes.NoLocalChanges; + } + + throw err; + } + } + + async popStash(index?: number, options?: { reinstateStagedChanges?: boolean }): Promise { + const args = ['stash', 'pop']; + if (options?.reinstateStagedChanges) { + args.push('--index'); + } + await this.popOrApplyStash(args, index); + } + + async applyStash(index?: number, options?: { reinstateStagedChanges?: boolean }): Promise { + const args = ['stash', 'apply']; + if (options?.reinstateStagedChanges) { + args.push('--index'); + } + await this.popOrApplyStash(args, index); + } + + private async popOrApplyStash(args: string[], index?: number): Promise { + try { + if (typeof index === 'number') { + args.push(`stash@{${index}}`); + } + + await this.exec(args); + } catch (err) { + if (/No stash found/.test(err.stderr || '')) { + err.gitErrorCode = GitErrorCodes.NoStashFound; + } else if (/error: Your local changes to the following files would be overwritten/.test(err.stderr || '')) { + err.gitErrorCode = GitErrorCodes.LocalChangesOverwritten; + } else if (/^CONFLICT/m.test(err.stdout || '')) { + err.gitErrorCode = GitErrorCodes.StashConflict; + } + + throw err; + } + } + + async dropStash(index?: number): Promise { + const args = ['stash']; + + if (typeof index === 'number') { + args.push('drop'); + args.push(`stash@{${index}}`); + } else { + args.push('clear'); + } + + try { + await this.exec(args); + } catch (err) { + if (/No stash found/.test(err.stderr || '')) { + err.gitErrorCode = GitErrorCodes.NoStashFound; + } + + throw err; + } + } + + async showStash(index: number): Promise { + const args = ['stash', 'show', `stash@{${index}}`, '--name-status', '-z', '-u']; + + try { + const result = await this.exec(args); + if (result.exitCode) { + return []; + } + + return parseGitChanges(this.repositoryRoot, result.stdout.trim()); + } catch (err) { + if (/No stash found/.test(err.stderr || '')) { + return undefined; + } + + throw err; + } + } + + async getStatus(opts?: { limit?: number; ignoreSubmodules?: boolean; similarityThreshold?: number; untrackedChanges?: 'mixed' | 'separate' | 'hidden'; cancellationToken?: CancellationToken }): Promise<{ status: IFileStatus[]; statusLength: number; didHitLimit: boolean }> { + if (opts?.cancellationToken && opts?.cancellationToken.isCancellationRequested) { + throw new CancellationError(); + } + + const disposables: IDisposable[] = []; + + const env = { GIT_OPTIONAL_LOCKS: '0' }; + const args = ['status', '-z']; + + if (opts?.untrackedChanges === 'hidden') { + args.push('-uno'); + } else { + args.push('-uall'); + } + + if (opts?.ignoreSubmodules) { + args.push('--ignore-submodules'); + } + + // --find-renames option is only available starting with git 2.18.0 + if (opts?.similarityThreshold && opts.similarityThreshold !== 50 && this._git.compareGitVersionTo('2.18.0') !== -1) { + args.push(`--find-renames=${opts.similarityThreshold}%`); + } + + const child = this.stream(args, { env }); + + let result = new Promise<{ status: IFileStatus[]; statusLength: number; didHitLimit: boolean }>((c, e) => { + const parser = new GitStatusParser(); + + const onClose = (exitCode: number) => { + if (exitCode !== 0) { + const stderr = stderrData.join(''); + return e(new GitError({ + message: 'Failed to execute git', + stderr, + exitCode, + gitErrorCode: getGitErrorCode(stderr), + gitCommand: 'status', + gitArgs: args + })); + } + + c({ status: parser.status, statusLength: parser.status.length, didHitLimit: false }); + }; + + const limit = opts?.limit ?? 10000; + const onStdoutData = (raw: string) => { + parser.update(raw); + + if (limit !== 0 && parser.status.length > limit) { + child.removeListener('close', onClose); + child.stdout?.removeListener('data', onStdoutData); + child.kill(); + + c({ status: parser.status.slice(0, limit), statusLength: parser.status.length, didHitLimit: true }); + } + }; + + if (child.stdout) { + child.stdout.setEncoding('utf8'); + child.stdout.on('data', onStdoutData); + } + + const stderrData: string[] = []; + if (child.stderr) { + child.stderr.setEncoding('utf8'); + child.stderr.on('data', raw => stderrData.push(raw as string)); + } + + child.on('error', cpErrorHandler(e)); + child.on('close', onClose); + }); + + if (opts?.cancellationToken) { + const cancellationPromise = new Promise<{ status: IFileStatus[]; statusLength: number; didHitLimit: boolean }>((_, e) => { + disposables.push(onceEvent(opts.cancellationToken!.onCancellationRequested)(() => { + try { + child.kill(); + } catch (err) { + // noop + } + + e(new CancellationError()); + })); + }); + + result = Promise.race([result, cancellationPromise]); + } + + try { + const { status, statusLength, didHitLimit } = await result; + return { status, statusLength, didHitLimit }; + } + finally { + dispose(disposables); + } + } + + async getHEADRef(): Promise { + let HEAD: Branch | undefined; + + try { + HEAD = await this.getHEAD(); + + if (HEAD.name) { + // Branch + HEAD = await this.getBranch(HEAD.name); + + // Upstream commit + if (HEAD && HEAD.upstream) { + const ref = HEAD.upstream.remote !== '.' + ? `refs/remotes/${HEAD.upstream.remote}/${HEAD.upstream.name}` + : `refs/heads/${HEAD.upstream.name}`; + const commit = await this.revParse(ref); + HEAD = { ...HEAD, upstream: { ...HEAD.upstream, commit } }; + } + } else if (HEAD.commit) { + // Tag || Commit + const tags = await this.getRefs({ pattern: 'refs/tags' }); + const tag = tags.find(tag => tag.commit === HEAD!.commit); + + if (tag) { + HEAD = { ...HEAD, name: tag.name, type: RefType.Tag }; + } + } + } catch (err) { + // noop + } + + return HEAD; + } + + async getHEAD(): Promise { + if (!this._isUsingRefTable) { + try { + // Attempt to parse the HEAD file + const result = await this.getHEADFS(); + + // Git 2.45 adds support for a new reference storage backend called "reftable", promising + // faster lookups, reads, and writes for repositories with any number of references. For + // backwards compatibility the `.git/HEAD` file contains `ref: refs/heads/.invalid`. More + // details are available at https://git-scm.com/docs/reftable + if (result.name === '.invalid') { + this._isUsingRefTable = true; + this.logger.warn(`[Git][getHEAD] Failed to parse HEAD file: Repository is using reftable format.`); + } else { + return result; + } + } + catch (err) { + this.logger.warn(`[Git][getHEAD] Failed to parse HEAD file: ${err.message}`); + } + } + + try { + // Fallback to using git to determine HEAD + const result = await this.exec(['symbolic-ref', '--short', 'HEAD']); + + if (!result.stdout) { + throw new Error('Not in a branch'); + } + + return { name: result.stdout.trim(), commit: undefined, type: RefType.Head }; + } + catch (err) { } + + // Detached HEAD + const result = await this.exec(['rev-parse', 'HEAD']); + + if (!result.stdout) { + throw new Error('Error parsing HEAD'); + } + + return { name: undefined, commit: result.stdout.trim(), type: RefType.Head }; + } + + async getHEADFS(): Promise { + const raw = await fs.readFile(path.join(this.dotGit.path, 'HEAD'), 'utf8'); + + // Branch + const branchMatch = raw.match(/^ref: refs\/heads\/(?.*)$/m); + if (branchMatch?.groups?.name) { + return { name: branchMatch.groups.name, commit: undefined, type: RefType.Head }; + } + + // Detached + const commitMatch = raw.match(/^(?[0-9a-f]{40})$/m); + if (commitMatch?.groups?.commit) { + return { name: undefined, commit: commitMatch.groups.commit, type: RefType.Head }; + } + + throw new Error(`Unable to parse HEAD file. HEAD file contents: ${raw}.`); + } + + async findTrackingBranches(upstreamBranch: string): Promise { + const result = await this.exec(['for-each-ref', '--format', '%(refname:short)%00%(upstream:short)', 'refs/heads']); + return result.stdout.trim().split('\n') + .map(line => line.trim().split('\0')) + .filter(([_, upstream]) => upstream === upstreamBranch) + .map(([ref]): Branch => ({ name: ref, type: RefType.Head })); + } + + async getRefs(query: RefQuery, cancellationToken?: CancellationToken): Promise<(Ref | Branch)[]> { + if (cancellationToken && cancellationToken.isCancellationRequested) { + throw new CancellationError(); + } + + const args = ['for-each-ref']; + + if (query.count) { + args.push(`--count=${query.count}`); + } + + if (query.sort && query.sort !== 'alphabetically') { + args.push('--sort', `-${query.sort}`); + } + + if (query.includeCommitDetails) { + const format = this._git.compareGitVersionTo('1.9.0') !== -1 + ? `${REFS_WITH_DETAILS_FORMAT}%00%(upstream:track)` + : REFS_WITH_DETAILS_FORMAT; + args.push('--format', format); + } else { + args.push('--format', REFS_FORMAT); + } + + if (query.pattern) { + const patterns = Array.isArray(query.pattern) ? query.pattern : [query.pattern]; + for (const pattern of patterns) { + args.push(pattern.startsWith('refs/') ? pattern : `refs/${pattern}`); + } + } + + if (query.contains) { + args.push('--contains', query.contains); + } + + const result = await this.exec(args, { cancellationToken }); + return parseRefs(result.stdout); + } + + async getRemoteRefs(remote: string, opts?: { heads?: boolean; tags?: boolean; cancellationToken?: CancellationToken }): Promise { + if (opts?.cancellationToken && opts?.cancellationToken.isCancellationRequested) { + throw new CancellationError(); + } + + const args = ['ls-remote']; + + if (opts?.heads) { + args.push('--heads'); + } + + if (opts?.tags) { + args.push('--tags'); + } + + args.push(remote); + + const result = await this.exec(args, { cancellationToken: opts?.cancellationToken }); + + const fn = (line: string): Ref | null => { + let match: RegExpExecArray | null; + + if (match = /^((?:[0-9a-f]{40}|[0-9a-f]{64}))\trefs\/heads\/([^ ]+)$/.exec(line)) { + return { name: match[2], commit: match[1], type: RefType.Head }; + } else if (match = /^((?:[0-9a-f]{40}|[0-9a-f]{64}))\trefs\/tags\/([^ ]+)$/.exec(line)) { + return { name: match[2], commit: match[1], type: RefType.Tag }; + } + + return null; + }; + + return result.stdout.split('\n') + .filter(line => !!line) + .map(fn) + .filter(ref => !!ref) as Ref[]; + } + + async getStashes(): Promise { + const result = await this.exec(['stash', 'list', `--format=${STASH_FORMAT}`, '-z']); + return parseGitStashes(result.stdout.trim()); + } + + async getWorktrees(): Promise { + return await this.getWorktreesFS(); + } + + private async getWorktreesFS(): Promise { + const result: Worktree[] = []; + const mainRepositoryPath = this.dotGit.commonPath ?? this.dotGit.path; + + try { + if (!this.dotGit.isBare) { + // Add main worktree for a non-bare repository + const headPath = path.join(mainRepositoryPath, 'HEAD'); + const headContent = (await fs.readFile(headPath, 'utf8')).trim(); + + const mainRepositoryWorktreeName = path.basename(path.dirname(mainRepositoryPath)); + + result.push({ + name: mainRepositoryWorktreeName, + path: path.dirname(mainRepositoryPath), + ref: headContent.replace(/^ref: /, ''), + detached: !headContent.startsWith('ref: '), + main: true + } satisfies Worktree); + } + + // List all worktree folder names + const worktreesPath = path.join(mainRepositoryPath, 'worktrees'); + const dirents = await fs.readdir(worktreesPath, { withFileTypes: true }); + + for (const dirent of dirents) { + if (!dirent.isDirectory()) { + continue; + } + + try { + const headPath = path.join(worktreesPath, dirent.name, 'HEAD'); + const headContent = (await fs.readFile(headPath, 'utf8')).trim(); + + const gitdirPath = path.join(worktreesPath, dirent.name, 'gitdir'); + const gitdirContent = (await fs.readFile(gitdirPath, 'utf8')).trim(); + + result.push({ + name: dirent.name, + // Remove '/.git' suffix + path: gitdirContent.replace(/\/.git.*$/, ''), + // Remove 'ref: ' prefix + ref: headContent.replace(/^ref: /, ''), + // Detached if HEAD does not start with 'ref: ' + detached: !headContent.startsWith('ref: '), + main: false + }); + } catch (err) { + if (/ENOENT/.test(err.message)) { + continue; + } + + throw err; + } + } + + return result; + } + catch (err) { + if (/ENOENT/.test(err.message) || /ENOTDIR/.test(err.message)) { + return result; + } + + throw err; + } + } + + async getRemotes(): Promise { + const remotes: MutableRemote[] = []; + + try { + // Attempt to parse the config file + remotes.push(...await this.getRemotesFS()); + + if (remotes.length === 0) { + this.logger.info('[Git][getRemotes] No remotes found in the git config file'); + } + } + catch (err) { + this.logger.warn(`[Git][getRemotes] Error: ${err.message}`); + + // Fallback to using git to get the remotes + remotes.push(...await this.getRemotesGit()); + } + + for (const remote of remotes) { + // https://github.com/microsoft/vscode/issues/45271 + remote.isReadOnly = remote.pushUrl === undefined || remote.pushUrl === 'no_push'; + } + + return remotes; + } + + private async getRemotesFS(): Promise { + const raw = await fs.readFile(path.join(this.dotGit.commonPath ?? this.dotGit.path, 'config'), 'utf8'); + return parseGitRemotes(raw); + } + + private async getRemotesGit(): Promise { + const remotes: MutableRemote[] = []; + + const result = await this.exec(['remote', '--verbose']); + const lines = result.stdout.trim().split('\n').filter(l => !!l); + + for (const line of lines) { + const parts = line.split(/\s/); + const [name, url, type] = parts; + + let remote = remotes.find(r => r.name === name); + + if (!remote) { + remote = { name, isReadOnly: false }; + remotes.push(remote); + } + + if (/fetch/i.test(type)) { + remote.fetchUrl = url; + } else if (/push/i.test(type)) { + remote.pushUrl = url; + } else { + remote.fetchUrl = url; + remote.pushUrl = url; + } + } + + return remotes; + } + + async getBranch(name: string): Promise { + if (name === 'HEAD') { + return this.getHEAD(); + } + + const args = ['for-each-ref']; + + let supportsAheadBehind = true; + if (this._git.compareGitVersionTo('1.9.0') === -1) { + args.push('--format=%(refname)%00%(upstream:short)%00%(objectname)'); + supportsAheadBehind = false; + } else if (this._git.compareGitVersionTo('2.16.0') === -1) { + args.push('--format=%(refname)%00%(upstream:short)%00%(objectname)%00%(upstream:track)'); + } else { + args.push('--format=%(refname)%00%(upstream:short)%00%(objectname)%00%(upstream:track)%00%(upstream:remotename)%00%(upstream:remoteref)'); + } + + // On Windows and macOS ref names are case insensitive so we add --ignore-case + // to handle the scenario where the user switched to a branch with incorrect + // casing + if (this.git.compareGitVersionTo('2.12') !== -1 && (isWindows || isMacintosh)) { + args.push('--ignore-case'); + } + + if (/^refs\/(heads|remotes)\//i.test(name)) { + args.push(name); + } else { + args.push(`refs/heads/${name}`, `refs/remotes/${name}`); + } + + const result = await this.exec(args); + const branches: Branch[] = result.stdout.trim().split('\n').map(line => { + let [branchName, upstream, ref, status, remoteName, upstreamRef] = line.trim().split('\0'); + + if (branchName.startsWith('refs/heads/')) { + branchName = branchName.substring(11); + const index = upstream.indexOf('/'); + + let ahead; + let behind; + const match = /\[(?:ahead ([0-9]+))?[,\s]*(?:behind ([0-9]+))?]|\[gone]/.exec(status); + if (match) { + [, ahead, behind] = match; + } + + return { + type: RefType.Head, + name: branchName, + upstream: upstream !== '' && status !== '[gone]' ? { + name: upstreamRef ? upstreamRef.substring(11) : upstream.substring(index + 1), + remote: remoteName ? remoteName : upstream.substring(0, index) + } : undefined, + commit: ref || undefined, + ahead: Number(ahead) || 0, + behind: Number(behind) || 0, + }; + } else if (branchName.startsWith('refs/remotes/')) { + branchName = branchName.substring(13); + const index = branchName.indexOf('/'); + + return { + type: RefType.RemoteHead, + name: branchName.substring(index + 1), + remote: branchName.substring(0, index), + commit: ref, + }; + } else { + return undefined; + } + }).filter((b?: Branch): b is Branch => !!b); + + if (branches.length) { + const [branch] = branches; + + if (!supportsAheadBehind && branch.upstream) { + try { + const result = await this.exec(['rev-list', '--left-right', '--count', `${branch.name}...${branch.upstream.remote}/${branch.upstream.name}`]); + const [ahead, behind] = result.stdout.trim().split('\t'); + + (branch as Mutable).ahead = Number(ahead) || 0; + (branch as Mutable).behind = Number(behind) || 0; + } catch { } + } + + return branch; + } + + this.logger.warn(`[Git][getBranch] No such branch: ${name}`); + return Promise.reject(new Error(`No such branch: ${name}.`)); + } + + async getDefaultBranch(remoteName: string): Promise { + const result = await this.exec(['symbolic-ref', '--short', `refs/remotes/${remoteName}/HEAD`]); + if (!result.stdout || result.stderr) { + throw new Error('No default branch'); + } + + return this.getBranch(result.stdout.trim()); + } + + // TODO: Support core.commentChar + stripCommitMessageComments(message: string): string { + return message.replace(/^\s*#.*$\n?/gm, '').trim(); + } + + async getSquashMessage(): Promise { + const squashMsgPath = path.join(this.repositoryRoot, '.git', 'SQUASH_MSG'); + + try { + const raw = await fs.readFile(squashMsgPath, 'utf8'); + return this.stripCommitMessageComments(raw); + } catch { + return undefined; + } + } + + async getMergeMessage(): Promise { + const mergeMsgPath = path.join(this.repositoryRoot, '.git', 'MERGE_MSG'); + + try { + const raw = await fs.readFile(mergeMsgPath, 'utf8'); + return this.stripCommitMessageComments(raw); + } catch { + return undefined; + } + } + + async getCommitTemplate(): Promise { + try { + const result = await this.exec(['config', '--get', 'commit.template']); + + if (!result.stdout) { + return ''; + } + + // https://github.com/git/git/blob/3a0f269e7c82aa3a87323cb7ae04ac5f129f036b/path.c#L612 + const homedir = os.homedir(); + let templatePath = result.stdout.trim() + .replace(/^~([^\/]*)\//, (_, user) => `${user ? path.join(path.dirname(homedir), user) : homedir}/`); + + if (!path.isAbsolute(templatePath)) { + templatePath = path.join(this.repositoryRoot, templatePath); + } + + const raw = await fs.readFile(templatePath, 'utf8'); + return this.stripCommitMessageComments(raw); + } catch (err) { + return ''; + } + } + + async getCommit(ref: string): Promise { + const result = await this.exec(['-c', 'i18n.logOutputEncoding=UTF-8', 'show', '-s', '--decorate=full', '--shortstat', `--format=${COMMIT_FORMAT}`, '-z', ref, '--']); + const commits = parseGitCommits(result.stdout); + if (commits.length === 0) { + return Promise.reject('bad commit format'); + } + return commits[0]; + } + + async showChanges(ref: string): Promise { + try { + const result = await this.exec(['-c', 'i18n.logOutputEncoding=UTF-8', 'log', '-p', '-n1', ref, '--']); + return result.stdout.trim(); + } catch (err) { + if (/^fatal: bad revision '.+'/.test(err.stderr || '')) { + err.gitErrorCode = GitErrorCodes.BadRevision; + } + + throw err; + } + } + + async showChangesBetween(ref1: string, ref2: string, path?: string): Promise { + try { + const args = ['-c', 'i18n.logOutputEncoding=UTF-8', 'log', '-p', `${ref1}..${ref2}`, '--']; + if (path) { + args.push(this.sanitizeRelativePath(path)); + } + + const result = await this.exec(args); + return result.stdout.trim(); + } catch (err) { + if (/^fatal: bad revision '.+'/.test(err.stderr || '')) { + err.gitErrorCode = GitErrorCodes.BadRevision; + } + + throw err; + } + } + + async revList(ref1: string, ref2: string): Promise { + const result = await this.exec(['rev-list', `${ref1}..${ref2}`]); + if (result.stderr) { + return []; + } + + return result.stdout.trim().split('\n'); + } + + async revParse(ref: string): Promise { + try { + const result = await fs.readFile(path.join(this.dotGit.path, ref), 'utf8'); + return result.trim(); + } catch (err) { + this.logger.warn(`[Git][revParse] Unable to read file: ${err.message}`); + } + + try { + const result = await this.exec(['rev-parse', ref]); + if (result.stderr) { + return undefined; + } + return result.stdout.trim(); + } catch (err) { + return undefined; + } + } + + async updateSubmodules(paths: string[]): Promise { + const args = ['submodule', 'update']; + + for (const chunk of splitInChunks(paths.map(p => this.sanitizeRelativePath(p)), MAX_CLI_LENGTH)) { + await this.exec([...args, '--', ...chunk]); + } + } + + async getSubmodules(): Promise { + const gitmodulesPath = path.join(this.root, '.gitmodules'); + + try { + const gitmodulesRaw = await fs.readFile(gitmodulesPath, 'utf8'); + return parseGitmodules(gitmodulesRaw); + } catch (err) { + if (/ENOENT/.test(err.message)) { + return []; + } + + throw err; + } + } + + private sanitizeRelativePath(filePath: string): string { + this.logger.trace(`[Git][sanitizeRelativePath] filePath: ${filePath}`); + + // Relative path + if (!path.isAbsolute(filePath)) { + filePath = sanitizeRelativePath(filePath); + this.logger.trace(`[Git][sanitizeRelativePath] relativePath (noop): ${filePath}`); + return filePath; + } + + let relativePath: string | undefined; + + // Repository root real path + if (this.repositoryRootRealPath) { + relativePath = relativePathWithNoFallback(this.repositoryRootRealPath, filePath); + if (relativePath) { + relativePath = sanitizeRelativePath(relativePath); + this.logger.trace(`[Git][sanitizeRelativePath] relativePath (real path): ${relativePath}`); + return relativePath; + } + } + + // Repository root path + relativePath = relativePathWithNoFallback(this.repositoryRoot, filePath); + if (relativePath) { + relativePath = sanitizeRelativePath(relativePath); + this.logger.trace(`[Git][sanitizeRelativePath] relativePath (path): ${relativePath}`); + return relativePath; + } + + // Fallback to relative() + filePath = sanitizeRelativePath(path.relative(this.repositoryRoot, filePath)); + this.logger.trace(`[Git][sanitizeRelativePath] relativePath (fallback): ${filePath}`); + return filePath; + } +} diff --git a/extensions/git/src/repository.ts b/extensions/git/src/repository.ts new file mode 100644 index 0000000..046422c --- /dev/null +++ b/extensions/git/src/repository.ts @@ -0,0 +1,3707 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { cp } from '@vscode/fs-copyfile'; +import TelemetryReporter from '@vscode/extension-telemetry'; +import { uniqueNamesGenerator, adjectives, animals, colors, NumberDictionary } from '@joaomoreno/unique-names-generator'; +import * as fs from 'fs'; +import * as fsPromises from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; +import picomatch from 'picomatch'; +import { CancellationError, CancellationToken, CancellationTokenSource, Command, commands, CustomExecution, Disposable, Event, EventEmitter, FileDecoration, l10n, LogLevel, LogOutputChannel, Memento, ProcessExecution, ProgressLocation, ProgressOptions, RelativePattern, scm, ShellExecution, SourceControl, SourceControlInputBox, SourceControlInputBoxValidation, SourceControlInputBoxValidationType, SourceControlResourceDecorations, SourceControlResourceGroup, SourceControlResourceState, TabInputNotebookDiff, TabInputTextDiff, TabInputTextMultiDiff, Task, TaskPanelKind, TaskRevealKind, TaskRunOn, tasks, ThemeColor, ThemeIcon, Uri, window, workspace, WorkspaceEdit, WorkspaceFolder } from 'vscode'; +import { ActionButton } from './actionButton'; +import { ApiRepository } from './api/api1'; +import type { Branch, BranchQuery, Change, CommitOptions, DiffChange, FetchOptions, LogOptions, Ref, Remote, RepositoryKind } from './api/git'; +import { ForcePushMode, GitErrorCodes, RefType, Status } from './api/git.constants'; +import { AutoFetcher } from './autofetch'; +import { GitBranchProtectionProvider, IBranchProtectionProviderRegistry } from './branchProtection'; +import { debounce, memoize, sequentialize, throttle } from './decorators'; +import { Repository as BaseRepository, BlameInformation, Commit, CommitShortStat, GitError, IDotGit, LogFileOptions, LsTreeElement, PullOptions, RefQuery, Stash, Submodule, Worktree } from './git'; +import { GitHistoryProvider } from './historyProvider'; +import { Operation, OperationKind, OperationManager, OperationResult } from './operation'; +import { CommitCommandsCenter, IPostCommitCommandsProviderRegistry } from './postCommitCommands'; +import { IPushErrorHandlerRegistry } from './pushError'; +import { IRemoteSourcePublisherRegistry } from './remotePublisher'; +import { StatusBarCommands } from './statusbar'; +import { toGitUri } from './uri'; +import { anyEvent, combinedDisposable, debounceEvent, dispose, EmptyDisposable, eventToPromise, filterEvent, find, getCommitShortHash, IDisposable, isCopilotWorktreeFolder, isDescendant, isLinuxSnap, isRemote, isWindows, Limiter, onceEvent, pathEquals, relativePath } from './util'; +import { IFileWatcher, watch } from './watch'; +import { ISourceControlHistoryItemDetailsProviderRegistry } from './historyItemDetailsProvider'; +import { GitArtifactProvider } from './artifactProvider'; +import { RepositoryCache } from './repositoryCache'; +import { getSafeNotificationMessage } from './notification'; +import { GitQuickDiffProvider, StagedResourceQuickDiffProvider } from './quickDiffProvider'; +import { resolveWorktreeIncludePaths, sanitizeWorktreeIncludePatterns } from './worktreeInclude'; +import { createWorktreeSymlink, filterWorktreeSymlinkFolders, getWorktreeSymlinkFolderCandidates, type WorktreeSymlinkStatus } from './worktreeSymlink'; + +const timeout = (millis: number) => new Promise(c => setTimeout(c, millis)); + +const iconsRootPath = path.join(path.dirname(__dirname), 'resources', 'icons'); + +function getIconUri(iconName: string, theme: string): Uri { + return Uri.file(path.join(iconsRootPath, theme, `${iconName}.svg`)); +} + +export const enum RepositoryState { + Idle, + Disposed +} + +export const enum ResourceGroupType { + Merge, + Index, + WorkingTree, + Untracked +} + +export class Resource implements SourceControlResourceState { + + static getStatusLetter(type: Status): string { + switch (type) { + case Status.INDEX_MODIFIED: + case Status.MODIFIED: + return 'M'; + case Status.INDEX_ADDED: + case Status.INTENT_TO_ADD: + return 'A'; + case Status.INDEX_DELETED: + case Status.DELETED: + return 'D'; + case Status.INDEX_RENAMED: + case Status.INTENT_TO_RENAME: + return 'R'; + case Status.TYPE_CHANGED: + return 'T'; + case Status.UNTRACKED: + return 'U'; + case Status.IGNORED: + return 'I'; + case Status.INDEX_COPIED: + return 'C'; + case Status.BOTH_DELETED: + case Status.ADDED_BY_US: + case Status.DELETED_BY_THEM: + case Status.ADDED_BY_THEM: + case Status.DELETED_BY_US: + case Status.BOTH_ADDED: + case Status.BOTH_MODIFIED: + return '!'; // Using ! instead of ⚠, because the latter looks really bad on windows + default: + throw new Error('Unknown git status: ' + type); + } + } + + static getStatusText(type: Status) { + switch (type) { + case Status.INDEX_MODIFIED: return l10n.t('Index Modified'); + case Status.MODIFIED: return l10n.t('Modified'); + case Status.INDEX_ADDED: return l10n.t('Index Added'); + case Status.INDEX_DELETED: return l10n.t('Index Deleted'); + case Status.DELETED: return l10n.t('Deleted'); + case Status.INDEX_RENAMED: return l10n.t('Index Renamed'); + case Status.INDEX_COPIED: return l10n.t('Index Copied'); + case Status.UNTRACKED: return l10n.t('Untracked'); + case Status.IGNORED: return l10n.t('Ignored'); + case Status.INTENT_TO_ADD: return l10n.t('Intent to Add'); + case Status.INTENT_TO_RENAME: return l10n.t('Intent to Rename'); + case Status.TYPE_CHANGED: return l10n.t('Type Changed'); + case Status.BOTH_DELETED: return l10n.t('Conflict: Both Deleted'); + case Status.ADDED_BY_US: return l10n.t('Conflict: Added By Us'); + case Status.DELETED_BY_THEM: return l10n.t('Conflict: Deleted By Them'); + case Status.ADDED_BY_THEM: return l10n.t('Conflict: Added By Them'); + case Status.DELETED_BY_US: return l10n.t('Conflict: Deleted By Us'); + case Status.BOTH_ADDED: return l10n.t('Conflict: Both Added'); + case Status.BOTH_MODIFIED: return l10n.t('Conflict: Both Modified'); + default: return ''; + } + } + + static getStatusColor(type: Status): ThemeColor { + switch (type) { + case Status.INDEX_MODIFIED: + return new ThemeColor('gitDecoration.stageModifiedResourceForeground'); + case Status.MODIFIED: + case Status.TYPE_CHANGED: + return new ThemeColor('gitDecoration.modifiedResourceForeground'); + case Status.INDEX_DELETED: + return new ThemeColor('gitDecoration.stageDeletedResourceForeground'); + case Status.DELETED: + return new ThemeColor('gitDecoration.deletedResourceForeground'); + case Status.INDEX_ADDED: + case Status.INTENT_TO_ADD: + return new ThemeColor('gitDecoration.addedResourceForeground'); + case Status.INDEX_COPIED: + case Status.INDEX_RENAMED: + case Status.INTENT_TO_RENAME: + return new ThemeColor('gitDecoration.renamedResourceForeground'); + case Status.UNTRACKED: + return new ThemeColor('gitDecoration.untrackedResourceForeground'); + case Status.IGNORED: + return new ThemeColor('gitDecoration.ignoredResourceForeground'); + case Status.BOTH_DELETED: + case Status.ADDED_BY_US: + case Status.DELETED_BY_THEM: + case Status.ADDED_BY_THEM: + case Status.DELETED_BY_US: + case Status.BOTH_ADDED: + case Status.BOTH_MODIFIED: + return new ThemeColor('gitDecoration.conflictingResourceForeground'); + default: + throw new Error('Unknown git status: ' + type); + } + } + + @memoize + get resourceUri(): Uri { + if (this.renameResourceUri && (this._type === Status.MODIFIED || this._type === Status.DELETED || this._type === Status.INDEX_RENAMED || this._type === Status.INDEX_COPIED || this._type === Status.INTENT_TO_RENAME)) { + return this.renameResourceUri; + } + + return this._resourceUri; + } + + get leftUri(): Uri | undefined { + return this.resources.left; + } + + get rightUri(): Uri | undefined { + return this.resources.right; + } + + get multiDiffEditorOriginalUri(): Uri | undefined { + return this.resources.original; + } + + get multiFileDiffEditorModifiedUri(): Uri | undefined { + return this.resources.modified; + } + + @memoize + get command(): Command { + return this._commandResolver.resolveDefaultCommand(this); + } + + @memoize + private get resources(): { left: Uri | undefined; right: Uri | undefined; original: Uri | undefined; modified: Uri | undefined } { + return this._commandResolver.getResources(this); + } + + get resourceGroupType(): ResourceGroupType { return this._resourceGroupType; } + get type(): Status { return this._type; } + get original(): Uri { return this._resourceUri; } + get renameResourceUri(): Uri | undefined { return this._renameResourceUri; } + get contextValue(): string | undefined { return this._repositoryKind; } + + private static Icons = { + light: { + Modified: getIconUri('status-modified', 'light'), + Added: getIconUri('status-added', 'light'), + Deleted: getIconUri('status-deleted', 'light'), + Renamed: getIconUri('status-renamed', 'light'), + Copied: getIconUri('status-copied', 'light'), + Untracked: getIconUri('status-untracked', 'light'), + Ignored: getIconUri('status-ignored', 'light'), + Conflict: getIconUri('status-conflict', 'light'), + TypeChanged: getIconUri('status-type-changed', 'light') + }, + dark: { + Modified: getIconUri('status-modified', 'dark'), + Added: getIconUri('status-added', 'dark'), + Deleted: getIconUri('status-deleted', 'dark'), + Renamed: getIconUri('status-renamed', 'dark'), + Copied: getIconUri('status-copied', 'dark'), + Untracked: getIconUri('status-untracked', 'dark'), + Ignored: getIconUri('status-ignored', 'dark'), + Conflict: getIconUri('status-conflict', 'dark'), + TypeChanged: getIconUri('status-type-changed', 'dark') + } + }; + + private getIconPath(theme: 'light' | 'dark'): Uri { + switch (this.type) { + case Status.INDEX_MODIFIED: return Resource.Icons[theme].Modified; + case Status.MODIFIED: return Resource.Icons[theme].Modified; + case Status.INDEX_ADDED: return Resource.Icons[theme].Added; + case Status.INDEX_DELETED: return Resource.Icons[theme].Deleted; + case Status.DELETED: return Resource.Icons[theme].Deleted; + case Status.INDEX_RENAMED: return Resource.Icons[theme].Renamed; + case Status.INDEX_COPIED: return Resource.Icons[theme].Copied; + case Status.UNTRACKED: return Resource.Icons[theme].Untracked; + case Status.IGNORED: return Resource.Icons[theme].Ignored; + case Status.INTENT_TO_ADD: return Resource.Icons[theme].Added; + case Status.INTENT_TO_RENAME: return Resource.Icons[theme].Renamed; + case Status.TYPE_CHANGED: return Resource.Icons[theme].TypeChanged; + case Status.BOTH_DELETED: return Resource.Icons[theme].Conflict; + case Status.ADDED_BY_US: return Resource.Icons[theme].Conflict; + case Status.DELETED_BY_THEM: return Resource.Icons[theme].Conflict; + case Status.ADDED_BY_THEM: return Resource.Icons[theme].Conflict; + case Status.DELETED_BY_US: return Resource.Icons[theme].Conflict; + case Status.BOTH_ADDED: return Resource.Icons[theme].Conflict; + case Status.BOTH_MODIFIED: return Resource.Icons[theme].Conflict; + default: throw new Error('Unknown git status: ' + this.type); + } + } + + private get tooltip(): string { + return Resource.getStatusText(this.type); + } + + private get strikeThrough(): boolean { + switch (this.type) { + case Status.DELETED: + case Status.BOTH_DELETED: + case Status.DELETED_BY_THEM: + case Status.DELETED_BY_US: + case Status.INDEX_DELETED: + return true; + default: + return false; + } + } + + @memoize + private get faded(): boolean { + // TODO@joao + return false; + // const workspaceRootPath = this.workspaceRoot.fsPath; + // return this.resourceUri.fsPath.substr(0, workspaceRootPath.length) !== workspaceRootPath; + } + + get decorations(): SourceControlResourceDecorations { + const light = this._useIcons ? { iconPath: this.getIconPath('light') } : undefined; + const dark = this._useIcons ? { iconPath: this.getIconPath('dark') } : undefined; + const tooltip = this.tooltip; + const strikeThrough = this.strikeThrough; + const faded = this.faded; + return { strikeThrough, faded, tooltip, light, dark }; + } + + get letter(): string { + return Resource.getStatusLetter(this.type); + } + + get color(): ThemeColor { + return Resource.getStatusColor(this.type); + } + + get priority(): number { + switch (this.type) { + case Status.INDEX_MODIFIED: + case Status.MODIFIED: + case Status.INDEX_COPIED: + case Status.TYPE_CHANGED: + return 2; + case Status.IGNORED: + return 3; + case Status.BOTH_DELETED: + case Status.ADDED_BY_US: + case Status.DELETED_BY_THEM: + case Status.ADDED_BY_THEM: + case Status.DELETED_BY_US: + case Status.BOTH_ADDED: + case Status.BOTH_MODIFIED: + return 4; + default: + return 1; + } + } + + get resourceDecoration(): FileDecoration { + const res = new FileDecoration(this.letter, this.tooltip, this.color); + res.propagate = this.type !== Status.DELETED && this.type !== Status.INDEX_DELETED; + return res; + } + + constructor( + private _commandResolver: ResourceCommandResolver, + private _resourceGroupType: ResourceGroupType, + private _resourceUri: Uri, + private _type: Status, + private _useIcons: boolean, + private _renameResourceUri?: Uri, + private _repositoryKind?: 'repository' | 'submodule' | 'worktree', + ) { } + + async open(): Promise { + const command = this.command; + await commands.executeCommand(command.command, ...(command.arguments || [])); + } + + async openFile(): Promise { + const command = this._commandResolver.resolveFileCommand(this); + await commands.executeCommand(command.command, ...(command.arguments || [])); + } + + async openChange(): Promise { + const command = this._commandResolver.resolveChangeCommand(this); + await commands.executeCommand(command.command, ...(command.arguments || [])); + } + + async compareWithWorkspace(): Promise { + const command = this._commandResolver.resolveCompareWithWorkspaceCommand(this); + await commands.executeCommand(command.command, ...(command.arguments || [])); + } + + clone(resourceGroupType?: ResourceGroupType) { + return new Resource(this._commandResolver, resourceGroupType ?? this._resourceGroupType, this._resourceUri, this._type, this._useIcons, this._renameResourceUri, this._repositoryKind); + } +} + +export interface GitResourceGroup extends SourceControlResourceGroup { + resourceStates: Resource[]; +} + +interface GitResourceGroups { + indexGroup?: Resource[]; + mergeGroup?: Resource[]; + untrackedGroup?: Resource[]; + workingTreeGroup?: Resource[]; +} + +class ProgressManager { + + private enabled = false; + private disposable: IDisposable = EmptyDisposable; + private readonly disposables: IDisposable[] = []; + + constructor(private repository: Repository) { + const onDidChange = filterEvent(workspace.onDidChangeConfiguration, e => e.affectsConfiguration('git', Uri.file(this.repository.root))); + onDidChange(_ => this.updateEnablement(), null, this.disposables); + this.updateEnablement(); + + if (!workspace.isAgentSessionsWorkspace) { + this.repository.onDidChangeOperations(() => { + // Disable input box when the commit operation is running + this.repository.sourceControl.inputBox.enabled = !this.repository.operations.isRunning(OperationKind.Commit); + }, null, this.disposables); + } + } + + private updateEnablement(): void { + const config = workspace.getConfiguration('git', Uri.file(this.repository.root)); + const showProgress = config.get('showProgress') === true && !workspace.isAgentSessionsWorkspace; + + if (showProgress) { + this.enable(); + } else { + this.disable(); + } + } + + private enable(): void { + if (this.enabled) { + return; + } + + const start = onceEvent(filterEvent(this.repository.onDidChangeOperations, () => this.repository.operations.shouldShowProgress())); + const end = onceEvent(filterEvent(debounceEvent(this.repository.onDidChangeOperations, 300), () => !this.repository.operations.shouldShowProgress())); + + const setup = () => { + this.disposable = start(() => { + const promise = eventToPromise(end).then(() => setup()); + window.withProgress({ location: ProgressLocation.SourceControl }, () => promise); + }); + }; + + setup(); + this.enabled = true; + } + + private disable(): void { + if (!this.enabled) { + return; + } + + this.disposable.dispose(); + this.disposable = EmptyDisposable; + this.enabled = false; + } + + dispose(): void { + this.disable(); + dispose(this.disposables); + } +} + +class FileEventLogger { + + private eventDisposable: IDisposable = EmptyDisposable; + private logLevelDisposable: IDisposable = EmptyDisposable; + + constructor( + private onWorkspaceWorkingTreeFileChange: Event, + private onDotGitFileChange: Event, + private logger: LogOutputChannel + ) { + this.logLevelDisposable = logger.onDidChangeLogLevel(this.onDidChangeLogLevel, this); + this.onDidChangeLogLevel(logger.logLevel); + } + + private onDidChangeLogLevel(logLevel: LogLevel): void { + this.eventDisposable.dispose(); + + if (logLevel > LogLevel.Debug) { + return; + } + + this.eventDisposable = combinedDisposable([ + this.onWorkspaceWorkingTreeFileChange(uri => this.logger.debug(`[FileEventLogger][onWorkspaceWorkingTreeFileChange] ${uri.fsPath}`)), + this.onDotGitFileChange(uri => this.logger.debug(`[FileEventLogger][onDotGitFileChange] ${uri.fsPath}`)) + ]); + } + + dispose(): void { + this.eventDisposable.dispose(); + this.logLevelDisposable.dispose(); + } +} + +class DotGitWatcher implements IFileWatcher { + + readonly event: Event; + + private emitter = new EventEmitter(); + private transientDisposables: IDisposable[] = []; + private disposables: IDisposable[] = []; + + constructor( + private repository: Repository, + private logger: LogOutputChannel + ) { + const rootWatcher = watch(repository.dotGit.path); + this.disposables.push(rootWatcher); + + // Ignore changes to the "index.lock" file (including worktree index.lock files), and watchman fsmonitor hook (https://git-scm.com/docs/githooks#_fsmonitor_watchman) cookie files. + // Watchman creates a cookie file inside the git directory whenever a query is run (https://facebook.github.io/watchman/docs/cookies.html). + const filteredRootWatcher = filterEvent(rootWatcher.event, uri => uri.scheme === 'file' && !/\/\.git(\/index\.lock|\/worktrees\/[^/]+\/index\.lock)?$|\/\.watchman-cookie-/.test(uri.path)); + this.event = anyEvent(filteredRootWatcher, this.emitter.event); + + repository.onDidRunGitStatus(this.updateTransientWatchers, this, this.disposables); + this.updateTransientWatchers(); + } + + private updateTransientWatchers() { + this.transientDisposables = dispose(this.transientDisposables); + + if (!this.repository.HEAD || !this.repository.HEAD.upstream) { + return; + } + + this.transientDisposables = dispose(this.transientDisposables); + + const { name, remote } = this.repository.HEAD.upstream; + const upstreamPath = path.join(this.repository.dotGit.commonPath ?? this.repository.dotGit.path, 'refs', 'remotes', remote, name); + + try { + const upstreamWatcher = watch(upstreamPath); + this.transientDisposables.push(upstreamWatcher); + upstreamWatcher.event(this.emitter.fire, this.emitter, this.transientDisposables); + } catch (err) { + this.logger.warn(`[DotGitWatcher][updateTransientWatchers] Failed to watch ref '${upstreamPath}', is most likely packed.`); + } + } + + dispose() { + this.emitter.dispose(); + this.transientDisposables = dispose(this.transientDisposables); + this.disposables = dispose(this.disposables); + } +} + +class ResourceCommandResolver { + + constructor(private repository: Repository) { } + + resolveDefaultCommand(resource: Resource): Command { + const config = workspace.getConfiguration('git', Uri.file(this.repository.root)); + const openDiffOnClick = config.get('openDiffOnClick', true); + return openDiffOnClick ? this.resolveChangeCommand(resource) : this.resolveFileCommand(resource); + } + + resolveFileCommand(resource: Resource): Command { + return { + command: 'vscode.open', + title: l10n.t('Open'), + arguments: [resource.resourceUri] + }; + } + + resolveChangeCommand(resource: Resource, compareWithWorkspace?: boolean, leftUri?: Uri): Command { + if (!compareWithWorkspace) { + leftUri = resource.leftUri; + } + + const title = this.getTitle(resource); + + if (!leftUri) { + const bothModified = resource.type === Status.BOTH_MODIFIED; + if (resource.rightUri && workspace.getConfiguration('git').get('mergeEditor', false) && (bothModified || resource.type === Status.BOTH_ADDED)) { + const command = this.repository.isWorktreeMigrating ? 'git.openWorktreeMergeEditor' : 'git.openMergeEditor'; + return { + command, + title: l10n.t('Open Merge'), + arguments: [resource.rightUri] + }; + } else { + return { + command: 'vscode.open', + title: l10n.t('Open'), + arguments: [resource.rightUri, { override: bothModified ? false : undefined }, title] + }; + } + } else { + return { + command: 'vscode.diff', + title: l10n.t('Open'), + arguments: [leftUri, resource.rightUri, title] + }; + } + } + + resolveCompareWithWorkspaceCommand(resource: Resource): Command { + // Resource is not a worktree + if (!this.repository.dotGit.commonPath) { + return this.resolveChangeCommand(resource); + } + + const parentRepoRoot = path.dirname(this.repository.dotGit.commonPath); + const relPath = path.relative(this.repository.root, resource.resourceUri.fsPath); + const candidateFsPath = path.join(parentRepoRoot, relPath); + + // eslint-disable-next-line local/code-no-sync-fs -- TODO: make compareWithWorkspace await this probe before constructing the diff command; otherwise a missing worktree path produces the wrong left-hand resource. + const leftUri = fs.existsSync(candidateFsPath) ? Uri.file(candidateFsPath) : undefined; + + return this.resolveChangeCommand(resource, true, leftUri); + } + + getResources(resource: Resource): { left: Uri | undefined; right: Uri | undefined; original: Uri | undefined; modified: Uri | undefined } { + for (const submodule of this.repository.submodules) { + if (path.join(this.repository.root, submodule.path) === resource.resourceUri.fsPath) { + const original = undefined; + const modified = toGitUri(resource.resourceUri, resource.resourceGroupType === ResourceGroupType.Index ? 'index' : 'wt', { submoduleOf: this.repository.root }); + return { left: original, right: modified, original, modified }; + } + } + + const left = this.getLeftResource(resource); + const right = this.getRightResource(resource); + + return { + left: left.original ?? left.modified, + right: right.original ?? right.modified, + original: left.original ?? right.original, + modified: left.modified ?? right.modified, + }; + } + + private getLeftResource(resource: Resource): ModifiedOrOriginal { + switch (resource.type) { + case Status.INDEX_MODIFIED: + case Status.INDEX_RENAMED: + case Status.INTENT_TO_RENAME: + case Status.TYPE_CHANGED: + return { original: toGitUri(resource.original, 'HEAD') }; + + case Status.MODIFIED: + return { original: toGitUri(resource.resourceUri, '~') }; + + case Status.DELETED_BY_US: + case Status.DELETED_BY_THEM: + return { original: toGitUri(resource.resourceUri, '~1') }; + } + return {}; + } + + private getRightResource(resource: Resource): ModifiedOrOriginal { + switch (resource.type) { + case Status.INDEX_MODIFIED: + case Status.INDEX_ADDED: + case Status.INDEX_COPIED: + case Status.INDEX_RENAMED: + return { modified: toGitUri(resource.resourceUri, '') }; + + case Status.INDEX_DELETED: + case Status.DELETED: + return { original: toGitUri(resource.resourceUri, 'HEAD') }; + + case Status.DELETED_BY_US: + return { original: toGitUri(resource.resourceUri, '~3') }; + + case Status.DELETED_BY_THEM: + return { original: toGitUri(resource.resourceUri, '~2') }; + + case Status.MODIFIED: + case Status.UNTRACKED: + case Status.IGNORED: + case Status.INTENT_TO_ADD: + case Status.INTENT_TO_RENAME: + case Status.TYPE_CHANGED: { + const uriString = resource.resourceUri.toString(); + const [indexStatus] = this.repository.indexGroup.resourceStates.filter(r => r.resourceUri.toString() === uriString); + + if (indexStatus && indexStatus.renameResourceUri) { + return { modified: indexStatus.renameResourceUri }; + } + + return { modified: resource.resourceUri }; + } + case Status.BOTH_ADDED: + case Status.BOTH_MODIFIED: + return { modified: resource.resourceUri }; + } + + return {}; + } + + private getTitle(resource: Resource): string { + const basename = path.basename(resource.resourceUri.fsPath); + + switch (resource.type) { + case Status.INDEX_MODIFIED: + case Status.INDEX_RENAMED: + case Status.INDEX_ADDED: + return l10n.t('{0} (Index)', basename); + + case Status.MODIFIED: + case Status.BOTH_ADDED: + case Status.BOTH_MODIFIED: + return l10n.t('{0} (Working Tree)', basename); + + case Status.INDEX_DELETED: + case Status.DELETED: + return l10n.t('{0} (Deleted)', basename); + + case Status.DELETED_BY_US: + return l10n.t('{0} (Theirs)', basename); + + case Status.DELETED_BY_THEM: + return l10n.t('{0} (Ours)', basename); + + case Status.UNTRACKED: + return l10n.t('{0} (Untracked)', basename); + + case Status.INTENT_TO_ADD: + case Status.INTENT_TO_RENAME: + return l10n.t('{0} (Intent to add)', basename); + + case Status.TYPE_CHANGED: + return l10n.t('{0} (Type changed)', basename); + + default: + return ''; + } + } +} + +interface ModifiedOrOriginal { + modified?: Uri | undefined; + original?: Uri | undefined; +} + +interface BranchProtectionMatcher { + include?: picomatch.Matcher; + exclude?: picomatch.Matcher; +} + +export interface IRepositoryResolver { + getRepository(hint: SourceControl | SourceControlResourceGroup | Uri | string): Repository | undefined; +} + +export class Repository implements Disposable { + static readonly WORKTREE_ROOT_STORAGE_KEY = 'worktreeRoot'; + + private _onDidChangeRepository = new EventEmitter(); + readonly onDidChangeRepository: Event = this._onDidChangeRepository.event; + + private _onDidChangeState = new EventEmitter(); + readonly onDidChangeState: Event = this._onDidChangeState.event; + + private _onDidChangeStatus = new EventEmitter(); + readonly onDidRunGitStatus: Event = this._onDidChangeStatus.event; + + private _onDidChangeOriginalResource = new EventEmitter(); + readonly onDidChangeOriginalResource: Event = this._onDidChangeOriginalResource.event; + + private _onRunOperation = new EventEmitter(); + readonly onRunOperation: Event = this._onRunOperation.event; + + private _onDidRunOperation = new EventEmitter(); + readonly onDidRunOperation: Event = this._onDidRunOperation.event; + + private _onDidChangeBranchProtection = new EventEmitter(); + readonly onDidChangeBranchProtection: Event = this._onDidChangeBranchProtection.event; + + @memoize + get onDidChangeOperations(): Event { + return anyEvent( + this.onRunOperation as Event, + this.onDidRunOperation as Event) as Event; + } + + private _sourceControl: SourceControl; + get sourceControl(): SourceControl { return this._sourceControl; } + + get inputBox(): SourceControlInputBox { return this._sourceControl.inputBox; } + + private _mergeGroup: SourceControlResourceGroup; + get mergeGroup(): GitResourceGroup { return this._mergeGroup as GitResourceGroup; } + + private _indexGroup: SourceControlResourceGroup; + get indexGroup(): GitResourceGroup { return this._indexGroup as GitResourceGroup; } + + private _workingTreeGroup: SourceControlResourceGroup; + get workingTreeGroup(): GitResourceGroup { return this._workingTreeGroup as GitResourceGroup; } + + private _untrackedGroup: SourceControlResourceGroup; + get untrackedGroup(): GitResourceGroup { return this._untrackedGroup as GitResourceGroup; } + + private _EMPTY_TREE: string | undefined; + + private _HEAD: Branch | undefined; + get HEAD(): Branch | undefined { + return this._HEAD; + } + + private _refs: Ref[] = []; + get refs(): Ref[] { + return this._refs; + } + + get headShortName(): string | undefined { + if (!this.HEAD) { + return; + } + + const HEAD = this.HEAD; + + if (HEAD.name) { + return HEAD.name; + } + + return (HEAD.commit || '').substr(0, 8); + } + + private _remotes: Remote[] = []; + get remotes(): Remote[] { + return this._remotes; + } + + private _submodules: Submodule[] = []; + get submodules(): Submodule[] { + return this._submodules; + } + + private _worktrees: Worktree[] = []; + get worktrees(): Worktree[] { + return this._worktrees; + } + + private _rebaseCommit: Commit | undefined = undefined; + + set rebaseCommit(rebaseCommit: Commit | undefined) { + if (this._rebaseCommit && !rebaseCommit) { + this.inputBox.value = ''; + } else if (rebaseCommit && (!this._rebaseCommit || this._rebaseCommit.hash !== rebaseCommit.hash)) { + this.inputBox.value = rebaseCommit.message; + } + + const shouldUpdateContext = !!this._rebaseCommit !== !!rebaseCommit; + this._rebaseCommit = rebaseCommit; + + if (shouldUpdateContext) { + commands.executeCommand('setContext', 'gitRebaseInProgress', !!this._rebaseCommit); + } + } + + get rebaseCommit(): Commit | undefined { + return this._rebaseCommit; + } + + private _mergeInProgress: boolean = false; + + set mergeInProgress(value: boolean) { + if (this._mergeInProgress === value) { + return; + } + + this._mergeInProgress = value; + commands.executeCommand('setContext', 'gitMergeInProgress', value); + } + + get mergeInProgress() { + return this._mergeInProgress; + } + + private _cherryPickInProgress: boolean = false; + + set cherryPickInProgress(value: boolean) { + if (this._cherryPickInProgress === value) { + return; + } + + this._cherryPickInProgress = value; + commands.executeCommand('setContext', 'gitCherryPickInProgress', value); + } + + get cherryPickInProgress() { + return this._cherryPickInProgress; + } + + private _isWorktreeMigrating: boolean = false; + get isWorktreeMigrating(): boolean { return this._isWorktreeMigrating; } + set isWorktreeMigrating(value: boolean) { this._isWorktreeMigrating = value; } + + private readonly _operations: OperationManager; + get operations(): OperationManager { return this._operations; } + + private _state = RepositoryState.Idle; + get state(): RepositoryState { return this._state; } + set state(state: RepositoryState) { + this._state = state; + this._onDidChangeState.fire(state); + + this._HEAD = undefined; + this._remotes = []; + this.mergeGroup.resourceStates = []; + this.indexGroup.resourceStates = []; + this.workingTreeGroup.resourceStates = []; + this.untrackedGroup.resourceStates = []; + this._sourceControl.count = 0; + } + + get root(): string { + return this.repository.root; + } + + get rootRealPath(): string | undefined { + return this.repository.rootRealPath; + } + + get dotGit(): IDotGit { + return this.repository.dotGit; + } + + get kind(): RepositoryKind { + return this.repository.kind; + } + + private _isUsingVirtualFileSystem: boolean | undefined = undefined; + get isUsingVirtualFileSystem(): boolean { + return this._isUsingVirtualFileSystem === true; + } + + private _artifactProvider: GitArtifactProvider; + get artifactProvider(): GitArtifactProvider { return this._artifactProvider; } + + private _historyProvider: GitHistoryProvider; + get historyProvider(): GitHistoryProvider { return this._historyProvider; } + + private _isHidden: boolean; + get isHidden(): boolean { return this._isHidden; } + + private isRepositoryHuge: false | { limit: number } = false; + private didWarnAboutLimit = false; + + private unpublishedCommits: Set | undefined = undefined; + private branchProtection = new Map(); + private commitCommandCenter: CommitCommandsCenter; + private resourceCommandResolver = new ResourceCommandResolver(this); + private updateModelStateCancellationTokenSource: CancellationTokenSource | undefined; + private disposables: Disposable[] = []; + + constructor( + private readonly repository: BaseRepository, + private readonly repositoryResolver: IRepositoryResolver, + private pushErrorHandlerRegistry: IPushErrorHandlerRegistry, + remoteSourcePublisherRegistry: IRemoteSourcePublisherRegistry, + postCommitCommandsProviderRegistry: IPostCommitCommandsProviderRegistry, + private readonly branchProtectionProviderRegistry: IBranchProtectionProviderRegistry, + historyItemDetailProviderRegistry: ISourceControlHistoryItemDetailsProviderRegistry, + private readonly globalState: Memento, + private readonly logger: LogOutputChannel, + private telemetryReporter: TelemetryReporter, + private readonly repositoryCache: RepositoryCache + ) { + this._operations = new OperationManager(this.logger); + + const repositoryWatcher = workspace.createFileSystemWatcher(new RelativePattern(Uri.file(repository.root), '**')); + this.disposables.push(repositoryWatcher); + + const onRepositoryFileChange = anyEvent(repositoryWatcher.onDidChange, repositoryWatcher.onDidCreate, repositoryWatcher.onDidDelete); + const onRepositoryWorkingTreeFileChange = filterEvent(onRepositoryFileChange, uri => !/\.git($|\\|\/)/.test(relativePath(repository.root, uri.fsPath))); + + let onRepositoryDotGitFileChange: Event; + + try { + const dotGitFileWatcher = new DotGitWatcher(this, logger); + onRepositoryDotGitFileChange = dotGitFileWatcher.event; + this.disposables.push(dotGitFileWatcher); + } catch (err) { + logger.error(`Failed to watch path:'${this.dotGit.path}' or commonPath:'${this.dotGit.commonPath}', reverting to legacy API file watched. Some events might be lost.\n${err.stack || err}`); + + onRepositoryDotGitFileChange = filterEvent(onRepositoryFileChange, uri => /\.git($|\\|\/)/.test(uri.path)); + } + + // FS changes should trigger `git status`: + // - any change inside the repository working tree + // - any change within the first level of the `.git` folder, except the folder itself and `index.lock` (repository and worktree) + const onFileChange = anyEvent(onRepositoryWorkingTreeFileChange, onRepositoryDotGitFileChange); + onFileChange(this.onFileChange, this, this.disposables); + + // Relevate repository changes should trigger virtual document change events + onRepositoryDotGitFileChange(this._onDidChangeRepository.fire, this._onDidChangeRepository, this.disposables); + + this.disposables.push(new FileEventLogger(onRepositoryWorkingTreeFileChange, onRepositoryDotGitFileChange, logger)); + + // Parent source control. Repositories opened in the Sessions app + // don't use the parent/child relationship and it is expected for + // a worktree repository to be opened while the main repository + // is closed. + const parentRoot = workspace.isAgentSessionsWorkspace + ? undefined + : repository.kind === 'submodule' + ? repository.dotGit.superProjectPath + : repository.kind === 'worktree' && repository.dotGit.commonPath + ? path.dirname(repository.dotGit.commonPath) + : undefined; + const parent = parentRoot + ? this.repositoryResolver.getRepository(parentRoot)?.sourceControl + : undefined; + + // Icon + const icon = repository.kind === 'submodule' + ? new ThemeIcon('archive') + : repository.kind === 'worktree' + ? isCopilotWorktreeFolder(repository.root) + ? new ThemeIcon('chat-sparkle') + : new ThemeIcon('worktree') + : new ThemeIcon('repo'); + + // Hidden + // This is a temporary solution to hide: + // * repositories in the empty window + // * worktrees created by Copilot when the main repository + // is opened. Users can still manually open the worktree + // from the Repositories view. + this._isHidden = workspace.workspaceFolders === undefined || + (repository.kind === 'worktree' && + isCopilotWorktreeFolder(repository.root) && parent !== undefined); + + const root = Uri.file(repository.root); + this._sourceControl = scm.createSourceControl('git', 'Git', root, icon, this._isHidden, parent); + this._sourceControl.contextValue = repository.kind; + const activeRepositoryRoot = repository.dotGit.isBare ? repository.dotGit.commonPath : parentRoot; + this._sourceControl.activeRepositoryName = repository.kind === 'worktree' && activeRepositoryRoot + ? path.basename(activeRepositoryRoot) || undefined + : undefined; + + this._sourceControl.quickDiffProvider = new GitQuickDiffProvider(this, this.repositoryResolver, logger); + this._sourceControl.secondaryQuickDiffProvider = new StagedResourceQuickDiffProvider(this, logger); + + this._historyProvider = new GitHistoryProvider(historyItemDetailProviderRegistry, this, logger); + this._sourceControl.historyProvider = this._historyProvider; + this.disposables.push(this._historyProvider); + + this._artifactProvider = new GitArtifactProvider(this, logger); + this._sourceControl.artifactProvider = this._artifactProvider; + this.disposables.push(this._artifactProvider); + + this._sourceControl.acceptInputCommand = { command: 'git.commit', title: l10n.t('Commit'), arguments: [this._sourceControl] }; + this._sourceControl.inputBox.validateInput = this.validateInput.bind(this); + + this.disposables.push(this._sourceControl); + + this.updateInputBoxPlaceholder(); + this.disposables.push(this.onDidRunGitStatus(() => this.updateInputBoxPlaceholder())); + + this._mergeGroup = this._sourceControl.createResourceGroup('merge', l10n.t('Merge Changes')); + this._indexGroup = this._sourceControl.createResourceGroup('index', l10n.t('Staged Changes'), { multiDiffEditorEnableViewChanges: true }); + this._workingTreeGroup = this._sourceControl.createResourceGroup('workingTree', l10n.t('Changes'), { multiDiffEditorEnableViewChanges: true }); + this._untrackedGroup = this._sourceControl.createResourceGroup('untracked', l10n.t('Untracked Changes'), { multiDiffEditorEnableViewChanges: true }); + + const updateIndexGroupVisibility = () => { + const config = workspace.getConfiguration('git', root); + this.indexGroup.hideWhenEmpty = !config.get('alwaysShowStagedChangesResourceGroup'); + }; + + const onConfigListener = filterEvent(workspace.onDidChangeConfiguration, e => e.affectsConfiguration('git.alwaysShowStagedChangesResourceGroup', root)); + onConfigListener(updateIndexGroupVisibility, this, this.disposables); + updateIndexGroupVisibility(); + + workspace.onDidChangeConfiguration(e => { + if (e.affectsConfiguration('git.mergeEditor')) { + this.mergeGroup.resourceStates = this.mergeGroup.resourceStates.map(r => r.clone()); + } + }, undefined, this.disposables); + + filterEvent(workspace.onDidChangeConfiguration, e => + e.affectsConfiguration('git.branchSortOrder', root) + || e.affectsConfiguration('git.untrackedChanges', root) + || e.affectsConfiguration('git.ignoreSubmodules', root) + || e.affectsConfiguration('git.openDiffOnClick', root) + || e.affectsConfiguration('git.showActionButton', root) + || e.affectsConfiguration('git.similarityThreshold', root) + )(() => this.updateModelState(), this, this.disposables); + + const updateInputBoxVisibility = () => { + const config = workspace.getConfiguration('git', root); + this._sourceControl.inputBox.visible = config.get('showCommitInput', true); + }; + + const onConfigListenerForInputBoxVisibility = filterEvent(workspace.onDidChangeConfiguration, e => e.affectsConfiguration('git.showCommitInput', root)); + onConfigListenerForInputBoxVisibility(updateInputBoxVisibility, this, this.disposables); + updateInputBoxVisibility(); + + this.mergeGroup.hideWhenEmpty = true; + this.untrackedGroup.hideWhenEmpty = true; + + this.disposables.push(this.mergeGroup); + this.disposables.push(this.indexGroup); + this.disposables.push(this.workingTreeGroup); + this.disposables.push(this.untrackedGroup); + + // Don't allow auto-fetch in untrusted workspaces + if (workspace.isTrusted) { + this.disposables.push(new AutoFetcher(this, globalState)); + } else { + const trustDisposable = workspace.onDidGrantWorkspaceTrust(() => { + trustDisposable.dispose(); + this.disposables.push(new AutoFetcher(this, globalState)); + }); + this.disposables.push(trustDisposable); + } + + // https://github.com/microsoft/vscode/issues/39039 + const onSuccessfulPush = filterEvent(this.onDidRunOperation, e => e.operation.kind === OperationKind.Push && !e.error); + onSuccessfulPush(() => { + const gitConfig = workspace.getConfiguration('git'); + + if (gitConfig.get('showPushSuccessNotification')) { + window.showInformationMessage(l10n.t('Successfully pushed.')); + } + }, null, this.disposables); + + // Default branch protection provider + const onBranchProtectionProviderChanged = filterEvent(this.branchProtectionProviderRegistry.onDidChangeBranchProtectionProviders, e => pathEquals(e.fsPath, root.fsPath)); + this.disposables.push(onBranchProtectionProviderChanged(root => this.updateBranchProtectionMatchers(root))); + const branchProtectionProvider = new GitBranchProtectionProvider(root, this.logger); + this.disposables.push(branchProtectionProvider); + this.disposables.push(this.branchProtectionProviderRegistry.registerBranchProtectionProvider(root, branchProtectionProvider)); + + const statusBar = new StatusBarCommands(this, remoteSourcePublisherRegistry); + this.disposables.push(statusBar); + statusBar.onDidChange(() => this._sourceControl.statusBarCommands = statusBar.commands, null, this.disposables); + this._sourceControl.statusBarCommands = statusBar.commands; + + this.commitCommandCenter = new CommitCommandsCenter(globalState, this, postCommitCommandsProviderRegistry); + this.disposables.push(this.commitCommandCenter); + + const actionButton = new ActionButton(this, this.commitCommandCenter, this.logger); + this.disposables.push(actionButton); + actionButton.onDidChange(() => this._sourceControl.actionButton = actionButton.button, this, this.disposables); + this._sourceControl.actionButton = actionButton.button; + + const progressManager = new ProgressManager(this); + this.disposables.push(progressManager); + + const onDidChangeCountBadge = filterEvent(workspace.onDidChangeConfiguration, e => e.affectsConfiguration('git.countBadge', root)); + onDidChangeCountBadge(this.setCountBadge, this, this.disposables); + this.setCountBadge(); + } + + validateInput(text: string, _: number): SourceControlInputBoxValidation | undefined { + if (this.isRepositoryHuge) { + return { + message: l10n.t('Too many changes were detected. Only the first {0} changes will be shown below.', this.isRepositoryHuge.limit), + type: SourceControlInputBoxValidationType.Warning + }; + } + + if (this.rebaseCommit) { + if (this.rebaseCommit.message !== text) { + return { + message: l10n.t('It\'s not possible to change the commit message in the middle of a rebase. Please complete the rebase operation and use interactive rebase instead.'), + type: SourceControlInputBoxValidationType.Warning + }; + } + } + + return undefined; + } + + async getInputTemplate(): Promise { + const commitMessage = (await Promise.all([this.repository.getMergeMessage(), this.repository.getSquashMessage()])).find(msg => !!msg); + + if (commitMessage) { + return commitMessage; + } + + return await this.repository.getCommitTemplate(); + } + + getConfigs(): Promise<{ key: string; value: string }[]> { + return this.run(Operation.Config(true), () => this.repository.getConfigs('local')); + } + + getConfig(key: string): Promise { + return this.run(Operation.Config(true), () => this.repository.config('get', 'local', key)); + } + + getGlobalConfig(key: string): Promise { + return this.run(Operation.Config(true), () => this.repository.config('get', 'global', key)); + } + + setConfig(key: string, value: string): Promise { + return this.run(Operation.Config(false), () => this.repository.config('add', 'local', key, value)); + } + + unsetConfig(key: string): Promise { + return this.run(Operation.Config(false), () => this.repository.config('unset', 'local', key)); + } + + log(options?: LogOptions & { silent?: boolean }, cancellationToken?: CancellationToken): Promise { + const showProgress = !options || options.silent !== true; + return this.run(Operation.Log(showProgress), () => this.repository.log(options, cancellationToken)); + } + + logFile(uri: Uri, options?: LogFileOptions, cancellationToken?: CancellationToken): Promise { + // TODO: This probably needs per-uri granularity + return this.run(Operation.LogFile, () => this.repository.logFile(uri, options, cancellationToken)); + } + + @throttle + async status(): Promise { + await this.run(Operation.Status); + } + + @throttle + async refresh(): Promise { + await this.run(Operation.Refresh); + } + + diff(cached?: boolean): Promise { + return this.run(Operation.Diff, () => this.repository.diff(cached)); + } + + diffWithHEAD(): Promise; + diffWithHEAD(path: string): Promise; + diffWithHEAD(path?: string | undefined): Promise; + diffWithHEAD(path?: string | undefined): Promise { + return this.run(Operation.Diff, () => this.repository.diffWithHEAD(path)); + } + + diffWithHEADShortStats(path?: string): Promise { + return this.run(Operation.Diff, () => this.repository.diffWithHEADShortStats(path)); + } + + diffWith(ref: string): Promise; + diffWith(ref: string, path: string): Promise; + diffWith(ref: string, path?: string | undefined): Promise; + diffWith(ref: string, path?: string): Promise { + return this.run(Operation.Diff, () => this.repository.diffWith(ref, path)); + } + + diffIndexWithHEAD(): Promise; + diffIndexWithHEAD(path: string): Promise; + diffIndexWithHEAD(path?: string | undefined): Promise; + diffIndexWithHEAD(path?: string): Promise { + return this.run(Operation.Diff, () => this.repository.diffIndexWithHEAD(path)); + } + + diffIndexWithHEADShortStats(path?: string): Promise { + return this.run(Operation.Diff, () => this.repository.diffIndexWithHEADShortStats(path)); + } + + diffIndexWith(ref: string): Promise; + diffIndexWith(ref: string, path: string): Promise; + diffIndexWith(ref: string, path?: string | undefined): Promise; + diffIndexWith(ref: string, path?: string): Promise { + return this.run(Operation.Diff, () => this.repository.diffIndexWith(ref, path)); + } + + diffBlobs(object1: string, object2: string): Promise { + return this.run(Operation.Diff, () => this.repository.diffBlobs(object1, object2)); + } + + diffBetween(ref1: string, ref2: string): Promise; + diffBetween(ref1: string, ref2: string, path: string): Promise; + diffBetween(ref1: string, ref2: string, path?: string | undefined): Promise; + diffBetween(ref1: string, ref2: string, path?: string): Promise { + return this.run(Operation.Diff, () => this.repository.diffBetween(ref1, ref2, path)); + } + + diffBetweenPatch(ref1: string, ref2: string, path?: string): Promise { + return this.run(Operation.Diff, () => + this.repository.diffBetweenPatch(`${ref1}...${ref2}`, { path })); + } + + diffBetweenWithStats(ref1: string, ref2: string, path?: string): Promise { + if (ref1 === this._EMPTY_TREE) { + // Use git diff-tree to get the + // changes in the first commit + return this.diffTrees(ref1, ref2); + } + + const scopedConfig = workspace.getConfiguration('git', Uri.file(this.root)); + const similarityThreshold = scopedConfig.get('similarityThreshold', 50); + + return this.run(Operation.Diff, () => + this.repository.diffBetweenWithStats(`${ref1}...${ref2}`, { path, similarityThreshold })); + } + + diffBetweenWithStats2(ref: string, path?: string): Promise { + const scopedConfig = workspace.getConfiguration('git', Uri.file(this.root)); + const similarityThreshold = scopedConfig.get('similarityThreshold', 50); + + return this.run(Operation.Diff, () => + this.repository.diffBetweenWithStats(ref, { path, similarityThreshold })); + } + + diffTrees(treeish1: string, treeish2?: string): Promise { + const scopedConfig = workspace.getConfiguration('git', Uri.file(this.root)); + const similarityThreshold = scopedConfig.get('similarityThreshold', 50); + + return this.run(Operation.Diff, () => this.repository.diffTrees(treeish1, treeish2, { similarityThreshold })); + } + + getMergeBase(ref1: string, ref2: string, ...refs: string[]): Promise { + return this.run(Operation.MergeBase, () => this.repository.getMergeBase(ref1, ref2, ...refs)); + } + + async hashObject(data: string): Promise { + return this.run(Operation.HashObject, () => this.repository.hashObject(data)); + } + + async add(resources: Uri[], opts?: { update?: boolean }): Promise { + await this.run( + Operation.Add(!this.optimisticUpdateEnabled()), + async () => { + await this.repository.add(resources.map(r => r.fsPath), opts); + this.closeDiffEditors([], [...resources.map(r => r.fsPath)]); + + // Accept working set changes across all chat sessions + commands.executeCommand('_chat.editSessions.accept', resources); + }, + () => { + const resourcePaths = resources.map(r => r.fsPath); + const indexGroupResourcePaths = this.indexGroup.resourceStates.map(r => r.resourceUri.fsPath); + + // Collect added resources + const addedResourceStates: Resource[] = []; + for (const resource of [...this.mergeGroup.resourceStates, ...this.untrackedGroup.resourceStates, ...this.workingTreeGroup.resourceStates]) { + if (resourcePaths.includes(resource.resourceUri.fsPath) && !indexGroupResourcePaths.includes(resource.resourceUri.fsPath)) { + addedResourceStates.push(resource.clone(ResourceGroupType.Index)); + } + } + + // Add new resource(s) to index group + const indexGroup = [...this.indexGroup.resourceStates, ...addedResourceStates]; + + // Remove resource(s) from merge group + const mergeGroup = this.mergeGroup.resourceStates + .filter(r => !resourcePaths.includes(r.resourceUri.fsPath)); + + // Remove resource(s) from working group + const workingTreeGroup = this.workingTreeGroup.resourceStates + .filter(r => !resourcePaths.includes(r.resourceUri.fsPath)); + + // Remove resource(s) from untracked group + const untrackedGroup = this.untrackedGroup.resourceStates + .filter(r => !resourcePaths.includes(r.resourceUri.fsPath)); + + return { indexGroup, mergeGroup, workingTreeGroup, untrackedGroup }; + }); + } + + async rm(resources: Uri[]): Promise { + await this.run(Operation.Remove, () => this.repository.rm(resources.map(r => r.fsPath))); + } + + async stage(resource: Uri, contents: string, encoding: string): Promise { + await this.run(Operation.Stage, async () => { + const data = await workspace.encode(contents, { encoding }); + await this.repository.stage(resource.fsPath, data); + + this._onDidChangeOriginalResource.fire(resource); + this.closeDiffEditors([], [...resource.fsPath]); + }); + } + + async revert(resources: Uri[]): Promise { + await this.run( + Operation.RevertFiles(!this.optimisticUpdateEnabled()), + async () => { + await this.repository.revert('HEAD', resources.map(r => r.fsPath)); + for (const resource of resources) { + this._onDidChangeOriginalResource.fire(resource); + } + this.closeDiffEditors([...resources.length !== 0 ? + resources.map(r => r.fsPath) : + this.indexGroup.resourceStates.map(r => r.resourceUri.fsPath)], []); + + // Clear AI contribution tracking for reverted resources + const uris = resources.length !== 0 + ? resources + : this.indexGroup.resourceStates.map(r => r.resourceUri); + commands.executeCommand('_aiEdits.clearAiContributions', uris); + }, + () => { + const config = workspace.getConfiguration('git', Uri.file(this.repository.root)); + const untrackedChanges = config.get<'mixed' | 'separate' | 'hidden'>('untrackedChanges'); + const untrackedChangesResourceGroupType = untrackedChanges === 'mixed' ? ResourceGroupType.WorkingTree : ResourceGroupType.Untracked; + + const resourcePaths = resources.length === 0 ? + this.indexGroup.resourceStates.map(r => r.resourceUri.fsPath) : resources.map(r => r.fsPath); + + // Collect removed resources + const trackedResources: Resource[] = []; + const untrackedResources: Resource[] = []; + for (const resource of this.indexGroup.resourceStates) { + if (resourcePaths.includes(resource.resourceUri.fsPath)) { + if (resource.type === Status.INDEX_ADDED) { + untrackedResources.push(resource.clone(untrackedChangesResourceGroupType)); + } else { + trackedResources.push(resource.clone(ResourceGroupType.WorkingTree)); + } + } + } + + // Remove resource(s) from index group + const indexGroup = this.indexGroup.resourceStates + .filter(r => !resourcePaths.includes(r.resourceUri.fsPath)); + + // Add resource(s) to working group + const workingTreeGroup = untrackedChanges === 'mixed' ? + [...this.workingTreeGroup.resourceStates, ...trackedResources, ...untrackedResources] : + [...this.workingTreeGroup.resourceStates, ...trackedResources]; + + // Add resource(s) to untracked group + const untrackedGroup = untrackedChanges === 'separate' ? + [...this.untrackedGroup.resourceStates, ...untrackedResources] : undefined; + + return { indexGroup, workingTreeGroup, untrackedGroup }; + }); + } + + async restore(resources: Uri[], options?: { staged?: boolean; ref?: string }): Promise { + await this.run( + Operation.Restore(!this.optimisticUpdateEnabled()), + async () => { + const toClean: string[] = []; + const toRestore: string[] = []; + + const resourceStates = [ + ...this.indexGroup.resourceStates, + ...this.workingTreeGroup.resourceStates, + ...this.untrackedGroup.resourceStates + ]; + + for (const resource of resources) { + const scmResource = find(resourceStates, r => r.resourceUri.toString() === resource.toString()); + + if (!scmResource) { + toRestore.push(resource.fsPath); + continue; + } + + switch (scmResource.type) { + case Status.UNTRACKED: + case Status.IGNORED: + toClean.push(resource.fsPath); + break; + + default: + toRestore.push(resource.fsPath); + break; + } + } + + if (toClean.length > 0) { + await this._clean(toClean); + } + + if (toRestore.length > 0) { + await this.repository.restore(toRestore, options); + } + + this.closeDiffEditors([], [...toClean, ...toRestore]); + + // Clear AI contribution tracking for discarded resources + commands.executeCommand('_aiEdits.clearAiContributions', resources); + }); + } + + async commit(message: string | undefined, opts: CommitOptions = Object.create(null)): Promise { + const indexResources = [...this.indexGroup.resourceStates.map(r => r.resourceUri.fsPath)]; + const workingGroupResources = opts.all && opts.all !== 'tracked' ? + [...this.workingTreeGroup.resourceStates.map(r => r.resourceUri.fsPath)] : []; + + if (this.rebaseCommit) { + await this.run( + Operation.RebaseContinue, + async () => { + if (opts.all) { + const addOpts = opts.all === 'tracked' ? { update: true } : {}; + await this.repository.add([], addOpts); + } + + await this.repository.rebaseContinue(); + await this.commitOperationCleanup(message, indexResources, workingGroupResources); + }, + () => this.commitOperationGetOptimisticResourceGroups(opts)); + } else { + // Set post-commit command to render the correct action button + this.commitCommandCenter.postCommitCommand = opts.postCommitCommand; + + await this.run( + Operation.Commit, + async () => { + if (opts.all) { + const addOpts = opts.all === 'tracked' ? { update: true } : {}; + await this.repository.add([], addOpts); + } + + delete opts.all; + + if (opts.requireUserConfig === undefined || opts.requireUserConfig === null) { + const config = workspace.getConfiguration('git', Uri.file(this.root)); + opts.requireUserConfig = config.get('requireGitUserConfig'); + } + + // Add AI co-author trailer if applicable + message = await this.appendAICoAuthorTrailer(message, indexResources, workingGroupResources); + + await this.repository.commit(message, opts); + await this.commitOperationCleanup(message, indexResources, workingGroupResources); + }, + () => this.commitOperationGetOptimisticResourceGroups(opts)); + + // Execute post-commit command + await this.run(Operation.PostCommitCommand, async () => { + await this.commitCommandCenter.executePostCommitCommand(opts.postCommitCommand); + }); + } + } + + private async commitOperationCleanup(message: string | undefined, indexResources: string[], workingGroupResources: string[]) { + if (message) { + this.inputBox.value = await this.getInputTemplate(); + } + this.closeDiffEditors(indexResources, workingGroupResources); + + // Accept working set changes across all chat sessions + const resources = indexResources.length !== 0 + ? indexResources.map(r => Uri.file(r)) + : workingGroupResources.map(r => Uri.file(r)); + commands.executeCommand('_chat.editSessions.accept', resources); + + // Clear AI contribution tracking for committed resources + commands.executeCommand('_aiEdits.clearAiContributions', resources); + } + + private static readonly AI_CO_AUTHOR_TRAILER = 'Co-authored-by: Copilot '; + + private async appendAICoAuthorTrailer( + message: string | undefined, + indexResources: string[], + workingGroupResources: string[] + ): Promise { + if (!message) { + return message; + } + + const chatConfig = workspace.getConfiguration('chat'); + if (chatConfig.get('disableAIFeatures', false)) { + return message; + } + + const config = workspace.getConfiguration('git', Uri.file(this.root)); + const addAICoAuthor = config.get<'off' | 'chatAndAgent' | 'all'>('addAICoAuthor', 'off'); + + if (addAICoAuthor === 'off') { + return message; + } + + // Don't add if trailer is already present + if (message.includes(Repository.AI_CO_AUTHOR_TRAILER)) { + return message; + } + + const resources = indexResources.length !== 0 + ? indexResources.map(r => Uri.file(r)) + : workingGroupResources.map(r => Uri.file(r)); + + if (resources.length === 0) { + return message; + } + + try { + const level = addAICoAuthor === 'all' ? 'all' : 'chatAndAgent'; + const hasAiContributions = await commands.executeCommand('_aiEdits.hasAiContributions', resources, level); + if (hasAiContributions) { + // Ensure proper trailer formatting: blank line before trailers + const trimmed = message.trimEnd(); + return `${trimmed}\n\n${Repository.AI_CO_AUTHOR_TRAILER}`; + } + } catch { + // Command may not be available (e.g., in web environment) + } + + return message; + } + + private commitOperationGetOptimisticResourceGroups(opts: CommitOptions): GitResourceGroups { + let untrackedGroup: Resource[] | undefined = undefined, + workingTreeGroup: Resource[] | undefined = undefined; + + if (opts.all === 'tracked') { + workingTreeGroup = this.workingTreeGroup.resourceStates + .filter(r => r.type === Status.UNTRACKED); + } else if (opts.all) { + untrackedGroup = workingTreeGroup = []; + } + + return { indexGroup: [], mergeGroup: [], untrackedGroup, workingTreeGroup }; + } + + async clean(resources: Uri[]): Promise { + await this.run( + Operation.Clean(!this.optimisticUpdateEnabled()), + async () => { + const toClean: string[] = []; + const toCheckout: string[] = []; + const submodulesToUpdate: string[] = []; + const resourceStates = [...this.workingTreeGroup.resourceStates, ...this.untrackedGroup.resourceStates]; + + resources.forEach(r => { + const fsPath = r.fsPath; + + for (const submodule of this.submodules) { + if (path.join(this.root, submodule.path) === fsPath) { + submodulesToUpdate.push(fsPath); + return; + } + } + + const raw = r.toString(); + const scmResource = find(resourceStates, sr => sr.resourceUri.toString() === raw); + + if (!scmResource) { + return; + } + + switch (scmResource.type) { + case Status.UNTRACKED: + case Status.IGNORED: + toClean.push(fsPath); + break; + + default: + toCheckout.push(fsPath); + break; + } + }); + + if (toClean.length > 0) { + await this._clean(toClean); + } + + if (toCheckout.length > 0) { + try { + await this.repository.checkout('', toCheckout); + } catch (err) { + if (err.gitErrorCode !== GitErrorCodes.BranchNotYetBorn) { + throw err; + } + } + } + + if (submodulesToUpdate.length > 0) { + await this.repository.updateSubmodules(submodulesToUpdate); + } + + this.closeDiffEditors([], [...toClean, ...toCheckout]); + + // Clear AI contribution tracking for discarded resources + commands.executeCommand('_aiEdits.clearAiContributions', resources); + }, + () => { + const resourcePaths = resources.map(r => r.fsPath); + + // Remove resource(s) from working group + const workingTreeGroup = this.workingTreeGroup.resourceStates + .filter(r => !resourcePaths.includes(r.resourceUri.fsPath)); + + // Remove resource(s) from untracked group + const untrackedGroup = this.untrackedGroup.resourceStates + .filter(r => !resourcePaths.includes(r.resourceUri.fsPath)); + + return { workingTreeGroup, untrackedGroup }; + }); + } + + async _clean(resources: string[]): Promise { + const config = workspace.getConfiguration('git'); + const discardUntrackedChangesToTrash = config.get('discardUntrackedChangesToTrash', true) && !isRemote && !isLinuxSnap; + + if (resources.length === 0) { + return; + } + + if (discardUntrackedChangesToTrash) { + try { + // Attempt to move the first resource to the recycle bin/trash to check + // if it is supported. If it fails, we show a confirmation dialog and + // fall back to deletion. + await workspace.fs.delete(Uri.file(resources[0]), { useTrash: true }); + + const limiter = new Limiter(5); + await Promise.all(resources.slice(1).map(fsPath => limiter.queue( + async () => await workspace.fs.delete(Uri.file(fsPath), { useTrash: true })))); + } catch { + const message = isWindows + ? l10n.t('Failed to delete using the Recycle Bin. Do you want to permanently delete instead?') + : l10n.t('Failed to delete using the Trash. Do you want to permanently delete instead?'); + const primaryAction = resources.length === 1 + ? l10n.t('Delete File') + : l10n.t('Delete All {0} Files', resources.length); + + const result = await window.showWarningMessage(message, { modal: true }, primaryAction); + if (result === primaryAction) { + // Delete permanently + await this.repository.clean(resources); + } + } + } else { + await this.repository.clean(resources); + } + } + + closeDiffEditors(indexResources: string[] | undefined, workingTreeResources: string[] | undefined, ignoreSetting = false): void { + const config = workspace.getConfiguration('git', Uri.file(this.root)); + if (!config.get('closeDiffOnOperation', false) && !ignoreSetting) { return; } + + function checkTabShouldClose(input: TabInputTextDiff | TabInputNotebookDiff) { + if (input.modified.scheme === 'git' && (indexResources === undefined || indexResources.some(r => pathEquals(r, input.modified.fsPath)))) { + // Index + return true; + } + if (input.modified.scheme === 'file' && input.original.scheme === 'git' && (workingTreeResources === undefined || workingTreeResources.some(r => pathEquals(r, input.modified.fsPath)))) { + // Working Tree + return true; + } + return false; + } + + const diffEditorTabsToClose = window.tabGroups.all + .flatMap(g => g.tabs) + .filter(({ input }) => { + if (input instanceof TabInputTextDiff || input instanceof TabInputNotebookDiff) { + return checkTabShouldClose(input); + } else if (input instanceof TabInputTextMultiDiff) { + return input.textDiffs.every(checkTabShouldClose); + } + return false; + }); + + // Close editors + window.tabGroups.close(diffEditorTabsToClose, true); + } + + async branch(name: string, _checkout: boolean, _ref?: string): Promise { + await this.run(Operation.Branch, () => this.repository.branch(name, _checkout, _ref)); + } + + async deleteBranch(name: string, force?: boolean): Promise { + return this.run(Operation.DeleteBranch, async () => { + await this.repository.deleteBranch(name, force); + await this.repository.config('unset', 'local', `branch.${name}.vscode-merge-base`); + }); + } + + async renameBranch(name: string): Promise { + await this.run(Operation.RenameBranch, () => this.repository.renameBranch(name)); + } + + @throttle + async fastForwardBranch(name: string): Promise { + // Get branch details + const branch = await this.getBranch(name); + if (!branch.upstream?.remote || !branch.upstream?.name || !branch.name) { + return; + } + + try { + // Fast-forward the branch if possible + const options = { remote: branch.upstream.remote, ref: `${branch.upstream.name}:${branch.name}` }; + await this.run(Operation.Fetch(true), async () => this.repository.fetch(options)); + } catch (err) { + if (err.gitErrorCode === GitErrorCodes.BranchFastForwardRejected) { + return; + } + + throw err; + } + } + + async cherryPick(commitHash: string): Promise { + await this.run(Operation.CherryPick, () => this.repository.cherryPick(commitHash)); + } + + async cherryPickAbort(): Promise { + await this.run(Operation.CherryPick, () => this.repository.cherryPickAbort()); + } + + async move(from: string, to: string): Promise { + await this.run(Operation.Move, () => this.repository.move(from, to)); + } + + async getBranch(name: string): Promise { + return await this.run(Operation.GetBranch, () => this.repository.getBranch(name)); + } + + async getBranches(query: BranchQuery = {}, cancellationToken?: CancellationToken): Promise { + return await this.run(Operation.GetBranches, async () => { + const refs = await this.getRefs(query, cancellationToken); + return refs.filter(value => value.type === RefType.Head || (value.type === RefType.RemoteHead && query.remote)); + }); + } + + @sequentialize + async getBranchBase(ref: string): Promise { + const branch = await this.getBranch(ref); + + // Git config + const mergeBaseConfigKey = `branch.${branch.name}.vscode-merge-base`; + + try { + const mergeBase = await this.getConfig(mergeBaseConfigKey); + const branchFromConfig = mergeBase !== '' ? await this.getBranch(mergeBase) : undefined; + + // There was a brief period of time when we would consider local branches as a valid + // merge base. Since then we have fixed the issue and only remote branches can be used + // as a merge base so we are adding an additional check. + if (branchFromConfig && branchFromConfig.remote) { + return branchFromConfig; + } + } catch (err) { } + + // Reflog + const branchFromReflog = await this.getBranchBaseFromReflog(ref); + + let branchFromReflogUpstream: Branch | undefined = undefined; + + if (branchFromReflog?.type === RefType.RemoteHead) { + branchFromReflogUpstream = branchFromReflog; + } else if (branchFromReflog?.type === RefType.Head) { + branchFromReflogUpstream = await this.getUpstreamBranch(branchFromReflog); + } + + if (branchFromReflogUpstream) { + await this.setConfig(mergeBaseConfigKey, `${branchFromReflogUpstream.remote}/${branchFromReflogUpstream.name}`); + return branchFromReflogUpstream; + } + + // Default branch + const defaultBranch = await this.getDefaultBranch(); + if (defaultBranch) { + await this.setConfig(mergeBaseConfigKey, `${defaultBranch.remote}/${defaultBranch.name}`); + return defaultBranch; + } + + return undefined; + } + + private async getBranchBaseFromReflog(ref: string): Promise { + try { + const reflogEntries = await this.repository.reflog(ref, 'branch: Created from *.'); + if (reflogEntries.length !== 1) { + return undefined; + } + + // Branch created from an explicit branch + const match = reflogEntries[0].match(/branch: Created from (?.*)$/); + if (match && match.length === 2 && match[1] !== 'HEAD') { + return await this.getBranch(match[1]); + } + + // Branch created from HEAD + const headReflogEntries = await this.repository.reflog('HEAD', `checkout: moving from .* to ${ref.replace('refs/heads/', '')}`); + if (headReflogEntries.length === 0) { + return undefined; + } + + const match2 = headReflogEntries[headReflogEntries.length - 1].match(/checkout: moving from ([^\s]+)\s/); + if (match2 && match2.length === 2) { + return await this.getBranch(match2[1]); + } + + } + catch (err) { } + + return undefined; + } + + private async getDefaultBranch(): Promise { + const defaultRemote = this.getDefaultRemote(); + if (!defaultRemote) { + return undefined; + } + + try { + const defaultBranch = await this.repository.getDefaultBranch(defaultRemote.name); + return defaultBranch; + } + catch (err) { + this.logger.warn(`[Repository][getDefaultBranch] Failed to get default branch details: ${err.message}.`); + return undefined; + } + } + + private async getUpstreamBranch(branch: Branch): Promise { + if (!branch.upstream) { + return undefined; + } + + try { + const upstreamBranch = await this.getBranch(`refs/remotes/${branch.upstream.remote}/${branch.upstream.name}`); + return upstreamBranch; + } + catch (err) { + this.logger.warn(`[Repository][getUpstreamBranch] Failed to get branch details for 'refs/remotes/${branch.upstream.remote}/${branch.upstream.name}': ${err.message}.`); + return undefined; + } + } + + async getRefs(query: RefQuery = {}, cancellationToken?: CancellationToken): Promise<(Ref | Branch)[]> { + const config = workspace.getConfiguration('git'); + let defaultSort = config.get<'alphabetically' | 'committerdate'>('branchSortOrder'); + if (defaultSort !== 'alphabetically' && defaultSort !== 'committerdate') { + defaultSort = 'alphabetically'; + } + + query = { ...query, sort: query?.sort ?? defaultSort }; + return await this.run(Operation.GetRefs, () => this.repository.getRefs(query, cancellationToken)); + } + + async getWorktrees(): Promise { + return await this.run(Operation.Worktree(true), () => this.repository.getWorktrees()); + } + + async getWorktreeDetails(): Promise { + return this.run(Operation.Worktree(true), async () => { + const worktrees = await this.repository.getWorktrees(); + if (worktrees.length === 0) { + return []; + } + + // Get refs for worktrees that point to a ref + const worktreeRefs = worktrees + .filter(worktree => !worktree.detached) + .map(worktree => worktree.ref); + + // Get the commit details for worktrees that point to a ref + const refs = await this.getRefs({ pattern: worktreeRefs, includeCommitDetails: true }); + + // Get the commit details for detached worktrees + const commits = await Promise.all(worktrees + .filter(worktree => worktree.detached) + .map(worktree => this.repository.getCommit(worktree.ref))); + + return worktrees.map(worktree => { + const commitDetails = worktree.detached + ? commits.find(commit => commit.hash === worktree.ref) + : refs.find(ref => `refs/heads/${ref.name}` === worktree.ref)?.commitDetails; + + return { ...worktree, commitDetails } satisfies Worktree; + }); + }); + } + + async getRemoteRefs(remote: string, opts?: { heads?: boolean; tags?: boolean }): Promise { + return await this.run(Operation.GetRemoteRefs, () => this.repository.getRemoteRefs(remote, opts)); + } + + async setBranchUpstream(name: string, upstream: string): Promise { + await this.run(Operation.SetBranchUpstream, () => this.repository.setBranchUpstream(name, upstream)); + } + + async merge(ref: string): Promise { + await this.run(Operation.Merge, () => this.repository.merge(ref)); + } + + async mergeAbort(): Promise { + await this.run(Operation.MergeAbort, async () => await this.repository.mergeAbort()); + } + + async rebase(branch: string, options?: { onto?: string; rebaseMerges?: boolean }): Promise { + await this.run(Operation.Rebase, () => this.repository.rebase(branch, options)); + } + + async tag(options: { name: string; message?: string; ref?: string }): Promise { + await this.run(Operation.Tag, () => this.repository.tag(options)); + } + + async deleteTag(name: string): Promise { + await this.run(Operation.DeleteTag, () => this.repository.deleteTag(name)); + } + + async createWorktree(options?: { path?: string; commitish?: string; branch?: string; noTrack?: boolean }): Promise { + const defaultWorktreeRoot = this.globalState.get(`${Repository.WORKTREE_ROOT_STORAGE_KEY}:${this.root}`); + const config = workspace.getConfiguration('git', Uri.file(this.root)); + const branchPrefix = config.get('branchPrefix', ''); + + return await this.run(Operation.Worktree(false), async () => { + let worktreeName: string | undefined; + let { path: worktreePath, commitish, branch, noTrack } = options || {}; + + // Create worktree path based on the branch name + if (worktreePath === undefined && branch !== undefined) { + worktreeName = branch.startsWith(branchPrefix) + ? branch.substring(branchPrefix.length).replace(/\//g, '-') + : branch.replace(/\//g, '-'); + + worktreePath = defaultWorktreeRoot + ? path.join(defaultWorktreeRoot, worktreeName) + : path.join(path.dirname(this.root), `${path.basename(this.root)}.worktrees`, worktreeName); + } + + // Ensure that the worktree path is unique + if (this.worktrees.some(worktree => pathEquals(path.normalize(worktree.path), path.normalize(worktreePath!)))) { + let counter = 0, uniqueWorktreePath: string; + do { + uniqueWorktreePath = `${worktreePath}-${++counter}`; + } while (this.worktrees.some(wt => pathEquals(path.normalize(wt.path), path.normalize(uniqueWorktreePath)))); + + worktreePath = uniqueWorktreePath; + } + + // Create the worktree + await this.repository.addWorktree({ path: worktreePath!, commitish: commitish ?? 'HEAD', branch, noTrack }); + + // Update worktree root in global state + const newWorktreeRoot = path.dirname(worktreePath!); + if (defaultWorktreeRoot && !pathEquals(newWorktreeRoot, defaultWorktreeRoot)) { + this.globalState.update(`${Repository.WORKTREE_ROOT_STORAGE_KEY}:${this.root}`, newWorktreeRoot); + } + + // Worktree setup is best effort and must not delay or fail creation. + this._setupWorktree(worktreePath!).then(undefined, err => { + this.logger.warn(`[Repository][createWorktree] Failed to set up worktree '${worktreePath}': ${err}`); + }); + + return worktreePath!; + }); + } + + private async _setupWorktree(worktreePath: string): Promise { + // Set up shared and copied worktree files before running any + // worktree-created tasks. + const symlinkFolders = await this._symlinkWorktreeFolders(worktreePath); + await this._copyWorktreeIncludeFiles(worktreePath, symlinkFolders); + await this._runWorktreeCreatedTasks(worktreePath); + } + + private async _symlinkWorktreeFolders(worktreePath: string): Promise { + try { + const directories = await this._getWorktreeSymlinkFolders(); + if (directories.length === 0) { + return []; + } + + const startTime = performance.now(); + const statuses = new Map(); + const createdDirectories: string[] = []; + const errors: { directory: string; error: string }[] = []; + + for (const directory of directories) { + try { + const status = await createWorktreeSymlink(this.root, worktreePath, directory); + statuses.set(status, (statuses.get(status) ?? 0) + 1); + if (status === 'created') { + createdDirectories.push(directory); + } + } catch (err) { + errors.push({ directory, error: String(err) }); + } + } + + const created = statuses.get('created') ?? 0; + this.logger.info(`[Repository][_symlinkWorktreeFolders] Symlinked ${created}/${directories.length} folder(s) to worktree. [${(performance.now() - startTime).toFixed(2)}ms]`); + + const skippedStatuses: WorktreeSymlinkStatus[] = ['sourceContainsWorktree', 'targetExists']; + for (const status of skippedStatuses) { + const count = statuses.get(status) ?? 0; + if (count > 0) { + this.logger.info(`[Repository][_symlinkWorktreeFolders] Skipped ${count} folder(s) (${status}).`); + } + } + + if (errors.length > 0) { + window.showWarningMessage(l10n.t('Failed to create {0} worktree folder symlink(s).', errors.length)); + + this.logger.warn(`[Repository][_symlinkWorktreeFolders] Failed to create ${errors.length} worktree folder symlink(s).`); + for (const error of errors) { + this.logger.warn(` - ${error.directory}: ${error.error}`); + } + } + return createdDirectories; + } catch (err) { + this.logger.warn(`[Repository][_symlinkWorktreeFolders] Failed to symlink folders to worktree: ${err}`); + return []; + } + } + + private async _getWorktreeSymlinkFolders(): Promise { + const config = workspace.getConfiguration('git', Uri.file(this.root)); + const worktreeSymlinkFolders = config.get('worktreeSymlinkFolders', []); + + const patterns = sanitizeWorktreeIncludePatterns(worktreeSymlinkFolders); + if (patterns.length !== worktreeSymlinkFolders.length) { + this.logger.warn(`[Repository][_getWorktreeSymlinkFolders] Ignoring ${worktreeSymlinkFolders.length - patterns.length} pattern(s) containing line breaks.`); + } + if (patterns.length === 0) { + return []; + } + + const tempDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'vscode-git-worktree-symlink-')); + const patternsFile = path.join(tempDir, 'patterns'); + + try { + await fsPromises.writeFile(patternsFile, patterns.join('\n') + '\n', 'utf8'); + + const tryExec = async (cwd: string, args: string[], input?: string, allowNoMatches = false): Promise => { + try { + return (await this.repository.git.exec(cwd, args, { input })).stdout; + } catch (err) { + if (allowNoMatches && err instanceof GitError && err.exitCode === 1) { + return ''; + } + this.logger.warn(`[Repository][_getWorktreeSymlinkFolders] Failed to execute 'git ${args.join(' ')}': ${err}`); + return undefined; + } + }; + + const baseArgs = ['ls-files', '--others', '--ignored', '-z']; + const [ignoredOutput, matchedOutput, directoryOutput] = await Promise.all([ + tryExec(this.root, [...baseArgs, '--exclude-standard']), + tryExec(this.root, [...baseArgs, `--exclude-from=${patternsFile}`]), + tryExec(this.root, [...baseArgs, '--exclude-standard', '--directory']) + ]); + if (ignoredOutput === undefined || matchedOutput === undefined || directoryOutput === undefined) { + return []; + } + + const candidates = getWorktreeSymlinkFolderCandidates(ignoredOutput, matchedOutput); + if (candidates.length === 0) { + return []; + } + + const matcherRoot = path.join(tempDir, 'matcher'); + await fsPromises.mkdir(matcherRoot); + if (await tryExec(matcherRoot, ['init', '--quiet']) === undefined) { + return []; + } + + const candidateInput = candidates.map(candidate => `${candidate}/`).join('\0'); + const [ignoredDirectoriesOutput, matchedDirectoriesOutput] = await Promise.all([ + tryExec(this.root, ['check-ignore', '--no-index', '-z', '--stdin'], candidateInput, true), + tryExec(matcherRoot, ['-c', `core.excludesFile=${patternsFile}`, 'check-ignore', '--no-index', '-z', '--stdin'], candidateInput, true) + ]); + if (ignoredDirectoriesOutput === undefined || matchedDirectoriesOutput === undefined) { + return []; + } + + return filterWorktreeSymlinkFolders(candidates, ignoredDirectoriesOutput, matchedDirectoriesOutput, directoryOutput); + } finally { + try { + await fsPromises.rm(tempDir, { recursive: true, force: true }); + } catch { + // best-effort + } + } + } + + private async _runWorktreeCreatedTasks(worktreePath: string): Promise { + try { + const allTasks = await tasks.fetchTasks(); + const worktreeTasks = allTasks.filter(task => task.runOptions.runOn === TaskRunOn.WorktreeCreated); + + for (const task of worktreeTasks) { + const worktreeTask = retargetTaskToWorktree(task, worktreePath); + if (!worktreeTask) { + this.logger.warn(`[Repository][_runWorktreeCreatedTasks] Skipped task '${task.name}' because it could not be retargeted to worktree '${worktreePath}'.`); + continue; + } + + tasks.executeTask(worktreeTask).then(undefined, err => { + this.logger.warn(`[Repository][_runWorktreeCreatedTasks] Failed to execute worktree-created task '${task.name}' for '${worktreePath}': ${err}`); + }); + } + } catch (err) { + this.logger.warn(`[Repository][_runWorktreeCreatedTasks] Failed to execute worktree-created tasks for '${worktreePath}': ${err}`); + } + } + + /** + * Resolves the git-ignored paths to copy into a worktree. The + * `git.worktreeIncludeFiles` patterns are matched by git using + * `.gitignore` semantics. + */ + private async _getWorktreeIncludePaths(worktreePath: string, excludedFolders: readonly string[]): Promise { + const config = workspace.getConfiguration('git', Uri.file(this.root)); + const worktreeIncludeFiles = config.get('worktreeIncludeFiles', []); + + const includePatterns = sanitizeWorktreeIncludePatterns(worktreeIncludeFiles); + if (includePatterns.length !== worktreeIncludeFiles.length) { + this.logger.warn(`[Repository][_getWorktreeIncludePaths] Ignoring ${worktreeIncludeFiles.length - includePatterns.length} pattern(s) containing line breaks.`); + } + if (includePatterns.length === 0) { + return []; + } + + // Git reads the patterns from a file so that they are parsed exactly + // like a `.gitignore` file (comments, blank lines, trailing spaces). + const tempDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'vscode-git-worktree-include-')); + const includePatternsFile = path.join(tempDir, 'patterns'); + + try { + await fsPromises.writeFile(includePatternsFile, includePatterns.join('\n') + '\n', 'utf8'); + + const tryExec = async (cwd: string, args: string[]): Promise => { + try { + return (await this.repository.git.exec(cwd, args)).stdout; + } catch (err) { + this.logger.warn(`[Repository][_getWorktreeIncludePaths] Failed to execute 'git ${args.join(' ')}': ${err}`); + return undefined; + } + }; + + // List the git-ignored (but untracked) files: `--others` selects + // untracked files, `--ignored` restricts to those matched by an exclude + // source, and `--exclude-standard` uses the standard sources (.gitignore, + // .git/info/exclude, core.excludesFile). `-z` NUL-separates entries so + // paths containing spaces or other special characters survive intact. + // + // The `--directory` variant additionally collapses a *wholly*-ignored + // directory (one containing no tracked files) into a single `dir/` + // entry. It is used to copy such directories as one recursive unit + // rather than file-by-file. + // + // The `--exclude-from` variant uses *only* the include patterns as the + // exclude source (no standard sources), so it lists the untracked files + // matching `git.worktreeIncludeFiles`. Passing both sources to a single + // invocation would yield their union, hence the separate call. + const baseArgs = ['ls-files', '--others', '--ignored', '--exclude-standard', '-z']; + const [ignoredOutput, directoryOutput, includedOutput, worktreeOutput] = await Promise.all([ + tryExec(this.root, baseArgs), + tryExec(this.root, [...baseArgs, '--directory', '--no-empty-directory']), + tryExec(this.root, ['ls-files', '--others', '--ignored', `--exclude-from=${includePatternsFile}`, '-z']), + tryExec(worktreePath, ['ls-files', '-z']) + ]); + if (ignoredOutput === undefined || includedOutput === undefined || worktreeOutput === undefined) { + return []; + } + + return resolveWorktreeIncludePaths(ignoredOutput, includedOutput, directoryOutput, worktreeOutput, excludedFolders); + } finally { + try { + await fsPromises.rm(tempDir, { recursive: true, force: true }); + } catch { + // best-effort + } + } + } + + private async _copyWorktreeIncludeFiles(worktreePath: string, excludedFolders: readonly string[]): Promise { + try { + const files = await this._getWorktreeIncludePaths(worktreePath, excludedFolders); + if (files.length === 0) { + return; + } + + const startTime = performance.now(); + const limiter = new Limiter(15); + + // Copy files and folders + const results = await Promise.allSettled(files.map(file => { + return limiter.queue(async () => { + const sourcePath = path.join(this.root, file); + const targetPath = path.join(worktreePath, file); + + try { + await fsPromises.lstat(targetPath); + return false; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + throw err; + } + } + + await fsPromises.mkdir(path.dirname(targetPath), { recursive: true }); + await cp(sourcePath, targetPath, { force: true, recursive: true, verbatimSymlinks: true }); + return true; + }); + })); + + // Log any failed operations + const failedOperations = results.filter(r => r.status === 'rejected'); + const copiedFiles = results.filter(r => r.status === 'fulfilled' && r.value).length; + this.logger.info(`[Repository][_copyWorktreeIncludeFiles] Copied ${copiedFiles}/${files.length} folder(s)/file(s) to worktree. [${(performance.now() - startTime).toFixed(2)}ms]`); + + if (failedOperations.length > 0) { + window.showWarningMessage(l10n.t('Failed to copy {0} folder(s)/file(s) to the worktree.', failedOperations.length)); + + this.logger.warn(`[Repository][_copyWorktreeIncludeFiles] Failed to copy ${failedOperations.length} folder(s)/file(s) to worktree.`); + for (const error of failedOperations) { + this.logger.warn(` - ${(error as PromiseRejectedResult).reason}`); + } + } + } catch (err) { + this.logger.warn(`[Repository][_copyWorktreeIncludeFiles] Failed to copy folder(s)/file(s) to worktree: ${err}`); + } + } + + async deleteWorktree(path: string, options?: { force?: boolean; label?: string }): Promise { + await this.run(Operation.Worktree(false), async () => { + const worktree = this.repositoryResolver.getRepository(path); + + const deleteWorktree = async (options?: { force?: boolean }): Promise => { + await this.repository.deleteWorktree(path, options); + worktree?.dispose(); + }; + + try { + await deleteWorktree(options); + } catch (err) { + if (err.gitErrorCode === GitErrorCodes.WorktreeContainsChanges) { + const forceDelete = l10n.t('Force Delete'); + const message = options?.label + ? l10n.t('The worktree for session "{0}" contains modified or untracked files. Do you want to force delete?', options.label) + : l10n.t('The worktree contains modified or untracked files. Do you want to force delete?'); + const choice = await window.showWarningMessage(message, { modal: true, detail: l10n.t('Worktree: {0}', path) }, forceDelete); + if (choice === forceDelete) { + await deleteWorktree({ ...options, force: true }); + } + return; + } + + throw err; + } + }); + } + + async deleteRemoteRef(remoteName: string, refName: string, options?: { force?: boolean }): Promise { + await this.run(Operation.DeleteRemoteRef, () => this.repository.deleteRemoteRef(remoteName, refName, options)); + } + + async checkout(treeish: string, opts?: { detached?: boolean; pullBeforeCheckout?: boolean }): Promise { + const refLabel = opts?.detached ? getCommitShortHash(Uri.file(this.root), treeish) : treeish; + + await this.run(Operation.Checkout(refLabel), + async () => { + if (opts?.pullBeforeCheckout && !opts?.detached) { + try { + await this.fastForwardBranch(treeish); + } + catch (err) { + // noop + } + } + + await this.repository.checkout(treeish, [], opts); + + // Clear all AI contribution tracking on branch switch + commands.executeCommand('_aiEdits.clearAllAiContributions'); + }); + } + + async checkoutTracking(treeish: string, opts: { detached?: boolean } = {}): Promise { + const refLabel = opts.detached ? getCommitShortHash(Uri.file(this.root), treeish) : treeish; + await this.run(Operation.CheckoutTracking(refLabel), async () => { + await this.repository.checkout(treeish, [], { ...opts, track: true }); + + // Clear all AI contribution tracking on branch switch + commands.executeCommand('_aiEdits.clearAllAiContributions'); + }); + } + + async findTrackingBranches(upstreamRef: string): Promise { + return await this.run(Operation.FindTrackingBranches, () => this.repository.findTrackingBranches(upstreamRef)); + } + + async getCommit(ref: string): Promise { + return await this.repository.getCommit(ref); + } + + async showChanges(ref: string): Promise { + return await this.run(Operation.Log(false), () => this.repository.showChanges(ref)); + } + + async showChangesBetween(ref1: string, ref2: string, path?: string): Promise { + return await this.run(Operation.Log(false), () => this.repository.showChangesBetween(ref1, ref2, path)); + } + + async getEmptyTree(): Promise { + if (!this._EMPTY_TREE) { + const result = await this.repository.exec(['hash-object', '-t', 'tree', '/dev/null']); + this._EMPTY_TREE = result.stdout.trim(); + } + + return this._EMPTY_TREE; + } + + async reset(treeish: string, hard?: boolean): Promise { + await this.run(Operation.Reset, async () => { + await this.repository.reset(treeish, hard); + + if (hard) { + // Clear all AI contribution tracking on hard reset + commands.executeCommand('_aiEdits.clearAllAiContributions'); + } + }); + } + + async resetKeep(ref: string): Promise { + await this.run(Operation.Reset, () => this.repository.resetKeep(ref)); + } + + async updateRef(ref: string, newSha: string, oldSha: string): Promise { + await this.run(Operation.Branch, () => this.repository.updateRef(ref, newSha, oldSha)); + } + + async deleteRef(ref: string): Promise { + await this.run(Operation.DeleteRef, () => this.repository.deleteRef(ref)); + } + + getDefaultRemote(): Remote | undefined { + if (this.remotes.length === 0) { + return undefined; + } + + return this.remotes.find(r => r.name === 'origin') ?? this.remotes[0]; + } + + async addRemote(name: string, url: string): Promise { + await this.run(Operation.Remote, async () => { + const result = await this.repository.addRemote(name, url); + this.repositoryCache.update(this.remotes, [], this.root); + return result; + }); + } + + async removeRemote(name: string): Promise { + await this.run(Operation.Remote, async () => { + const result = this.repository.removeRemote(name); + const remote = this.remotes.find(remote => remote.name === name); + if (remote) { + this.repositoryCache.update([], [remote], this.root); + } + return result; + }); + + } + + async renameRemote(name: string, newName: string): Promise { + await this.run(Operation.Remote, () => this.repository.renameRemote(name, newName)); + } + + @throttle + async fetchDefault(options: { silent?: boolean } = {}): Promise { + await this._fetch({ silent: options.silent }); + } + + @throttle + async fetchPrune(): Promise { + await this._fetch({ prune: true }); + } + + @throttle + async fetchAll(options: { silent?: boolean } = {}, cancellationToken?: CancellationToken): Promise { + await this._fetch({ all: true, silent: options.silent, cancellationToken }); + } + + async fetch(options: FetchOptions): Promise { + await this._fetch(options); + } + + private async _fetch(options: { remote?: string; ref?: string; all?: boolean; prune?: boolean; depth?: number; silent?: boolean; cancellationToken?: CancellationToken } = {}): Promise { + if (!options.prune) { + const config = workspace.getConfiguration('git', Uri.file(this.root)); + const prune = config.get('pruneOnFetch'); + options.prune = prune; + } + + await this.run(Operation.Fetch(options.silent !== true), async () => this.repository.fetch(options)); + } + + @throttle + async pullWithRebase(head: Branch | undefined): Promise { + let remote: string | undefined; + let branch: string | undefined; + + if (head && head.name && head.upstream) { + remote = head.upstream.remote; + branch = `${head.upstream.name}`; + } + + return this.pullFrom(true, remote, branch); + } + + @throttle + async pull(head?: Branch, unshallow?: boolean): Promise { + let remote: string | undefined; + let branch: string | undefined; + + if (head && head.name && head.upstream) { + remote = head.upstream.remote; + branch = `${head.upstream.name}`; + } + + return this.pullFrom(false, remote, branch, unshallow); + } + + async pullFrom(rebase?: boolean, remote?: string, branch?: string, unshallow?: boolean): Promise { + await this.run(Operation.Pull, async () => { + await this.maybeAutoStash(async () => { + const config = workspace.getConfiguration('git', Uri.file(this.root)); + const autoStash = config.get('autoStash'); + const fetchOnPull = config.get('fetchOnPull'); + const tags = config.get('pullTags'); + + // When fetchOnPull is enabled, fetch all branches when pulling + if (fetchOnPull) { + await this.fetchAll(); + } + + if (await this.checkIfMaybeRebased(this.HEAD?.name)) { + await this._pullAndHandleTagConflict(rebase, remote, branch, { unshallow, tags, autoStash }); + } + }); + }); + } + + private async _pullAndHandleTagConflict(rebase?: boolean, remote?: string, branch?: string, options: PullOptions = {}): Promise { + try { + await this.repository.pull(rebase, remote, branch, options); + } + catch (err) { + if (err.gitErrorCode !== GitErrorCodes.TagConflict) { + throw err; + } + + // Handle tag(s) conflict + if (await this.handleTagConflict(remote, err.stderr)) { + await this.repository.pull(rebase, remote, branch, options); + } + } + } + + @throttle + async push(head: Branch, forcePushMode?: ForcePushMode): Promise { + let remote: string | undefined; + let branch: string | undefined; + + if (head && head.name && head.upstream) { + remote = head.upstream.remote; + branch = `${head.name}:${head.upstream.name}`; + } + + await this.run(Operation.Push, () => this._push(remote, branch, undefined, undefined, forcePushMode)); + } + + async pushTo(remote?: string, name?: string, setUpstream = false, forcePushMode?: ForcePushMode): Promise { + await this.run(Operation.Push, () => this._push(remote, name, setUpstream, undefined, forcePushMode)); + } + + async pushRefWithLease(remote: string, branch: string, newSha: string, expectedSha: string): Promise { + await this.run(Operation.Push, () => this.repository.pushRefWithLease(remote, branch, newSha, expectedSha)); + } + + async pushFollowTags(remote?: string, forcePushMode?: ForcePushMode): Promise { + await this.run(Operation.Push, () => this._push(remote, undefined, false, true, forcePushMode)); + } + + async pushTags(remote?: string, forcePushMode?: ForcePushMode): Promise { + await this.run(Operation.Push, () => this._push(remote, undefined, false, false, forcePushMode, true)); + } + + async blame(path: string): Promise { + return await this.run(Operation.Blame(true), () => this.repository.blame(path)); + } + + async blame2(path: string, ref?: string): Promise { + return await this.run(Operation.Blame(false), () => { + const config = workspace.getConfiguration('git', Uri.file(this.root)); + const ignoreWhitespace = config.get('blame.ignoreWhitespace', false); + return this.repository.blame2(path, ref, ignoreWhitespace); + }); + } + + @throttle + sync(head: Branch, rebase: boolean): Promise { + return this._sync(head, rebase); + } + + private async _sync(head: Branch, rebase: boolean): Promise { + let remoteName: string | undefined; + let pullBranch: string | undefined; + let pushBranch: string | undefined; + + if (head.name && head.upstream) { + remoteName = head.upstream.remote; + pullBranch = `${head.upstream.name}`; + pushBranch = `${head.name}:${head.upstream.name}`; + } + + await this.run(Operation.Sync, async () => { + await this.maybeAutoStash(async () => { + const config = workspace.getConfiguration('git', Uri.file(this.root)); + const autoStash = config.get('autoStash'); + const fetchOnPull = config.get('fetchOnPull'); + const tags = config.get('pullTags'); + const followTags = config.get('followTagsWhenSync'); + const supportCancellation = config.get('supportCancellation'); + + const fn = async (cancellationToken?: CancellationToken) => { + // When fetchOnPull is enabled, fetch all branches when pulling + if (fetchOnPull) { + await this.fetchAll({}, cancellationToken); + } + + if (await this.checkIfMaybeRebased(this.HEAD?.name)) { + await this._pullAndHandleTagConflict(rebase, remoteName, pullBranch, { tags, cancellationToken, autoStash }); + } + }; + + if (supportCancellation) { + const opts: ProgressOptions = { + location: ProgressLocation.Notification, + title: l10n.t('Syncing. Cancelling may cause serious damages to the repository'), + cancellable: true + }; + + await window.withProgress(opts, (_, token) => fn(token)); + } else { + await fn(); + } + + const remote = this.remotes.find(r => r.name === remoteName); + + if (remote && remote.isReadOnly) { + return; + } + + const shouldPush = this.HEAD && (typeof this.HEAD.ahead === 'number' ? this.HEAD.ahead > 0 : true); + + if (shouldPush) { + await this._push(remoteName, pushBranch, false, followTags); + } + }); + }); + } + + private async checkIfMaybeRebased(currentBranch?: string) { + const config = workspace.getConfiguration('git'); + const shouldIgnore = config.get('ignoreRebaseWarning') === true; + + if (shouldIgnore) { + return true; + } + + const maybeRebased = await this.run(Operation.Log(true), async () => { + try { + const result = await this.repository.exec(['log', '--oneline', '--cherry', `${currentBranch ?? ''}...${currentBranch ?? ''}@{upstream}`, '--']); + if (result.exitCode) { + return false; + } + + return /^=/.test(result.stdout); + } catch { + return false; + } + }); + + if (!maybeRebased) { + return true; + } + + const always = { title: l10n.t('Always Pull') }; + const pull = { title: l10n.t('Pull') }; + const cancel = { title: l10n.t('Don\'t Pull') }; + const result = await window.showWarningMessage( + currentBranch + ? l10n.t('It looks like the current branch "{0}" might have been rebased. Are you sure you still want to pull into it?', currentBranch) + : l10n.t('It looks like the current branch might have been rebased. Are you sure you still want to pull into it?'), + always, pull, cancel + ); + + if (result === pull) { + return true; + } + + if (result === always) { + await config.update('ignoreRebaseWarning', true, true); + + return true; + } + + return false; + } + + async show(ref: string, filePath: string): Promise { + return await this.run(Operation.Show, async () => { + try { + const content = await this.repository.buffer(ref, filePath); + return await workspace.decode(content, { uri: Uri.file(filePath) }); + } catch (err) { + if (err.gitErrorCode === GitErrorCodes.WrongCase) { + const gitFilePath = await this.repository.getGitFilePath(ref, filePath); + const content = await this.repository.buffer(ref, gitFilePath); + return await workspace.decode(content, { uri: Uri.file(filePath) }); + } + + throw err; + } + }); + } + + async buffer(ref: string, filePath: string): Promise { + return this.run(Operation.Show, () => this.repository.buffer(ref, filePath)); + } + + getObjectFiles(ref: string): Promise { + return this.run(Operation.GetObjectFiles, () => this.repository.lstree(ref)); + } + + getObjectDetails(ref: string, path: string): Promise<{ mode: string; object: string; size: number }> { + return this.run(Operation.GetObjectDetails, () => this.repository.getObjectDetails(ref, path)); + } + + detectObjectType(object: string): Promise<{ mimetype: string; encoding?: string }> { + return this.run(Operation.Show, () => this.repository.detectObjectType(object)); + } + + async apply(patch: string, options?: { allowEmpty?: boolean; reverse?: boolean; threeWay?: boolean }): Promise { + return await this.run(Operation.Apply, () => this.repository.apply(patch, options)); + } + + async getStashes(): Promise { + return this.run(Operation.Stash(true), () => this.repository.getStashes()); + } + + async createStash(message?: string, includeUntracked?: boolean, staged?: boolean): Promise { + const indexResources = [...this.indexGroup.resourceStates.map(r => r.resourceUri.fsPath)]; + const workingGroupResources = [ + ...!staged ? this.workingTreeGroup.resourceStates.map(r => r.resourceUri.fsPath) : [], + ...includeUntracked ? this.untrackedGroup.resourceStates.map(r => r.resourceUri.fsPath) : []]; + + return await this.run(Operation.Stash(false), async () => { + await this.repository.createStash(message, includeUntracked, staged); + this.closeDiffEditors(indexResources, workingGroupResources); + }); + } + + async popStash(index?: number, options?: { reinstateStagedChanges?: boolean }): Promise { + return await this.run(Operation.Stash(false), () => this.repository.popStash(index, options)); + } + + async dropStash(index?: number): Promise { + return await this.run(Operation.Stash(false), () => this.repository.dropStash(index)); + } + + async applyStash(index?: number, options?: { reinstateStagedChanges?: boolean }): Promise { + return await this.run(Operation.Stash(false), () => this.repository.applyStash(index, options)); + } + + async showStash(index: number): Promise { + return await this.run(Operation.Stash(true), () => this.repository.showStash(index)); + } + + async getCommitTemplate(): Promise { + return await this.run(Operation.GetCommitTemplate, async () => this.repository.getCommitTemplate()); + } + + async ignore(files: Uri[]): Promise { + return await this.run(Operation.Ignore, async () => { + const ignoreFile = `${this.repository.root}${path.sep}.gitignore`; + const textToAppend = files + .map(uri => relativePath(this.repository.root, uri.fsPath) + .replace(/\\|\[/g, match => match === '\\' ? '/' : `\\${match}`)) + .join('\n'); + + const document = await new Promise(c => fs.exists(ignoreFile, c)) + ? await workspace.openTextDocument(ignoreFile) + : await workspace.openTextDocument(Uri.file(ignoreFile).with({ scheme: 'untitled' })); + + await window.showTextDocument(document); + + const edit = new WorkspaceEdit(); + const lastLine = document.lineAt(document.lineCount - 1); + const text = lastLine.isEmptyOrWhitespace ? `${textToAppend}\n` : `\n${textToAppend}\n`; + + edit.insert(document.uri, lastLine.range.end, text); + await workspace.applyEdit(edit); + await document.save(); + }); + } + + async rebaseAbort(): Promise { + await this.run(Operation.RebaseAbort, async () => await this.repository.rebaseAbort()); + } + + checkIgnore(filePaths: string[]): Promise> { + return this.run(Operation.CheckIgnore, () => { + return new Promise>((resolve, reject) => { + + filePaths = filePaths + .filter(filePath => isDescendant(this.root, filePath)); + + if (filePaths.length === 0) { + // nothing left + return resolve(new Set()); + } + + // https://git-scm.com/docs/git-check-ignore#git-check-ignore--z + const child = this.repository.stream(['check-ignore', '-v', '-z', '--stdin'], { stdio: [null, null, null] }); + + if (!child.stdin) { + return reject(new GitError({ + message: 'Failed to spawn git process', + exitCode: -1 + })); + } + + child.stdin.end(filePaths.join('\0'), 'utf8'); + + const onExit = (exitCode: number) => { + if (exitCode === 1) { + // nothing ignored + resolve(new Set()); + } else if (exitCode === 0) { + resolve(new Set(this.parseIgnoreCheck(data))); + } else { + if (/ is in submodule /.test(stderr)) { + reject(new GitError({ stdout: data, stderr, exitCode, gitErrorCode: GitErrorCodes.IsInSubmodule })); + } else { + reject(new GitError({ stdout: data, stderr, exitCode })); + } + } + }; + + let data = ''; + const onStdoutData = (raw: string) => { + data += raw; + }; + + if (child.stdout) { + child.stdout.setEncoding('utf8'); + child.stdout.on('data', onStdoutData); + } + + let stderr: string = ''; + if (child.stderr) { + child.stderr.setEncoding('utf8'); + child.stderr.on('data', raw => stderr += raw); + } + + child.on('error', reject); + child.on('exit', onExit); + }); + }); + } + + // Parses output of `git check-ignore -v -z` and returns only those paths + // that are actually ignored by git. + // Matches to a negative pattern (starting with '!') are filtered out. + // See also https://git-scm.com/docs/git-check-ignore#_output. + private parseIgnoreCheck(raw: string): string[] { + const ignored = []; + const elements = raw.split('\0'); + for (let i = 0; i < elements.length; i += 4) { + const pattern = elements[i + 2]; + const path = elements[i + 3]; + if (pattern && !pattern.startsWith('!')) { + ignored.push(path); + } + } + return ignored; + } + + private async _push(remote?: string, refspec?: string, setUpstream = false, followTags = false, forcePushMode?: ForcePushMode, tags = false): Promise { + try { + await this.repository.push(remote, refspec, setUpstream, followTags, forcePushMode, tags); + } catch (err) { + if (!remote || !refspec) { + throw err; + } + + const repository = new ApiRepository(this); + const remoteObj = repository.state.remotes.find(r => r.name === remote); + + if (!remoteObj) { + throw err; + } + + for (const handler of this.pushErrorHandlerRegistry.getPushErrorHandlers()) { + if (await handler.handlePushError(repository, remoteObj, refspec, err)) { + return; + } + } + + throw err; + } + } + + private async run( + operation: Operation, + runOperation: () => Promise = () => Promise.resolve(null) as Promise, + getOptimisticResourceGroups: () => GitResourceGroups | undefined = () => undefined + ): Promise { + + if (this.state !== RepositoryState.Idle) { + throw new Error('Repository not initialized'); + } + + let error: unknown = null; + + this._operations.start(operation); + this._onRunOperation.fire(operation.kind); + + try { + const result = await this.retryRun(operation, runOperation); + + if (!operation.readOnly) { + await this.updateModelState(this.optimisticUpdateEnabled() ? getOptimisticResourceGroups() : undefined); + } + + return result; + } catch (err) { + error = err; + + if (err instanceof GitError && err.gitErrorCode === GitErrorCodes.NotAGitRepository) { + this.state = RepositoryState.Disposed; + } + + if (!operation.readOnly) { + await this.updateModelState(); + } + + throw err; + } finally { + this._operations.end(operation); + this._onDidRunOperation.fire({ operation: operation, error }); + } + } + + async migrateChanges(sourceRepositoryRoot: string, options?: { confirmation?: boolean; deleteFromSource?: boolean; untracked?: boolean }): Promise { + const sourceRepository = this.repositoryResolver.getRepository(sourceRepositoryRoot); + if (!sourceRepository) { + window.showWarningMessage(l10n.t('The source repository could not be found.')); + return; + } + + if (sourceRepository.indexGroup.resourceStates.length === 0 && + sourceRepository.workingTreeGroup.resourceStates.length === 0 && + sourceRepository.untrackedGroup.resourceStates.length === 0) { + await window.showInformationMessage(l10n.t('There are no changes in the selected worktree to migrate.')); + return; + } + + const sourceFilePaths = [ + ...sourceRepository.indexGroup.resourceStates, + ...sourceRepository.workingTreeGroup.resourceStates, + ...sourceRepository.untrackedGroup.resourceStates + ].map(resource => path.relative(sourceRepository.root, resource.resourceUri.fsPath)); + + const targetFilePaths = [ + ...this.workingTreeGroup.resourceStates, + ...this.untrackedGroup.resourceStates + ].map(resource => path.relative(this.root, resource.resourceUri.fsPath)); + + // Detect overlapping unstaged files in worktree stash and target repository + const conflicts = sourceFilePaths.filter(path => targetFilePaths.includes(path)); + + if (conflicts.length > 0) { + const maxFilesShown = 5; + const filesToShow = conflicts.slice(0, maxFilesShown); + const remainingCount = conflicts.length - maxFilesShown; + + const fileList = filesToShow.join('\n ') + + (remainingCount > 0 ? l10n.t('\n and {0} more file{1}...', remainingCount, remainingCount > 1 ? 's' : '') : ''); + + const message = l10n.t('Your local changes to the following files would be overwritten by merge:\n {0}\n\nPlease stage, commit, or stash your changes in the repository before migrating changes.', fileList); + await window.showErrorMessage(message, { modal: true }); + return; + } + + if (options?.confirmation) { + // Non-interactive migration, do not show confirmation dialog + const message = l10n.t('Proceed with migrating changes to the current repository?'); + const detail = l10n.t('This will apply the worktree\'s changes to this repository and discard changes in the worktree.\nThis is IRREVERSIBLE!'); + const proceed = l10n.t('Proceed'); + const pick = await window.showWarningMessage(message, { modal: true, detail }, proceed); + if (pick !== proceed) { + return; + } + } + + const stashName = `migration:${sourceRepository.HEAD?.name ?? sourceRepository.HEAD?.commit}-${this.HEAD?.name ?? this.HEAD?.commit}`; + await sourceRepository.createStash(stashName, options?.untracked); + const stashes = await sourceRepository.getStashes(); + + try { + if (options?.deleteFromSource) { + await this.popStash(stashes[0].index); + } else { + await this.applyStash(stashes[0].index); + await sourceRepository.popStash(stashes[0].index, { reinstateStagedChanges: true }); + } + } catch (err) { + if (err.gitErrorCode === GitErrorCodes.StashConflict) { + this.isWorktreeMigrating = true; + + const message = l10n.t('There are merge conflicts from migrating changes. Please resolve them before committing.'); + const show = l10n.t('Show Changes'); + const choice = await window.showWarningMessage(message, show); + if (choice === show) { + await commands.executeCommand('workbench.view.scm'); + } + + await sourceRepository.popStash(stashes[0].index, { reinstateStagedChanges: true }); + return; + } + + await sourceRepository.popStash(stashes[0].index, { reinstateStagedChanges: true }); + throw err; + } + } + + private async retryRun(operation: Operation, runOperation: () => Promise): Promise { + let attempt = 0; + + while (true) { + try { + attempt++; + return await runOperation(); + } catch (err) { + const shouldRetry = attempt <= 10 && ( + (err.gitErrorCode === GitErrorCodes.RepositoryIsLocked) + || (operation.retry && (err.gitErrorCode === GitErrorCodes.CantLockRef || err.gitErrorCode === GitErrorCodes.CantRebaseMultipleBranches)) + ); + + if (shouldRetry) { + // quatratic backoff + await timeout(Math.pow(attempt, 2) * 50); + } else { + throw err; + } + } + } + } + + private static KnownHugeFolderNames = ['node_modules']; + + private async findKnownHugeFolderPathsToIgnore(): Promise { + const folderPaths: string[] = []; + + for (const folderName of Repository.KnownHugeFolderNames) { + const folderPath = path.join(this.repository.root, folderName); + + if (await new Promise(c => fs.exists(folderPath, c))) { + folderPaths.push(folderPath); + } + } + + const ignored = await this.checkIgnore(folderPaths); + + return folderPaths.filter(p => !ignored.has(p)); + } + + private async updateModelState(optimisticResourcesGroups?: GitResourceGroups) { + this.updateModelStateCancellationTokenSource?.cancel(); + + this.updateModelStateCancellationTokenSource = new CancellationTokenSource(); + await this._updateModelState(optimisticResourcesGroups, this.updateModelStateCancellationTokenSource.token); + } + + private async _updateModelState(optimisticResourcesGroups?: GitResourceGroups, cancellationToken?: CancellationToken): Promise { + try { + // Optimistically update resource groups + if (optimisticResourcesGroups) { + this._updateResourceGroupsState(optimisticResourcesGroups); + } + + const [HEAD, remotes, submodules, worktrees, rebaseCommit, mergeInProgress, cherryPickInProgress, commitTemplate] = + await Promise.all([ + this.repository.getHEADRef(), + this.repository.getRemotes(), + this.repository.getSubmodules(), + this.repository.getWorktrees(), + this.getRebaseCommit(), + this.isMergeInProgress(), + this.isCherryPickInProgress(), + this.getInputTemplate(), + this.initIsUsingVirtualFileSystem()]); + + // Reset the list of unpublished commits if HEAD has + // changed (ex: checkout, fetch, pull, push, publish, etc.). + // The list of unpublished commits will be computed lazily + // on demand. + if (this.HEAD?.name !== HEAD?.name || + this.HEAD?.commit !== HEAD?.commit || + this.HEAD?.ahead !== HEAD?.ahead || + this.HEAD?.upstream !== HEAD?.upstream) { + this.unpublishedCommits = undefined; + } + + this._HEAD = HEAD; + this._remotes = remotes!; + this._submodules = submodules!; + this._worktrees = worktrees!; + this.rebaseCommit = rebaseCommit; + this.mergeInProgress = mergeInProgress; + this.cherryPickInProgress = cherryPickInProgress; + + this._sourceControl.commitTemplate = commitTemplate; + + // Execute cancellable long-running operation + const [resourceGroups, refs] = + await Promise.all([ + this.getStatus(cancellationToken), + this.getRefs({}, cancellationToken)]); + + this._refs = refs; + this._updateResourceGroupsState(resourceGroups); + + this._onDidChangeStatus.fire(); + } + catch (err) { + if (err instanceof CancellationError) { + return; + } + + throw err; + } + } + + private _updateResourceGroupsState(resourcesGroups: GitResourceGroups): void { + // set resource groups + if (resourcesGroups.indexGroup) { this.indexGroup.resourceStates = resourcesGroups.indexGroup; } + if (resourcesGroups.mergeGroup) { this.mergeGroup.resourceStates = resourcesGroups.mergeGroup; } + if (resourcesGroups.untrackedGroup) { this.untrackedGroup.resourceStates = resourcesGroups.untrackedGroup; } + if (resourcesGroups.workingTreeGroup) { this.workingTreeGroup.resourceStates = resourcesGroups.workingTreeGroup; } + + // clear worktree migrating flag once all conflicts are resolved + if (this._isWorktreeMigrating && resourcesGroups.mergeGroup && resourcesGroups.mergeGroup.length === 0) { + this._isWorktreeMigrating = false; + } + + // set count badge + this.setCountBadge(); + } + + private async getStatus(cancellationToken?: CancellationToken): Promise { + if (cancellationToken && cancellationToken.isCancellationRequested) { + throw new CancellationError(); + } + + const scopedConfig = workspace.getConfiguration('git', Uri.file(this.repository.root)); + const untrackedChanges = scopedConfig.get<'mixed' | 'separate' | 'hidden'>('untrackedChanges'); + const ignoreSubmodules = scopedConfig.get('ignoreSubmodules'); + + const limit = scopedConfig.get('statusLimit', 10000); + const similarityThreshold = scopedConfig.get('similarityThreshold', 50); + + const start = new Date().getTime(); + const { status, statusLength, didHitLimit } = await this.repository.getStatus({ limit, ignoreSubmodules, similarityThreshold, untrackedChanges, cancellationToken }); + const totalTime = new Date().getTime() - start; + + this.isRepositoryHuge = didHitLimit ? { limit } : false; + + if (didHitLimit) { + /* __GDPR__ + "statusLimit" : { + "owner": "lszomoru", + "ignoreSubmodules": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Setting indicating whether submodules are ignored" }, + "limit": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Setting indicating the limit of status entries" }, + "statusLength": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Total number of status entries" }, + "totalTime": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Total number of ms the operation took" } + } + */ + this.telemetryReporter.sendTelemetryEvent('statusLimit', { ignoreSubmodules: String(ignoreSubmodules) }, { limit, statusLength, totalTime }); + } + + if (totalTime > 5000) { + /* __GDPR__ + "statusSlow" : { + "owner": "digitarald", + "comment": "Reports when git status is slower than 5s", + "expiration": "1.73", + "ignoreSubmodules": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Setting indicating whether submodules are ignored" }, + "didHitLimit": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Total number of status entries" }, + "didWarnAboutLimit": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "True when the user was warned about slow git status" }, + "statusLength": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Total number of status entries" }, + "totalTime": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Total number of ms the operation took" } + } + */ + this.telemetryReporter.sendTelemetryEvent('statusSlow', { ignoreSubmodules: String(ignoreSubmodules), didHitLimit: String(didHitLimit), didWarnAboutLimit: String(this.didWarnAboutLimit) }, { statusLength, totalTime }); + } + + // Triggers or clears any validation warning + this._sourceControl.inputBox.validateInput = this._sourceControl.inputBox.validateInput; + + const config = workspace.getConfiguration('git'); + const shouldIgnore = config.get('ignoreLimitWarning') === true; + const useIcons = !config.get('decorations.enabled', true); + + if (didHitLimit && !shouldIgnore && !this.didWarnAboutLimit) { + const knownHugeFolderPaths = await this.findKnownHugeFolderPathsToIgnore(); + const gitWarn = getSafeNotificationMessage( + l10n.t('The git repository at "{0}" has too many active changes, only a subset of Git features will be enabled.', this.repository.root), + l10n.t('The git repository has too many active changes, only a subset of Git features will be enabled.'), + ); + const neverAgain = { title: l10n.t('Don\'t Show Again') }; + + if (knownHugeFolderPaths.length > 0) { + const folderPath = knownHugeFolderPaths[0]; + const folderName = path.basename(folderPath); + + const addKnown = getSafeNotificationMessage( + l10n.t('Would you like to add "{0}" to .gitignore?', folderName), + l10n.t('Would you like to add the large folder to .gitignore?'), + ); + const yes = { title: l10n.t('Yes') }; + const no = { title: l10n.t('No') }; + + window.showWarningMessage(`${gitWarn} ${addKnown}`, yes, no, neverAgain).then(result => { + if (result === yes) { + this.ignore([Uri.file(folderPath)]); + } else { + if (result === neverAgain) { + config.update('ignoreLimitWarning', true, false); + } + + this.didWarnAboutLimit = true; + } + }); + } else { + const ok = { title: l10n.t('OK') }; + window.showWarningMessage(gitWarn, ok, neverAgain).then(result => { + if (result === neverAgain) { + config.update('ignoreLimitWarning', true, false); + } + + this.didWarnAboutLimit = true; + }); + } + } + + const indexGroup: Resource[] = [], + mergeGroup: Resource[] = [], + untrackedGroup: Resource[] = [], + workingTreeGroup: Resource[] = []; + + status.forEach(raw => { + const uri = Uri.file(path.join(this.repository.root, raw.path)); + const renameUri = raw.rename + ? Uri.file(path.join(this.repository.root, raw.rename)) + : undefined; + + switch (raw.x + raw.y) { + case '??': switch (untrackedChanges) { + case 'mixed': return workingTreeGroup.push(new Resource(this.resourceCommandResolver, ResourceGroupType.WorkingTree, uri, Status.UNTRACKED, useIcons, undefined, this.kind)); + case 'separate': return untrackedGroup.push(new Resource(this.resourceCommandResolver, ResourceGroupType.Untracked, uri, Status.UNTRACKED, useIcons)); + default: return undefined; + } + case '!!': switch (untrackedChanges) { + case 'mixed': return workingTreeGroup.push(new Resource(this.resourceCommandResolver, ResourceGroupType.WorkingTree, uri, Status.IGNORED, useIcons, undefined, this.kind)); + case 'separate': return untrackedGroup.push(new Resource(this.resourceCommandResolver, ResourceGroupType.Untracked, uri, Status.IGNORED, useIcons)); + default: return undefined; + } + case 'DD': return mergeGroup.push(new Resource(this.resourceCommandResolver, ResourceGroupType.Merge, uri, Status.BOTH_DELETED, useIcons)); + case 'AU': return mergeGroup.push(new Resource(this.resourceCommandResolver, ResourceGroupType.Merge, uri, Status.ADDED_BY_US, useIcons)); + case 'UD': return mergeGroup.push(new Resource(this.resourceCommandResolver, ResourceGroupType.Merge, uri, Status.DELETED_BY_THEM, useIcons)); + case 'UA': return mergeGroup.push(new Resource(this.resourceCommandResolver, ResourceGroupType.Merge, uri, Status.ADDED_BY_THEM, useIcons)); + case 'DU': return mergeGroup.push(new Resource(this.resourceCommandResolver, ResourceGroupType.Merge, uri, Status.DELETED_BY_US, useIcons)); + case 'AA': return mergeGroup.push(new Resource(this.resourceCommandResolver, ResourceGroupType.Merge, uri, Status.BOTH_ADDED, useIcons)); + case 'UU': return mergeGroup.push(new Resource(this.resourceCommandResolver, ResourceGroupType.Merge, uri, Status.BOTH_MODIFIED, useIcons)); + } + + switch (raw.x) { + case 'M': indexGroup.push(new Resource(this.resourceCommandResolver, ResourceGroupType.Index, uri, Status.INDEX_MODIFIED, useIcons, undefined, this.kind)); break; + case 'A': indexGroup.push(new Resource(this.resourceCommandResolver, ResourceGroupType.Index, uri, Status.INDEX_ADDED, useIcons, undefined, this.kind)); break; + case 'D': indexGroup.push(new Resource(this.resourceCommandResolver, ResourceGroupType.Index, uri, Status.INDEX_DELETED, useIcons, undefined, this.kind)); break; + case 'R': indexGroup.push(new Resource(this.resourceCommandResolver, ResourceGroupType.Index, uri, Status.INDEX_RENAMED, useIcons, renameUri, this.kind)); break; + case 'C': indexGroup.push(new Resource(this.resourceCommandResolver, ResourceGroupType.Index, uri, Status.INDEX_COPIED, useIcons, renameUri, this.kind)); break; + } + + switch (raw.y) { + case 'M': workingTreeGroup.push(new Resource(this.resourceCommandResolver, ResourceGroupType.WorkingTree, uri, Status.MODIFIED, useIcons, renameUri, this.kind)); break; + case 'D': workingTreeGroup.push(new Resource(this.resourceCommandResolver, ResourceGroupType.WorkingTree, uri, Status.DELETED, useIcons, renameUri, this.kind)); break; + case 'A': workingTreeGroup.push(new Resource(this.resourceCommandResolver, ResourceGroupType.WorkingTree, uri, Status.INTENT_TO_ADD, useIcons, renameUri, this.kind)); break; + case 'R': workingTreeGroup.push(new Resource(this.resourceCommandResolver, ResourceGroupType.WorkingTree, uri, Status.INTENT_TO_RENAME, useIcons, renameUri, this.kind)); break; + case 'T': workingTreeGroup.push(new Resource(this.resourceCommandResolver, ResourceGroupType.WorkingTree, uri, Status.TYPE_CHANGED, useIcons, renameUri, this.kind)); break; + } + + return undefined; + }); + + return { indexGroup, mergeGroup, untrackedGroup, workingTreeGroup }; + } + + private setCountBadge(): void { + const config = workspace.getConfiguration('git', Uri.file(this.repository.root)); + const countBadge = config.get<'all' | 'tracked' | 'off'>('countBadge'); + const untrackedChanges = config.get<'mixed' | 'separate' | 'hidden'>('untrackedChanges'); + + let count = + this.mergeGroup.resourceStates.length + + this.indexGroup.resourceStates.length + + this.workingTreeGroup.resourceStates.length; + + switch (countBadge) { + case 'off': count = 0; break; + case 'tracked': + if (untrackedChanges === 'mixed') { + count -= this.workingTreeGroup.resourceStates.filter(r => r.type === Status.UNTRACKED || r.type === Status.IGNORED).length; + } + break; + case 'all': + if (untrackedChanges === 'separate') { + count += this.untrackedGroup.resourceStates.length; + } + break; + } + + this._sourceControl.count = count; + } + + private async getRebaseCommit(): Promise { + const rebaseHeadPath = path.join(this.repository.root, '.git', 'REBASE_HEAD'); + const rebaseApplyPath = path.join(this.repository.root, '.git', 'rebase-apply'); + const rebaseMergePath = path.join(this.repository.root, '.git', 'rebase-merge'); + + try { + const [rebaseApplyExists, rebaseMergePathExists, rebaseHead] = await Promise.all([ + new Promise(c => fs.exists(rebaseApplyPath, c)), + new Promise(c => fs.exists(rebaseMergePath, c)), + new Promise((c, e) => fs.readFile(rebaseHeadPath, 'utf8', (err, result) => err ? e(err) : c(result))) + ]); + if (!rebaseApplyExists && !rebaseMergePathExists) { + return undefined; + } + return await this.getCommit(rebaseHead.trim()); + } catch (err) { + return undefined; + } + } + + private isMergeInProgress(): Promise { + const mergeHeadPath = path.join(this.repository.root, '.git', 'MERGE_HEAD'); + return new Promise(resolve => fs.exists(mergeHeadPath, resolve)); + } + + private isCherryPickInProgress(): Promise { + const cherryPickHeadPath = path.join(this.repository.root, '.git', 'CHERRY_PICK_HEAD'); + return new Promise(resolve => fs.exists(cherryPickHeadPath, resolve)); + } + + private async maybeAutoStash(runOperation: () => Promise): Promise { + const config = workspace.getConfiguration('git', Uri.file(this.root)); + const shouldAutoStash = config.get('autoStash') + && this.repository.git.compareGitVersionTo('2.27.0') < 0 + && (this.indexGroup.resourceStates.length > 0 + || this.workingTreeGroup.resourceStates.some( + r => r.type !== Status.UNTRACKED && r.type !== Status.IGNORED)); + + if (!shouldAutoStash) { + return await runOperation(); + } + + await this.repository.createStash(undefined, true); + try { + const result = await runOperation(); + return result; + } finally { + await this.repository.popStash(undefined, { reinstateStagedChanges: true }); + } + } + + private onFileChange(_uri: Uri): void { + const config = workspace.getConfiguration('git'); + const autorefresh = config.get('autorefresh'); + + if (!autorefresh) { + this.logger.trace('[Repository][onFileChange] Skip running git status because autorefresh setting is disabled.'); + return; + } + + if (this.isRepositoryHuge) { + this.logger.trace('[Repository][onFileChange] Skip running git status because repository is huge.'); + return; + } + + if (!this.operations.isIdle()) { + this.logger.trace('[Repository][onFileChange] Skip running git status because an operation is running.'); + return; + } + + this.eventuallyUpdateWhenIdleAndWait(); + } + + @debounce(1000) + private eventuallyUpdateWhenIdleAndWait(): void { + this.updateWhenIdleAndWait(); + } + + @throttle + private async updateWhenIdleAndWait(): Promise { + await this.whenIdleAndFocused(); + await this.status(); + await timeout(5000); + } + + async whenIdleAndFocused(): Promise { + while (true) { + if (!this.operations.isIdle()) { + await eventToPromise(this.onDidRunOperation); + continue; + } + + if (!window.state.focused) { + const onDidFocusWindow = filterEvent(window.onDidChangeWindowState, e => e.focused); + await eventToPromise(onDidFocusWindow); + continue; + } + + return; + } + } + + get headLabel(): string { + const HEAD = this.HEAD; + + if (!HEAD) { + return ''; + } + + const head = HEAD.name || (HEAD.commit || '').substr(0, 8); + + return head + + (this.workingTreeGroup.resourceStates.length + this.untrackedGroup.resourceStates.length > 0 ? '*' : '') + + (this.indexGroup.resourceStates.length > 0 ? '+' : '') + + (this.mergeInProgress || !!this.rebaseCommit ? '!' : ''); + } + + get syncLabel(): string { + if (!this.HEAD + || !this.HEAD.name + || !this.HEAD.commit + || !this.HEAD.upstream + || !(this.HEAD.ahead || this.HEAD.behind) + ) { + return ''; + } + + const remoteName = this.HEAD && this.HEAD.remote || this.HEAD.upstream.remote; + const remote = this.remotes.find(r => r.name === remoteName); + + if (remote && remote.isReadOnly) { + return `${this.HEAD.behind}↓`; + } + + return `${this.HEAD.behind}↓ ${this.HEAD.ahead}↑`; + } + + get syncTooltip(): string { + if (!this.HEAD + || !this.HEAD.name + || !this.HEAD.commit + || !this.HEAD.upstream + || !(this.HEAD.ahead || this.HEAD.behind) + ) { + return l10n.t('Synchronize Changes'); + } + + const remoteName = this.HEAD && this.HEAD.remote || this.HEAD.upstream.remote; + const remote = this.remotes.find(r => r.name === remoteName); + + if ((remote && remote.isReadOnly) || !this.HEAD.ahead) { + return l10n.t('Pull {0} commits from {1}/{2}', this.HEAD.behind!, this.HEAD.upstream.remote, this.HEAD.upstream.name); + } else if (!this.HEAD.behind) { + return l10n.t('Push {0} commits to {1}/{2}', this.HEAD.ahead, this.HEAD.upstream.remote, this.HEAD.upstream.name); + } else { + return l10n.t('Pull {0} and push {1} commits between {2}/{3}', this.HEAD.behind, this.HEAD.ahead, this.HEAD.upstream.remote, this.HEAD.upstream.name); + } + } + + private updateInputBoxPlaceholder(): void { + const branchName = this.headShortName; + + if (branchName) { + // '{0}' will be replaced by the corresponding key-command later in the process, which is why it needs to stay. + this._sourceControl.inputBox.placeholder = l10n.t('Message ({0} to commit on "{1}")', '{0}', branchName); + } else { + this._sourceControl.inputBox.placeholder = l10n.t('Message ({0} to commit)'); + } + } + + private updateBranchProtectionMatchers(root: Uri): void { + this.branchProtection.clear(); + + for (const provider of this.branchProtectionProviderRegistry.getBranchProtectionProviders(root)) { + for (const { remote, rules } of provider.provideBranchProtection()) { + const matchers: BranchProtectionMatcher[] = []; + + for (const rule of rules) { + const include = rule.include && rule.include.length !== 0 ? picomatch(rule.include) : undefined; + const exclude = rule.exclude && rule.exclude.length !== 0 ? picomatch(rule.exclude) : undefined; + + if (include || exclude) { + matchers.push({ include, exclude }); + } + } + + if (matchers.length !== 0) { + this.branchProtection.set(remote, matchers); + } + } + } + + this._onDidChangeBranchProtection.fire(); + } + + private optimisticUpdateEnabled(): boolean { + const config = workspace.getConfiguration('git', Uri.file(this.root)); + return config.get('optimisticUpdate') === true; + } + + private async handleTagConflict(remote: string | undefined, raw: string): Promise { + // Ensure there is a remote + remote = remote ?? this.HEAD?.upstream?.remote; + if (!remote) { + throw new Error('Unable to resolve tag conflict due to missing remote.'); + } + + // Extract tag names from message + const tags: string[] = []; + for (const match of raw.matchAll(/^ ! \[rejected\]\s+([^\s]+)\s+->\s+([^\s]+)\s+\(would clobber existing tag\)$/gm)) { + if (match.length === 3) { + tags.push(match[1]); + } + } + if (tags.length === 0) { + throw new Error(`Unable to extract tag names from error message: ${raw}`); + } + + const config = workspace.getConfiguration('git', Uri.file(this.repository.root)); + const replaceTagsWhenPull = config.get('replaceTagsWhenPull', false) === true; + + if (!replaceTagsWhenPull) { + // Notification + const replaceLocalTags = l10n.t('Replace Local Tag(s)'); + const replaceLocalTagsAlways = l10n.t('Always Replace Local Tag(s)'); + const message = l10n.t('Unable to pull from remote repository due to conflicting tag(s): {0}. Would you like to resolve the conflict by replacing the local tag(s)?', tags.join(', ')); + const choice = await window.showErrorMessage(message, { modal: true }, replaceLocalTags, replaceLocalTagsAlways); + + if (choice !== replaceLocalTags && choice !== replaceLocalTagsAlways) { + return false; + } + + if (choice === replaceLocalTagsAlways) { + await config.update('replaceTagsWhenPull', true, true); + } + } + + // Force fetch tags + await this.repository.fetchTags({ remote, tags, force: true }); + return true; + } + + public isBranchProtected(branch = this.HEAD): boolean { + if (branch?.name) { + // Default branch protection (settings) + const defaultBranchProtectionMatcher = this.branchProtection.get(''); + if (defaultBranchProtectionMatcher?.length === 1 && + defaultBranchProtectionMatcher[0].include && + defaultBranchProtectionMatcher[0].include(branch.name)) { + return true; + } + + if (branch.upstream?.remote) { + // Branch protection (contributed) + const remoteBranchProtectionMatcher = this.branchProtection.get(branch.upstream.remote); + if (remoteBranchProtectionMatcher && remoteBranchProtectionMatcher?.length !== 0) { + return remoteBranchProtectionMatcher.some(matcher => { + const include = matcher.include ? matcher.include(branch.name!) : true; + const exclude = matcher.exclude ? matcher.exclude(branch.name!) : false; + + return include && !exclude; + }); + } + } + } + + return false; + } + + async getUnpublishedCommits(): Promise> { + if (this.unpublishedCommits) { + return this.unpublishedCommits; + } + + if (!this.HEAD?.name) { + this.unpublishedCommits = new Set(); + return this.unpublishedCommits; + } + + if (this.HEAD.upstream) { + // Upstream + if (this.HEAD.ahead === 0) { + this.unpublishedCommits = new Set(); + } else { + const ref1 = `${this.HEAD.upstream.remote}/${this.HEAD.upstream.name}`; + const ref2 = this.HEAD.name; + + const revList = await this.repository.revList(ref1, ref2); + this.unpublishedCommits = new Set(revList); + } + } else if (this.historyProvider.currentHistoryItemBaseRef) { + // Base + const ref1 = this.historyProvider.currentHistoryItemBaseRef.id; + const ref2 = this.HEAD.name; + + const revList = await this.repository.revList(ref1, ref2); + this.unpublishedCommits = new Set(revList); + } else { + this.unpublishedCommits = new Set(); + } + + return this.unpublishedCommits; + } + + async generateRandomBranchName(): Promise { + const config = workspace.getConfiguration('git', Uri.file(this.root)); + const branchRandomNameEnabled = config.get('branchRandomName.enable', false); + + if (!branchRandomNameEnabled) { + return undefined; + } + + const branchPrefix = config.get('branchPrefix', ''); + const branchWhitespaceChar = config.get('branchWhitespaceChar', '-'); + const branchRandomNameDictionary = config.get('branchRandomName.dictionary', ['adjectives', 'animals']); + + const dictionaries: string[][] = []; + for (const dictionary of branchRandomNameDictionary) { + if (dictionary.toLowerCase() === 'adjectives') { + dictionaries.push(adjectives); + } + if (dictionary.toLowerCase() === 'animals') { + dictionaries.push(animals); + } + if (dictionary.toLowerCase() === 'colors') { + dictionaries.push(colors); + } + if (dictionary.toLowerCase() === 'numbers') { + dictionaries.push(NumberDictionary.generate({ length: 3 })); + } + } + + if (dictionaries.length === 0) { + return undefined; + } + + // 5 attempts to generate a random branch name + for (let index = 0; index < 5; index++) { + const randomName = uniqueNamesGenerator({ + dictionaries, + length: dictionaries.length, + separator: branchWhitespaceChar + }); + + // Check for local ref conflict + const refs = await this.getRefs({ pattern: `refs/heads/${branchPrefix}${randomName}` }); + if (refs.length === 0) { + return `${branchPrefix}${randomName}`; + } + } + + return undefined; + } + + private async initIsUsingVirtualFileSystem(): Promise { + if (this._isUsingVirtualFileSystem !== undefined) { + return; + } + + try { + const result = await this.getConfig('core.virtualfilesystem'); + this._isUsingVirtualFileSystem = result.length > 0; + } catch (error) { + this._isUsingVirtualFileSystem = false; + return; + } + } + + dispose(): void { + this.disposables = dispose(this.disposables); + } +} + +function retargetTaskToWorktree(task: Task, worktreePath: string): Task | undefined { + const execution = retargetTaskExecution(task.execution, worktreePath); + if (!execution) { + return undefined; + } + + const worktreeFolder: WorkspaceFolder = { + uri: Uri.file(worktreePath), + name: path.basename(worktreePath), + index: workspace.workspaceFolders?.length ?? 0 + }; + + const worktreeTask = new Task({ ...task.definition }, worktreeFolder, task.name, task.source, execution, task.problemMatchers); + worktreeTask.detail = task.detail; + worktreeTask.group = task.group; + worktreeTask.isBackground = task.isBackground; + worktreeTask.presentationOptions = { ...task.presentationOptions, reveal: TaskRevealKind.Never, panel: TaskPanelKind.New }; + worktreeTask.runOptions = { ...task.runOptions }; + + return worktreeTask; +} + +function retargetTaskExecution(execution: ProcessExecution | ShellExecution | CustomExecution | undefined, worktreePath: string): ProcessExecution | ShellExecution | CustomExecution | undefined { + if (!execution) { + return undefined; + } + + if (execution instanceof ProcessExecution) { + return new ProcessExecution(execution.process, execution.args, { + ...execution.options, + cwd: worktreePath + }); + } + + if (execution instanceof ShellExecution) { + if (execution.commandLine !== undefined) { + return new ShellExecution(execution.commandLine, { + ...execution.options, + cwd: worktreePath + }); + } + + if (execution.command !== undefined) { + return new ShellExecution(execution.command, execution.args ?? [], { + ...execution.options, + cwd: worktreePath + }); + } + } + + return execution; +}