Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/git/src/git.ts
5239 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { promises as fs, exists, realpath } from 'fs';
7
import * as path from 'path';
8
import * as os from 'os';
9
import * as cp from 'child_process';
10
import { fileURLToPath } from 'url';
11
import which from 'which';
12
import { EventEmitter } from 'events';
13
import * as filetype from 'file-type';
14
import { assign, groupBy, IDisposable, toDisposable, dispose, mkdirp, readBytes, detectUnicodeEncoding, Encoding, onceEvent, splitInChunks, Limiter, Versions, isWindows, pathEquals, isMacintosh, isDescendant, relativePathWithNoFallback, Mutable } from './util';
15
import { CancellationError, CancellationToken, ConfigurationChangeEvent, LogOutputChannel, Progress, Uri, workspace } from 'vscode';
16
import { Commit as ApiCommit, Ref, RefType, Branch, Remote, ForcePushMode, GitErrorCodes, LogOptions, Change, Status, CommitOptions, RefQuery as ApiRefQuery, InitOptions, DiffChange, Worktree as ApiWorktree } from './api/git';
17
import * as byline from 'byline';
18
import { StringDecoder } from 'string_decoder';
19
20
// https://github.com/microsoft/vscode/issues/65693
21
const MAX_CLI_LENGTH = 30000;
22
23
export interface IGit {
24
path: string;
25
version: string;
26
}
27
28
export interface IDotGit {
29
readonly path: string;
30
readonly commonPath?: string;
31
readonly superProjectPath?: string;
32
readonly isBare: boolean;
33
}
34
35
export interface IFileStatus {
36
x: string;
37
y: string;
38
path: string;
39
rename?: string;
40
}
41
42
export interface Stash {
43
readonly hash: string;
44
readonly parents: string[];
45
readonly index: number;
46
readonly description: string;
47
readonly branchName?: string;
48
readonly authorDate?: Date;
49
readonly commitDate?: Date;
50
}
51
52
interface MutableRemote extends Remote {
53
fetchUrl?: string;
54
pushUrl?: string;
55
isReadOnly: boolean;
56
}
57
58
// TODO@eamodio: Move to git.d.ts once we are good with the api
59
/**
60
* Log file options.
61
*/
62
export interface LogFileOptions {
63
/** Optional. Continue listing the history of a file beyond renames */
64
readonly follow?: boolean;
65
/** Optional. The maximum number of log entries to retrieve. */
66
readonly maxEntries?: number | string;
67
/** Optional. The Git sha (hash) to start retrieving log entries from. */
68
readonly hash?: string;
69
/** Optional. Specifies whether to start retrieving log entries in reverse order. */
70
readonly reverse?: boolean;
71
readonly sortByAuthorDate?: boolean;
72
readonly shortStats?: boolean;
73
}
74
75
function parseVersion(raw: string): string {
76
return raw.replace(/^git version /, '');
77
}
78
79
function findSpecificGit(path: string, onValidate: (path: string) => boolean): Promise<IGit> {
80
return new Promise<IGit>((c, e) => {
81
if (!onValidate(path)) {
82
return e(new Error(`Path "${path}" is invalid.`));
83
}
84
85
const buffers: Buffer[] = [];
86
const child = cp.spawn(path, ['--version']);
87
child.stdout.on('data', (b: Buffer) => buffers.push(b));
88
child.on('error', cpErrorHandler(e));
89
child.on('close', code => code ? e(new Error(`Not found. Code: ${code}`)) : c({ path, version: parseVersion(Buffer.concat(buffers).toString('utf8').trim()) }));
90
});
91
}
92
93
function findGitDarwin(onValidate: (path: string) => boolean): Promise<IGit> {
94
return new Promise<IGit>((c, e) => {
95
cp.exec('which git', (err, gitPathBuffer) => {
96
if (err) {
97
return e(new Error(`Executing "which git" failed: ${err.message}`));
98
}
99
100
const path = gitPathBuffer.toString().trim();
101
102
function getVersion(path: string) {
103
if (!onValidate(path)) {
104
return e(new Error(`Path "${path}" is invalid.`));
105
}
106
107
// make sure git executes
108
cp.exec('git --version', (err, stdout) => {
109
110
if (err) {
111
return e(new Error(`Executing "git --version" failed: ${err.message}`));
112
}
113
114
return c({ path, version: parseVersion(stdout.trim()) });
115
});
116
}
117
118
if (path !== '/usr/bin/git') {
119
return getVersion(path);
120
}
121
122
// must check if XCode is installed
123
cp.exec('xcode-select -p', (err) => {
124
if (err && err.code === 2) {
125
// git is not installed, and launching /usr/bin/git
126
// will prompt the user to install it
127
128
return e(new Error('Executing "xcode-select -p" failed with error code 2.'));
129
}
130
131
getVersion(path);
132
});
133
});
134
});
135
}
136
137
function findSystemGitWin32(base: string, onValidate: (path: string) => boolean): Promise<IGit> {
138
if (!base) {
139
return Promise.reject<IGit>('Not found');
140
}
141
142
return findSpecificGit(path.join(base, 'Git', 'cmd', 'git.exe'), onValidate);
143
}
144
145
async function findGitWin32InPath(onValidate: (path: string) => boolean): Promise<IGit> {
146
const path = await which('git.exe');
147
return findSpecificGit(path, onValidate);
148
}
149
150
function findGitWin32(onValidate: (path: string) => boolean): Promise<IGit> {
151
return findSystemGitWin32(process.env['ProgramW6432'] as string, onValidate)
152
.then(undefined, () => findSystemGitWin32(process.env['ProgramFiles(x86)'] as string, onValidate))
153
.then(undefined, () => findSystemGitWin32(process.env['ProgramFiles'] as string, onValidate))
154
.then(undefined, () => findSystemGitWin32(path.join(process.env['LocalAppData'] as string, 'Programs'), onValidate))
155
.then(undefined, () => findGitWin32InPath(onValidate));
156
}
157
158
export async function findGit(hints: string[], onValidate: (path: string) => boolean, logger: LogOutputChannel): Promise<IGit> {
159
for (const hint of hints) {
160
try {
161
return await findSpecificGit(hint, onValidate);
162
} catch (err) {
163
// noop
164
logger.info(`Unable to find git on the PATH: "${hint}". Error: ${err.message}`);
165
}
166
}
167
168
try {
169
switch (process.platform) {
170
case 'darwin': return await findGitDarwin(onValidate);
171
case 'win32': return await findGitWin32(onValidate);
172
default: return await findSpecificGit('git', onValidate);
173
}
174
} catch (err) {
175
// noop
176
logger.warn(`Unable to find git. Error: ${err.message}`);
177
}
178
179
throw new Error('Git installation not found.');
180
}
181
182
export interface IExecutionResult<T extends string | Buffer> {
183
exitCode: number;
184
stdout: T;
185
stderr: string;
186
}
187
188
function cpErrorHandler(cb: (reason?: any) => void): (reason?: any) => void {
189
return err => {
190
if (/ENOENT/.test(err.message)) {
191
err = new GitError({
192
error: err,
193
message: 'Failed to execute git (ENOENT)',
194
gitErrorCode: GitErrorCodes.NotAGitRepository
195
});
196
}
197
198
cb(err);
199
};
200
}
201
202
export interface SpawnOptions extends cp.SpawnOptions {
203
input?: string;
204
log?: boolean;
205
cancellationToken?: CancellationToken;
206
onSpawn?: (childProcess: cp.ChildProcess) => void;
207
}
208
209
async function exec(child: cp.ChildProcess, cancellationToken?: CancellationToken): Promise<IExecutionResult<Buffer>> {
210
if (!child.stdout || !child.stderr) {
211
throw new GitError({ message: 'Failed to get stdout or stderr from git process.' });
212
}
213
214
if (cancellationToken && cancellationToken.isCancellationRequested) {
215
throw new CancellationError();
216
}
217
218
const disposables: IDisposable[] = [];
219
220
const once = (ee: NodeJS.EventEmitter, name: string, fn: (...args: any[]) => void) => {
221
ee.once(name, fn);
222
disposables.push(toDisposable(() => ee.removeListener(name, fn)));
223
};
224
225
const on = (ee: NodeJS.EventEmitter, name: string, fn: (...args: any[]) => void) => {
226
ee.on(name, fn);
227
disposables.push(toDisposable(() => ee.removeListener(name, fn)));
228
};
229
230
let result = Promise.all<any>([
231
new Promise<number>((c, e) => {
232
once(child, 'error', cpErrorHandler(e));
233
once(child, 'exit', c);
234
}),
235
new Promise<Buffer>(c => {
236
const buffers: Buffer[] = [];
237
on(child.stdout!, 'data', (b: Buffer) => buffers.push(b));
238
once(child.stdout!, 'close', () => c(Buffer.concat(buffers)));
239
}),
240
new Promise<string>(c => {
241
const buffers: Buffer[] = [];
242
on(child.stderr!, 'data', (b: Buffer) => buffers.push(b));
243
once(child.stderr!, 'close', () => c(Buffer.concat(buffers).toString('utf8')));
244
})
245
]) as Promise<[number, Buffer, string]>;
246
247
if (cancellationToken) {
248
const cancellationPromise = new Promise<[number, Buffer, string]>((_, e) => {
249
onceEvent(cancellationToken.onCancellationRequested)(() => {
250
try {
251
child.kill();
252
} catch (err) {
253
// noop
254
}
255
256
e(new CancellationError());
257
});
258
});
259
260
result = Promise.race([result, cancellationPromise]);
261
}
262
263
try {
264
const [exitCode, stdout, stderr] = await result;
265
return { exitCode, stdout, stderr };
266
} finally {
267
dispose(disposables);
268
}
269
}
270
271
export interface IGitErrorData {
272
error?: Error;
273
message?: string;
274
stdout?: string;
275
stderr?: string;
276
exitCode?: number;
277
gitErrorCode?: string;
278
gitCommand?: string;
279
gitArgs?: string[];
280
}
281
282
export class GitError extends Error {
283
284
error?: Error;
285
stdout?: string;
286
stderr?: string;
287
exitCode?: number;
288
gitErrorCode?: string;
289
gitCommand?: string;
290
gitArgs?: string[];
291
292
constructor(data: IGitErrorData) {
293
super(data.error?.message || data.message || 'Git error');
294
295
this.error = data.error;
296
this.stdout = data.stdout;
297
this.stderr = data.stderr;
298
this.exitCode = data.exitCode;
299
this.gitErrorCode = data.gitErrorCode;
300
this.gitCommand = data.gitCommand;
301
this.gitArgs = data.gitArgs;
302
}
303
304
override toString(): string {
305
let result = this.message + ' ' + JSON.stringify({
306
exitCode: this.exitCode,
307
gitErrorCode: this.gitErrorCode,
308
gitCommand: this.gitCommand,
309
stdout: this.stdout,
310
stderr: this.stderr
311
}, null, 2);
312
313
if (this.error?.stack) {
314
result += this.error.stack;
315
}
316
317
return result;
318
}
319
}
320
321
export interface IGitOptions {
322
gitPath: string;
323
userAgent: string;
324
version: string;
325
env?: { [key: string]: string };
326
}
327
328
function getGitErrorCode(stderr: string): string | undefined {
329
if (/Another git process seems to be running in this repository|If no other git process is currently running/.test(stderr)) {
330
return GitErrorCodes.RepositoryIsLocked;
331
} else if (/Authentication failed/i.test(stderr)) {
332
return GitErrorCodes.AuthenticationFailed;
333
} else if (/Not a git repository/i.test(stderr)) {
334
return GitErrorCodes.NotAGitRepository;
335
} else if (/bad config file/.test(stderr)) {
336
return GitErrorCodes.BadConfigFile;
337
} else if (/cannot make pipe for command substitution|cannot create standard input pipe/.test(stderr)) {
338
return GitErrorCodes.CantCreatePipe;
339
} else if (/Repository not found/.test(stderr)) {
340
return GitErrorCodes.RepositoryNotFound;
341
} else if (/unable to access/.test(stderr)) {
342
return GitErrorCodes.CantAccessRemote;
343
} else if (/branch '.+' is not fully merged/.test(stderr)) {
344
return GitErrorCodes.BranchNotFullyMerged;
345
} else if (/Couldn\'t find remote ref/.test(stderr)) {
346
return GitErrorCodes.NoRemoteReference;
347
} else if (/A branch named '.+' already exists/.test(stderr)) {
348
return GitErrorCodes.BranchAlreadyExists;
349
} else if (/'.+' is not a valid branch name/.test(stderr)) {
350
return GitErrorCodes.InvalidBranchName;
351
} else if (/Please,? commit your changes or stash them/.test(stderr)) {
352
return GitErrorCodes.DirtyWorkTree;
353
} else if (/detected dubious ownership in repository at/.test(stderr)) {
354
return GitErrorCodes.NotASafeGitRepository;
355
} else if (/contains modified or untracked files|use --force to delete it/.test(stderr)) {
356
return GitErrorCodes.WorktreeContainsChanges;
357
} else if (/fatal: '[^']+' already exists/.test(stderr)) {
358
return GitErrorCodes.WorktreeAlreadyExists;
359
} else if (/is already used by worktree at/.test(stderr)) {
360
return GitErrorCodes.WorktreeBranchAlreadyUsed;
361
}
362
return undefined;
363
}
364
365
// https://github.com/microsoft/vscode/issues/89373
366
// https://github.com/git-for-windows/git/issues/2478
367
function sanitizePath(path: string): string {
368
return path.replace(/^([a-z]):\\/i, (_, letter) => `${letter.toUpperCase()}:\\`);
369
}
370
371
function sanitizeRelativePath(path: string): string {
372
return path.replace(/\\/g, '/');
373
}
374
375
const COMMIT_FORMAT = '%H%n%aN%n%aE%n%at%n%ct%n%P%n%D%n%B';
376
const STASH_FORMAT = '%H%n%P%n%gd%n%gs%n%at%n%ct';
377
378
export interface ICloneOptions {
379
readonly parentPath: string;
380
readonly progress: Progress<{ increment: number }>;
381
readonly recursive?: boolean;
382
readonly ref?: string;
383
}
384
385
export class Git {
386
387
readonly path: string;
388
readonly userAgent: string;
389
readonly version: string;
390
readonly env: { [key: string]: string };
391
392
private commandsToLog: string[] = [];
393
394
private _onOutput = new EventEmitter();
395
get onOutput(): EventEmitter { return this._onOutput; }
396
397
constructor(options: IGitOptions) {
398
this.path = options.gitPath;
399
this.version = options.version;
400
this.userAgent = options.userAgent;
401
this.env = options.env || {};
402
403
const onConfigurationChanged = (e?: ConfigurationChangeEvent) => {
404
if (e !== undefined && !e.affectsConfiguration('git.commandsToLog')) {
405
return;
406
}
407
408
const config = workspace.getConfiguration('git');
409
this.commandsToLog = config.get<string[]>('commandsToLog', []);
410
};
411
412
workspace.onDidChangeConfiguration(onConfigurationChanged, this);
413
onConfigurationChanged();
414
}
415
416
compareGitVersionTo(version: string): -1 | 0 | 1 {
417
return Versions.compare(Versions.fromString(this.version), Versions.fromString(version));
418
}
419
420
open(repositoryRoot: string, repositoryRootRealPath: string | undefined, dotGit: IDotGit, logger: LogOutputChannel): Repository {
421
return new Repository(this, repositoryRoot, repositoryRootRealPath, dotGit, logger);
422
}
423
424
async init(repository: string, options: InitOptions = {}): Promise<void> {
425
const args = ['init'];
426
427
if (options.defaultBranch && options.defaultBranch !== '' && this.compareGitVersionTo('2.28.0') !== -1) {
428
args.push('-b', options.defaultBranch);
429
}
430
431
await this.exec(repository, args);
432
}
433
434
async clone(url: string, options: ICloneOptions, cancellationToken?: CancellationToken): Promise<string> {
435
const baseFolderName = decodeURI(url).replace(/[\/]+$/, '').replace(/^.*[\/\\]/, '').replace(/\.git$/, '') || 'repository';
436
let folderName = baseFolderName;
437
let folderPath = path.join(options.parentPath, folderName);
438
let count = 1;
439
440
while (count < 20 && await new Promise(c => exists(folderPath, c))) {
441
folderName = `${baseFolderName}-${count++}`;
442
folderPath = path.join(options.parentPath, folderName);
443
}
444
445
await mkdirp(options.parentPath);
446
447
const onSpawn = (child: cp.ChildProcess) => {
448
const decoder = new StringDecoder('utf8');
449
const lineStream = new byline.LineStream({ encoding: 'utf8' });
450
child.stderr!.on('data', (buffer: Buffer) => lineStream.write(decoder.write(buffer)));
451
452
let totalProgress = 0;
453
let previousProgress = 0;
454
455
lineStream.on('data', (line: string) => {
456
let match: RegExpExecArray | null = null;
457
458
if (match = /Counting objects:\s*(\d+)%/i.exec(line)) {
459
totalProgress = Math.floor(parseInt(match[1]) * 0.1);
460
} else if (match = /Compressing objects:\s*(\d+)%/i.exec(line)) {
461
totalProgress = 10 + Math.floor(parseInt(match[1]) * 0.1);
462
} else if (match = /Receiving objects:\s*(\d+)%/i.exec(line)) {
463
totalProgress = 20 + Math.floor(parseInt(match[1]) * 0.4);
464
} else if (match = /Resolving deltas:\s*(\d+)%/i.exec(line)) {
465
totalProgress = 60 + Math.floor(parseInt(match[1]) * 0.4);
466
}
467
468
if (totalProgress !== previousProgress) {
469
options.progress.report({ increment: totalProgress - previousProgress });
470
previousProgress = totalProgress;
471
}
472
});
473
};
474
475
try {
476
const command = ['clone', url.includes(' ') ? encodeURI(url) : url, folderPath, '--progress'];
477
if (options.recursive) {
478
command.push('--recursive');
479
}
480
if (options.ref) {
481
command.push('--branch', options.ref);
482
}
483
await this.exec(options.parentPath, command, {
484
cancellationToken,
485
env: { 'GIT_HTTP_USER_AGENT': this.userAgent },
486
onSpawn,
487
});
488
} catch (err) {
489
if (err.stderr) {
490
err.stderr = err.stderr.replace(/^Cloning.+$/m, '').trim();
491
err.stderr = err.stderr.replace(/^ERROR:\s+/, '').trim();
492
}
493
494
throw err;
495
}
496
497
return folderPath;
498
}
499
500
async getRepositoryRoot(pathInsidePossibleRepository: string): Promise<string> {
501
const result = await this.exec(pathInsidePossibleRepository, ['rev-parse', '--show-toplevel']);
502
503
// Keep trailing spaces which are part of the directory name
504
const repositoryRootPath = path.normalize(result.stdout.trimStart().replace(/[\r\n]+$/, ''));
505
506
// Handle symbolic links and UNC paths
507
// Git 2.31 added the `--path-format` flag to rev-parse which
508
// allows us to get the relative path of the repository root
509
if (!pathEquals(pathInsidePossibleRepository, repositoryRootPath) &&
510
!isDescendant(repositoryRootPath, pathInsidePossibleRepository) &&
511
!isDescendant(pathInsidePossibleRepository, repositoryRootPath) &&
512
this.compareGitVersionTo('2.31.0') !== -1) {
513
const relativePathResult = await this.exec(pathInsidePossibleRepository, ['rev-parse', '--path-format=relative', '--show-toplevel',]);
514
return path.resolve(pathInsidePossibleRepository, relativePathResult.stdout.trimStart().replace(/[\r\n]+$/, ''));
515
}
516
517
if (isWindows) {
518
// On Git 2.25+ if you call `rev-parse --show-toplevel` on a mapped drive, instead of getting the mapped
519
// drive path back, you get the UNC path for the mapped drive. So we will try to normalize it back to the
520
// mapped drive path, if possible
521
const repoUri = Uri.file(repositoryRootPath);
522
const pathUri = Uri.file(pathInsidePossibleRepository);
523
if (repoUri.authority.length !== 0 && pathUri.authority.length === 0) {
524
const match = /^[\/]?([a-zA-Z])[:\/]/.exec(pathUri.path);
525
if (match !== null) {
526
const [, letter] = match;
527
528
try {
529
const networkPath = await new Promise<string | undefined>(resolve =>
530
realpath.native(`${letter}:\\`, { encoding: 'utf8' }, (err, resolvedPath) =>
531
resolve(err !== null ? undefined : resolvedPath),
532
),
533
);
534
if (networkPath !== undefined) {
535
// If the repository is at the root of the mapped drive then we
536
// have to append `\` (ex: D:\) otherwise the path is not valid.
537
const isDriveRoot = pathEquals(repoUri.fsPath, networkPath);
538
539
return path.normalize(
540
repoUri.fsPath.replace(
541
networkPath,
542
`${letter.toLowerCase()}:${isDriveRoot || networkPath.endsWith('\\') ? '\\' : ''}`
543
),
544
);
545
}
546
} catch { }
547
}
548
549
return path.normalize(pathUri.fsPath);
550
}
551
}
552
553
return repositoryRootPath;
554
}
555
556
async getRepositoryDotGit(repositoryPath: string): Promise<IDotGit> {
557
let dotGitPath: string | undefined, commonDotGitPath: string | undefined, superProjectPath: string | undefined;
558
559
const args = ['rev-parse', '--git-dir', '--git-common-dir'];
560
if (this.compareGitVersionTo('2.13.0') >= 0) {
561
args.push('--show-superproject-working-tree');
562
}
563
564
const result = await this.exec(repositoryPath, args);
565
[dotGitPath, commonDotGitPath, superProjectPath] = result.stdout.split('\n').map(r => r.trim());
566
567
if (!path.isAbsolute(dotGitPath)) {
568
dotGitPath = path.join(repositoryPath, dotGitPath);
569
}
570
dotGitPath = path.normalize(dotGitPath);
571
572
if (commonDotGitPath) {
573
if (!path.isAbsolute(commonDotGitPath)) {
574
commonDotGitPath = path.join(repositoryPath, commonDotGitPath);
575
}
576
commonDotGitPath = path.normalize(commonDotGitPath);
577
}
578
579
const raw = await fs.readFile(path.join(commonDotGitPath ?? dotGitPath, 'config'), 'utf8');
580
const coreSections = GitConfigParser.parse(raw).find(s => s.name === 'core');
581
const isBare = coreSections?.properties['bare'] === 'true';
582
583
return {
584
isBare,
585
path: dotGitPath,
586
commonPath: commonDotGitPath !== dotGitPath ? commonDotGitPath : undefined,
587
superProjectPath: superProjectPath ? path.normalize(superProjectPath) : undefined
588
};
589
}
590
591
async exec(cwd: string, args: string[], options: SpawnOptions = {}): Promise<IExecutionResult<string>> {
592
options = assign({ cwd }, options || {});
593
return await this._exec(args, options);
594
}
595
596
async exec2(args: string[], options: SpawnOptions = {}): Promise<IExecutionResult<string>> {
597
return await this._exec(args, options);
598
}
599
600
stream(cwd: string, args: string[], options: SpawnOptions = {}): cp.ChildProcess {
601
options = assign({ cwd }, options || {});
602
const child = this.spawn(args, options);
603
604
if (options.log !== false) {
605
const startTime = Date.now();
606
child.on('exit', (_) => {
607
this.log(`> git ${args.join(' ')} [${Date.now() - startTime}ms]${child.killed ? ' (cancelled)' : ''}\n`);
608
});
609
}
610
611
return child;
612
}
613
614
private async _exec(args: string[], options: SpawnOptions = {}): Promise<IExecutionResult<string>> {
615
const child = this.spawn(args, options);
616
617
options.onSpawn?.(child);
618
619
if (options.input) {
620
child.stdin!.end(options.input, 'utf8');
621
}
622
623
const startExec = Date.now();
624
let bufferResult: IExecutionResult<Buffer>;
625
626
try {
627
bufferResult = await exec(child, options.cancellationToken);
628
} catch (ex) {
629
if (ex instanceof CancellationError) {
630
this.log(`> git ${args.join(' ')} [${Date.now() - startExec}ms] (cancelled)\n`);
631
}
632
633
throw ex;
634
}
635
636
if (options.log !== false) {
637
// command
638
this.log(`> git ${args.join(' ')} [${Date.now() - startExec}ms]\n`);
639
640
// stdout
641
if (bufferResult.stdout.length > 0 && args.find(a => this.commandsToLog.includes(a))) {
642
this.log(`${bufferResult.stdout}\n`);
643
}
644
645
// stderr
646
if (bufferResult.stderr.length > 0) {
647
this.log(`${bufferResult.stderr}\n`);
648
}
649
}
650
651
const result: IExecutionResult<string> = {
652
exitCode: bufferResult.exitCode,
653
stdout: bufferResult.stdout.toString('utf8'),
654
stderr: bufferResult.stderr
655
};
656
657
if (bufferResult.exitCode) {
658
return Promise.reject<IExecutionResult<string>>(new GitError({
659
message: 'Failed to execute git',
660
stdout: result.stdout,
661
stderr: result.stderr,
662
exitCode: result.exitCode,
663
gitErrorCode: getGitErrorCode(result.stderr),
664
gitCommand: args[0],
665
gitArgs: args
666
}));
667
}
668
669
return result;
670
}
671
672
spawn(args: string[], options: SpawnOptions = {}): cp.ChildProcess {
673
if (!this.path) {
674
throw new Error('git could not be found in the system.');
675
}
676
677
if (!options) {
678
options = {};
679
}
680
681
if (!options.stdio && !options.input) {
682
options.stdio = ['ignore', null, null]; // Unless provided, ignore stdin and leave default streams for stdout and stderr
683
}
684
685
options.env = assign({}, process.env, this.env, options.env || {}, {
686
VSCODE_GIT_COMMAND: args[0],
687
LANGUAGE: 'en',
688
LC_ALL: 'en_US.UTF-8',
689
LANG: 'en_US.UTF-8',
690
GIT_PAGER: 'cat'
691
});
692
693
const cwd = this.getCwd(options);
694
if (cwd) {
695
options.cwd = sanitizePath(cwd);
696
}
697
698
return cp.spawn(this.path, args, options);
699
}
700
701
private getCwd(options: SpawnOptions): string | undefined {
702
const cwd = options.cwd;
703
if (typeof cwd === 'undefined' || typeof cwd === 'string') {
704
return cwd;
705
}
706
707
if (cwd.protocol === 'file:') {
708
return fileURLToPath(cwd);
709
}
710
711
return undefined;
712
}
713
714
private log(output: string): void {
715
this._onOutput.emit('log', output);
716
}
717
718
async mergeFile(options: { input1Path: string; input2Path: string; basePath: string; diff3?: boolean }): Promise<string> {
719
const args = ['merge-file', '-p', options.input1Path, options.basePath, options.input2Path];
720
if (options.diff3) {
721
args.push('--diff3');
722
} else {
723
args.push('--no-diff3');
724
}
725
726
try {
727
const result = await this.exec(os.homedir(), args);
728
return result.stdout;
729
} catch (err) {
730
if (typeof err.stdout === 'string') {
731
// The merge had conflicts, stdout still contains the merged result (with conflict markers)
732
return err.stdout;
733
} else {
734
throw err;
735
}
736
}
737
}
738
739
async addSafeDirectory(repositoryPath: string): Promise<void> {
740
await this.exec(os.homedir(), ['config', '--global', '--add', 'safe.directory', repositoryPath]);
741
return;
742
}
743
}
744
745
export interface CommitShortStat {
746
readonly files: number;
747
readonly insertions: number;
748
readonly deletions: number;
749
}
750
751
export interface Commit {
752
hash: string;
753
message: string;
754
parents: string[];
755
authorDate?: Date;
756
authorName?: string;
757
authorEmail?: string;
758
commitDate?: Date;
759
refNames: string[];
760
shortStat?: CommitShortStat;
761
}
762
763
export interface RefQuery extends ApiRefQuery {
764
readonly includeCommitDetails?: boolean;
765
}
766
767
interface GitConfigSection {
768
name: string;
769
subSectionName?: string;
770
properties: { [key: string]: string };
771
}
772
773
class GitConfigParser {
774
private static readonly _lineSeparator = /\r?\n/;
775
776
private static readonly _propertyRegex = /^\s*(\w+)\s*=\s*"?([^"]+)"?$/;
777
private static readonly _sectionRegex = /^\s*\[\s*([^\]]+?)\s*(\"[^"]+\")*\]\s*$/;
778
779
static parse(raw: string): GitConfigSection[] {
780
const config: { sections: GitConfigSection[] } = { sections: [] };
781
let section: GitConfigSection = { name: 'DEFAULT', properties: {} };
782
783
const addSection = (section?: GitConfigSection) => {
784
if (!section) { return; }
785
config.sections.push(section);
786
};
787
788
for (const line of raw.split(GitConfigParser._lineSeparator)) {
789
// Section
790
const sectionMatch = line.match(GitConfigParser._sectionRegex);
791
if (sectionMatch?.length === 3) {
792
addSection(section);
793
section = { name: sectionMatch[1], subSectionName: sectionMatch[2]?.replaceAll('"', ''), properties: {} };
794
795
continue;
796
}
797
798
// Property
799
const propertyMatch = line.match(GitConfigParser._propertyRegex);
800
if (propertyMatch?.length === 3 && !Object.keys(section.properties).includes(propertyMatch[1])) {
801
section.properties[propertyMatch[1]] = propertyMatch[2];
802
}
803
}
804
805
addSection(section);
806
807
return config.sections;
808
}
809
}
810
811
export class GitStatusParser {
812
813
private lastRaw = '';
814
private result: IFileStatus[] = [];
815
816
get status(): IFileStatus[] {
817
return this.result;
818
}
819
820
update(raw: string): void {
821
let i = 0;
822
let nextI: number | undefined;
823
824
raw = this.lastRaw + raw;
825
826
while ((nextI = this.parseEntry(raw, i)) !== undefined) {
827
i = nextI;
828
}
829
830
this.lastRaw = raw.substr(i);
831
}
832
833
private parseEntry(raw: string, i: number): number | undefined {
834
if (i + 4 >= raw.length) {
835
return;
836
}
837
838
let lastIndex: number;
839
const entry: IFileStatus = {
840
x: raw.charAt(i++),
841
y: raw.charAt(i++),
842
rename: undefined,
843
path: ''
844
};
845
846
// space
847
i++;
848
849
if (entry.x === 'R' || entry.y === 'R' || entry.x === 'C') {
850
lastIndex = raw.indexOf('\0', i);
851
852
if (lastIndex === -1) {
853
return;
854
}
855
856
entry.rename = raw.substring(i, lastIndex);
857
i = lastIndex + 1;
858
}
859
860
lastIndex = raw.indexOf('\0', i);
861
862
if (lastIndex === -1) {
863
return;
864
}
865
866
entry.path = raw.substring(i, lastIndex);
867
868
// If path ends with slash, it must be a nested git repo
869
if (entry.path[entry.path.length - 1] !== '/') {
870
this.result.push(entry);
871
}
872
873
return lastIndex + 1;
874
}
875
}
876
877
export interface Submodule {
878
name: string;
879
path: string;
880
url: string;
881
}
882
883
export function parseGitmodules(raw: string): Submodule[] {
884
const result: Submodule[] = [];
885
886
for (const submoduleSection of GitConfigParser.parse(raw).filter(s => s.name === 'submodule')) {
887
if (submoduleSection.subSectionName && submoduleSection.properties['path'] && submoduleSection.properties['url']) {
888
result.push({
889
name: submoduleSection.subSectionName,
890
path: submoduleSection.properties['path'],
891
url: submoduleSection.properties['url']
892
});
893
}
894
}
895
896
return result;
897
}
898
899
export function parseGitRemotes(raw: string): MutableRemote[] {
900
const remotes: MutableRemote[] = [];
901
902
for (const remoteSection of GitConfigParser.parse(raw).filter(s => s.name === 'remote')) {
903
if (remoteSection.subSectionName) {
904
remotes.push({
905
name: remoteSection.subSectionName,
906
fetchUrl: remoteSection.properties['url'],
907
pushUrl: remoteSection.properties['pushurl'] ?? remoteSection.properties['url'],
908
isReadOnly: false
909
});
910
}
911
}
912
913
return remotes;
914
}
915
916
const commitRegex = /([0-9a-f]{40})\n(.*)\n(.*)\n(.*)\n(.*)\n(.*)\n(.*)(?:\n([^]*?))?(?:\x00)(?:\n((?:.*)files? changed(?:.*))$)?/gm;
917
918
export function parseGitCommits(data: string): Commit[] {
919
const commits: Commit[] = [];
920
921
let ref;
922
let authorName;
923
let authorEmail;
924
let authorDate;
925
let commitDate;
926
let parents;
927
let refNames;
928
let message;
929
let shortStat;
930
let match;
931
932
do {
933
match = commitRegex.exec(data);
934
if (match === null) {
935
break;
936
}
937
938
[, ref, authorName, authorEmail, authorDate, commitDate, parents, refNames, message, shortStat] = match;
939
940
if (message[message.length - 1] === '\n') {
941
message = message.substr(0, message.length - 1);
942
}
943
944
// Stop excessive memory usage by using substr -- https://bugs.chromium.org/p/v8/issues/detail?id=2869
945
commits.push({
946
hash: ` ${ref}`.substr(1),
947
message: ` ${message}`.substr(1),
948
parents: parents ? parents.split(' ') : [],
949
authorDate: new Date(Number(authorDate) * 1000),
950
authorName: ` ${authorName}`.substr(1),
951
authorEmail: ` ${authorEmail}`.substr(1),
952
commitDate: new Date(Number(commitDate) * 1000),
953
refNames: refNames.split(',').map(s => s.trim()),
954
shortStat: shortStat ? parseGitDiffShortStat(shortStat) : undefined
955
});
956
} while (true);
957
958
return commits;
959
}
960
961
const diffShortStatRegex = /(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?/;
962
963
function parseGitDiffShortStat(data: string): CommitShortStat {
964
const matches = data.trim().match(diffShortStatRegex);
965
966
if (!matches) {
967
return { files: 0, insertions: 0, deletions: 0 };
968
}
969
970
const [, files, insertions = undefined, deletions = undefined] = matches;
971
return { files: parseInt(files), insertions: parseInt(insertions ?? '0'), deletions: parseInt(deletions ?? '0') };
972
}
973
974
export interface LsTreeElement {
975
mode: string;
976
type: string;
977
object: string;
978
size: string;
979
file: string;
980
}
981
982
export function parseLsTree(raw: string): LsTreeElement[] {
983
return raw.split('\n')
984
.filter(l => !!l)
985
.map(line => /^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(.*)$/.exec(line)!)
986
.filter(m => !!m)
987
.map(([, mode, type, object, size, file]) => ({ mode, type, object, size, file }));
988
}
989
990
interface LsFilesElement {
991
mode: string;
992
object: string;
993
stage: string;
994
file: string;
995
}
996
997
export function parseLsFiles(raw: string): LsFilesElement[] {
998
return raw.split('\n')
999
.filter(l => !!l)
1000
.map(line => /^(\S+)\s+(\S+)\s+(\S+)\s+(.*)$/.exec(line)!)
1001
.filter(m => !!m)
1002
.map(([, mode, object, stage, file]) => ({ mode, object, stage, file }));
1003
}
1004
1005
const stashRegex = /([0-9a-f]{40})\n(.*)\nstash@{(\d+)}\n(WIP\s)?on\s([^:]+):\s(.*)\n(\d+)\n(\d+)(?:\x00)/gmi;
1006
1007
function parseGitStashes(raw: string): Stash[] {
1008
const result: Stash[] = [];
1009
1010
let match, hash, parents, index, wip, branchName, description, authorDate, commitDate;
1011
1012
do {
1013
match = stashRegex.exec(raw);
1014
if (match === null) {
1015
break;
1016
}
1017
1018
[, hash, parents, index, wip, branchName, description, authorDate, commitDate] = match;
1019
result.push({
1020
hash,
1021
parents: parents.split(' '),
1022
index: parseInt(index),
1023
branchName: branchName.trim(),
1024
description: wip ? `WIP (${description.trim()})` : description.trim(),
1025
authorDate: authorDate ? new Date(Number(authorDate) * 1000) : undefined,
1026
commitDate: commitDate ? new Date(Number(commitDate) * 1000) : undefined,
1027
});
1028
} while (true);
1029
1030
return result;
1031
}
1032
1033
function parseGitChanges(repositoryRoot: string, raw: string): Change[] {
1034
let index = 0;
1035
const result: Change[] = [];
1036
const segments = raw.trim().split('\x00').filter(s => s);
1037
1038
segmentsLoop:
1039
while (index < segments.length - 1) {
1040
const change = segments[index++];
1041
const resourcePath = segments[index++];
1042
1043
if (!change || !resourcePath) {
1044
break;
1045
}
1046
1047
const originalUri = Uri.file(path.isAbsolute(resourcePath) ? resourcePath : path.join(repositoryRoot, resourcePath));
1048
1049
let uri = originalUri;
1050
let renameUri = originalUri;
1051
let status = Status.UNTRACKED;
1052
1053
// Copy or Rename status comes with a number (ex: 'R100').
1054
// We don't need the number, we use only first character of the status.
1055
switch (change[0]) {
1056
case 'A':
1057
status = Status.INDEX_ADDED;
1058
break;
1059
1060
case 'M':
1061
status = Status.MODIFIED;
1062
break;
1063
1064
case 'D':
1065
status = Status.DELETED;
1066
break;
1067
1068
// Rename contains two paths, the second one is what the file is renamed/copied to.
1069
case 'R': {
1070
if (index >= segments.length) {
1071
break;
1072
}
1073
1074
const newPath = segments[index++];
1075
if (!newPath) {
1076
break;
1077
}
1078
1079
status = Status.INDEX_RENAMED;
1080
uri = renameUri = Uri.file(path.isAbsolute(newPath) ? newPath : path.join(repositoryRoot, newPath));
1081
break;
1082
}
1083
default:
1084
// Unknown status
1085
break segmentsLoop;
1086
}
1087
1088
result.push({ status, uri, originalUri, renameUri });
1089
}
1090
1091
return result;
1092
}
1093
1094
function parseGitChangesRaw(repositoryRoot: string, raw: string): DiffChange[] {
1095
const changes: Change[] = [];
1096
const numStats = new Map<string, { insertions: number; deletions: number }>();
1097
1098
let index = 0;
1099
const segments = raw.trim().split('\x00').filter(s => s);
1100
1101
segmentsLoop:
1102
while (index < segments.length) {
1103
const segment = segments[index++];
1104
if (!segment) {
1105
break;
1106
}
1107
1108
if (segment.startsWith(':')) {
1109
// Parse --raw output
1110
const [, , , , change] = segment.split(' ');
1111
const filePath = segments[index++];
1112
const originalUri = Uri.file(path.isAbsolute(filePath) ? filePath : path.join(repositoryRoot, filePath));
1113
1114
let uri = originalUri;
1115
let renameUri = originalUri;
1116
let status = Status.UNTRACKED;
1117
1118
switch (change[0]) {
1119
case 'A':
1120
status = Status.INDEX_ADDED;
1121
break;
1122
case 'M':
1123
status = Status.MODIFIED;
1124
break;
1125
case 'D':
1126
status = Status.DELETED;
1127
break;
1128
case 'R': {
1129
if (index >= segments.length) {
1130
break;
1131
}
1132
const newPath = segments[index++];
1133
if (!newPath) {
1134
break;
1135
}
1136
1137
status = Status.INDEX_RENAMED;
1138
uri = renameUri = Uri.file(path.isAbsolute(newPath) ? newPath : path.join(repositoryRoot, newPath));
1139
break;
1140
}
1141
default:
1142
// Unknown status
1143
break segmentsLoop;
1144
}
1145
1146
changes.push({ status, uri, originalUri, renameUri });
1147
} else {
1148
// Parse --numstat output
1149
const [insertions, deletions, filePath] = segment.split('\t');
1150
1151
let numstatPath: string;
1152
if (filePath === '') {
1153
// For renamed files, filePath is empty and the old/new paths
1154
// are in the next two null-terminated segments. We skip the
1155
// old path and use the new path for the stats key.
1156
index++;
1157
1158
const renamePath = segments[index++];
1159
numstatPath = path.isAbsolute(renamePath) ? renamePath : path.join(repositoryRoot, renamePath);
1160
} else {
1161
numstatPath = path.isAbsolute(filePath) ? filePath : path.join(repositoryRoot, filePath);
1162
}
1163
1164
numStats.set(numstatPath, {
1165
insertions: insertions === '-' ? 0 : parseInt(insertions),
1166
deletions: deletions === '-' ? 0 : parseInt(deletions),
1167
});
1168
}
1169
}
1170
1171
return changes.map(change => ({
1172
...change,
1173
insertions: numStats.get(change.uri.fsPath)?.insertions ?? 0,
1174
deletions: numStats.get(change.uri.fsPath)?.deletions ?? 0,
1175
}));
1176
}
1177
1178
export interface BlameInformation {
1179
readonly hash: string;
1180
readonly subject?: string;
1181
readonly authorName?: string;
1182
readonly authorEmail?: string;
1183
readonly authorDate?: number;
1184
readonly ranges: {
1185
readonly startLineNumber: number;
1186
readonly endLineNumber: number;
1187
}[];
1188
}
1189
1190
function parseGitBlame(data: string): BlameInformation[] {
1191
const lineSeparator = /\r?\n/;
1192
const commitRegex = /^([0-9a-f]{40})/gm;
1193
1194
const blameInformation = new Map<string, BlameInformation>();
1195
1196
let commitHash: string | undefined = undefined;
1197
let authorName: string | undefined = undefined;
1198
let authorEmail: string | undefined = undefined;
1199
let authorTime: number | undefined = undefined;
1200
let message: string | undefined = undefined;
1201
let startLineNumber: number | undefined = undefined;
1202
let endLineNumber: number | undefined = undefined;
1203
1204
for (const line of data.split(lineSeparator)) {
1205
// Commit
1206
const commitMatch = line.match(commitRegex);
1207
if (!commitHash && commitMatch) {
1208
const segments = line.split(' ');
1209
1210
commitHash = commitMatch[0];
1211
startLineNumber = Number(segments[2]);
1212
endLineNumber = Number(segments[2]) + Number(segments[3]) - 1;
1213
}
1214
1215
// Commit properties
1216
if (commitHash && line.startsWith('author ')) {
1217
authorName = line.substring('author '.length);
1218
}
1219
if (commitHash && line.startsWith('author-mail ')) {
1220
authorEmail = line.substring('author-mail <'.length, line.length - 1);
1221
}
1222
if (commitHash && line.startsWith('author-time ')) {
1223
authorTime = Number(line.substring('author-time '.length)) * 1000;
1224
}
1225
if (commitHash && line.startsWith('summary ')) {
1226
message = line.substring('summary '.length);
1227
}
1228
1229
// Commit end
1230
if (commitHash && startLineNumber && endLineNumber && line.startsWith('filename ')) {
1231
const existingCommit = blameInformation.get(commitHash);
1232
if (existingCommit) {
1233
existingCommit.ranges.push({ startLineNumber, endLineNumber });
1234
blameInformation.set(commitHash, existingCommit);
1235
} else {
1236
blameInformation.set(commitHash, {
1237
hash: commitHash, authorName, authorEmail, authorDate: authorTime, subject: message, ranges: [{ startLineNumber, endLineNumber }]
1238
});
1239
}
1240
1241
commitHash = authorName = authorEmail = authorTime = message = startLineNumber = endLineNumber = undefined;
1242
}
1243
}
1244
1245
return Array.from(blameInformation.values());
1246
}
1247
1248
const REFS_FORMAT = '%(refname)%00%(objectname)%00%(*objectname)';
1249
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)`;
1250
1251
function parseRefs(data: string): (Ref | Branch)[] {
1252
const refRegex = /^(refs\/[^\0]+)\0([0-9a-f]{40})\0([0-9a-f]{40})?(?:\0(.*))?$/gm;
1253
1254
const headRegex = /^refs\/heads\/([^ ]+)$/;
1255
const remoteHeadRegex = /^refs\/remotes\/([^/]+)\/([^ ]+)$/;
1256
const tagRegex = /^refs\/tags\/([^ ]+)$/;
1257
const statusRegex = /\[(?:ahead ([0-9]+))?[,\s]*(?:behind ([0-9]+))?]|\[gone]/;
1258
1259
let ref: string | undefined;
1260
let commitHash: string | undefined;
1261
let tagCommitHash: string | undefined;
1262
let details: string | undefined;
1263
let commitParents: string | undefined;
1264
let tagCommitParents: string | undefined;
1265
let commitSubject: string | undefined;
1266
let tagCommitSubject: string | undefined;
1267
let authorName: string | undefined;
1268
let tagAuthorName: string | undefined;
1269
let committerDate: string | undefined;
1270
let tagCommitterDate: string | undefined;
1271
let status: string | undefined;
1272
1273
const refs: (Ref | Branch)[] = [];
1274
1275
let match: RegExpExecArray | null;
1276
let refMatch: RegExpExecArray | null;
1277
1278
do {
1279
match = refRegex.exec(data);
1280
if (match === null) {
1281
break;
1282
}
1283
1284
[, ref, commitHash, tagCommitHash, details] = match;
1285
[commitParents, tagCommitParents, authorName, tagAuthorName, committerDate, tagCommitterDate, commitSubject, tagCommitSubject, status] = details?.split('\0') ?? [];
1286
1287
const parents = tagCommitParents || commitParents;
1288
const subject = tagCommitSubject || commitSubject;
1289
const author = tagAuthorName || authorName;
1290
const date = tagCommitterDate || committerDate;
1291
1292
const commitDetails = parents && subject && author && date
1293
? {
1294
hash: commitHash,
1295
message: subject,
1296
parents: parents.split(' '),
1297
authorName: author,
1298
commitDate: date ? new Date(Number(date) * 1000) : undefined,
1299
} satisfies ApiCommit : undefined;
1300
1301
if (refMatch = headRegex.exec(ref)) {
1302
const [, aheadCount, behindCount] = statusRegex.exec(status) ?? [];
1303
const ahead = status ? aheadCount ? Number(aheadCount) : 0 : undefined;
1304
const behind = status ? behindCount ? Number(behindCount) : 0 : undefined;
1305
refs.push({ name: refMatch[1], commit: commitHash, commitDetails, ahead, behind, type: RefType.Head });
1306
} else if (refMatch = remoteHeadRegex.exec(ref)) {
1307
const name = `${refMatch[1]}/${refMatch[2]}`;
1308
refs.push({ name, remote: refMatch[1], commit: commitHash, commitDetails, type: RefType.RemoteHead });
1309
} else if (refMatch = tagRegex.exec(ref)) {
1310
refs.push({ name: refMatch[1], commit: tagCommitHash ?? commitHash, commitDetails, type: RefType.Tag });
1311
}
1312
} while (true);
1313
1314
return refs;
1315
}
1316
1317
export interface PullOptions {
1318
readonly unshallow?: boolean;
1319
readonly tags?: boolean;
1320
readonly autoStash?: boolean;
1321
readonly cancellationToken?: CancellationToken;
1322
}
1323
1324
export interface Worktree extends ApiWorktree {
1325
readonly commitDetails?: ApiCommit;
1326
}
1327
1328
export class Repository {
1329
private _isUsingRefTable = false;
1330
1331
constructor(
1332
private _git: Git,
1333
private repositoryRoot: string,
1334
private repositoryRootRealPath: string | undefined,
1335
readonly dotGit: IDotGit,
1336
private logger: LogOutputChannel
1337
) {
1338
this._kind = this.dotGit.commonPath
1339
? 'worktree'
1340
: this.dotGit.superProjectPath
1341
? 'submodule'
1342
: 'repository';
1343
}
1344
1345
private readonly _kind: 'repository' | 'submodule' | 'worktree';
1346
get kind(): 'repository' | 'submodule' | 'worktree' {
1347
return this._kind;
1348
}
1349
1350
get git(): Git {
1351
return this._git;
1352
}
1353
1354
get root(): string {
1355
return this.repositoryRoot;
1356
}
1357
1358
get rootRealPath(): string | undefined {
1359
return this.repositoryRootRealPath;
1360
}
1361
1362
async exec(args: string[], options: SpawnOptions = {}): Promise<IExecutionResult<string>> {
1363
return await this.git.exec(this.repositoryRoot, args, options);
1364
}
1365
1366
stream(args: string[], options: SpawnOptions = {}): cp.ChildProcess {
1367
return this.git.stream(this.repositoryRoot, args, options);
1368
}
1369
1370
spawn(args: string[], options: SpawnOptions = {}): cp.ChildProcess {
1371
return this.git.spawn(args, options);
1372
}
1373
1374
async config(command: string, scope: string, key: string, value: any = null, options: SpawnOptions = {}): Promise<string> {
1375
const args = ['config', `--${command}`];
1376
1377
if (scope) {
1378
args.push(`--${scope}`);
1379
}
1380
1381
args.push(key);
1382
1383
if (value) {
1384
args.push(value);
1385
}
1386
1387
try {
1388
const result = await this.exec(args, options);
1389
return result.stdout.trim();
1390
}
1391
catch (err) {
1392
this.logger.warn(`[Git][config] git config failed: ${err.message}`);
1393
return '';
1394
}
1395
}
1396
1397
async getConfigs(scope: string): Promise<{ key: string; value: string }[]> {
1398
const args = ['config'];
1399
1400
if (scope) {
1401
args.push('--' + scope);
1402
}
1403
1404
args.push('-l');
1405
1406
const result = await this.exec(args);
1407
const lines = result.stdout.trim().split(/\r|\r\n|\n/);
1408
1409
return lines.map(entry => {
1410
const equalsIndex = entry.indexOf('=');
1411
return { key: entry.substr(0, equalsIndex), value: entry.substr(equalsIndex + 1) };
1412
});
1413
}
1414
1415
async log(options?: LogOptions, cancellationToken?: CancellationToken): Promise<Commit[]> {
1416
const spawnOptions: SpawnOptions = { cancellationToken };
1417
const args = ['log', `--format=${COMMIT_FORMAT}`, '-z'];
1418
1419
if (options?.shortStats) {
1420
args.push('--shortstat');
1421
1422
if (this._git.compareGitVersionTo('2.31') !== -1) {
1423
args.push('--diff-merges=first-parent');
1424
}
1425
}
1426
1427
if (options?.reverse) {
1428
args.push('--reverse', '--ancestry-path');
1429
}
1430
1431
if (options?.sortByAuthorDate) {
1432
args.push('--author-date-order');
1433
}
1434
1435
if (options?.range) {
1436
args.push(options.range);
1437
} else {
1438
args.push(`-n${options?.maxEntries ?? 32}`);
1439
}
1440
1441
if (options?.author) {
1442
args.push(`--author=${options.author}`);
1443
}
1444
1445
if (options?.grep) {
1446
args.push(`--grep=${options.grep}`);
1447
args.push('--extended-regexp');
1448
args.push('--regexp-ignore-case');
1449
}
1450
1451
if (typeof options?.maxParents === 'number') {
1452
args.push(`--max-parents=${options.maxParents}`);
1453
}
1454
1455
if (typeof options?.skip === 'number') {
1456
args.push(`--skip=${options.skip}`);
1457
}
1458
1459
if (options?.refNames) {
1460
args.push('--topo-order');
1461
args.push('--decorate=full');
1462
1463
// In order to avoid hitting the command line limit due to large number of reference
1464
// names (can happen when the `all` filter is used in the Source Control Graph view),
1465
// we are passing the reference names via stdin.
1466
spawnOptions.input = options.refNames.join('\n');
1467
args.push('--stdin');
1468
}
1469
1470
if (options?.path) {
1471
args.push('--', options.path);
1472
}
1473
1474
const result = await this.exec(args, spawnOptions);
1475
if (result.exitCode) {
1476
// An empty repo
1477
return [];
1478
}
1479
1480
return parseGitCommits(result.stdout);
1481
}
1482
1483
async logFile(uri: Uri, options?: LogFileOptions, cancellationToken?: CancellationToken): Promise<Commit[]> {
1484
const args = ['log', `--format=${COMMIT_FORMAT}`, '-z'];
1485
1486
if (options?.maxEntries && !options?.reverse) {
1487
args.push(`-n${options.maxEntries}`);
1488
}
1489
1490
if (options?.hash) {
1491
// If we are reversing, we must add a range (with HEAD) because we are using --ancestry-path for better reverse walking
1492
if (options?.reverse) {
1493
args.push('--reverse', '--ancestry-path', `${options.hash}..HEAD`);
1494
} else {
1495
args.push(options.hash);
1496
}
1497
}
1498
1499
if (options?.shortStats) {
1500
args.push('--shortstat');
1501
}
1502
1503
if (options?.sortByAuthorDate) {
1504
args.push('--author-date-order');
1505
}
1506
1507
if (options?.follow) {
1508
args.push('--follow');
1509
}
1510
1511
args.push('--', uri.fsPath);
1512
1513
try {
1514
const result = await this.exec(args, { cancellationToken });
1515
if (result.exitCode) {
1516
// No file history, e.g. a new file or untracked
1517
return [];
1518
}
1519
1520
return parseGitCommits(result.stdout);
1521
} catch (err) {
1522
// Repository has no commits yet
1523
if (/does not have any commits yet/.test(err.stderr)) {
1524
return [];
1525
}
1526
1527
throw err;
1528
}
1529
}
1530
1531
async reflog(ref: string, pattern: string): Promise<string[]> {
1532
const args = ['reflog', ref, `--grep-reflog=${pattern}`];
1533
const result = await this.exec(args);
1534
if (result.exitCode) {
1535
return [];
1536
}
1537
1538
return result.stdout.split('\n')
1539
.filter(entry => !!entry);
1540
}
1541
1542
async buffer(ref: string, filePath: string): Promise<Buffer> {
1543
const relativePath = this.sanitizeRelativePath(filePath);
1544
const child = this.stream(['show', '--textconv', `${ref}:${relativePath}`]);
1545
1546
if (!child.stdout) {
1547
return Promise.reject<Buffer>('Can\'t open file from git');
1548
}
1549
1550
const { exitCode, stdout, stderr } = await exec(child);
1551
1552
if (exitCode) {
1553
const err = new GitError({
1554
message: 'Could not show object.',
1555
exitCode
1556
});
1557
1558
if (/exists on disk, but not in/.test(stderr)) {
1559
err.gitErrorCode = GitErrorCodes.WrongCase;
1560
}
1561
1562
return Promise.reject<Buffer>(err);
1563
}
1564
1565
return stdout;
1566
}
1567
1568
async getObjectDetails(treeish: string, path: string): Promise<{ mode: string; object: string; size: number }> {
1569
if (!treeish || treeish === ':1' || treeish === ':2' || treeish === ':3') { // index
1570
const elements = await this.lsfiles(path);
1571
1572
if (elements.length === 0) {
1573
throw new GitError({ message: 'Path not known by git', gitErrorCode: GitErrorCodes.UnknownPath });
1574
}
1575
1576
const { mode, object } = treeish !== ''
1577
? elements.find(e => e.stage === treeish.substring(1)) ?? elements[0]
1578
: elements[0];
1579
1580
const catFile = await this.exec(['cat-file', '-s', object]);
1581
const size = parseInt(catFile.stdout);
1582
1583
return { mode, object, size };
1584
}
1585
1586
const elements = await this.lstree(treeish, path);
1587
1588
if (elements.length === 0) {
1589
throw new GitError({ message: 'Path not known by git', gitErrorCode: GitErrorCodes.UnknownPath });
1590
}
1591
1592
const { mode, object, size } = elements[0];
1593
return { mode, object, size: parseInt(size) || 0 };
1594
}
1595
1596
async lstree(treeish: string, path?: string, options?: { recursive?: boolean }): Promise<LsTreeElement[]> {
1597
const args = ['ls-tree', '-l'];
1598
1599
if (options?.recursive) {
1600
args.push('-r');
1601
}
1602
1603
args.push(treeish);
1604
1605
if (path) {
1606
args.push('--', this.sanitizeRelativePath(path));
1607
}
1608
1609
const { stdout } = await this.exec(args);
1610
return parseLsTree(stdout);
1611
}
1612
1613
async lsfiles(path: string): Promise<LsFilesElement[]> {
1614
const args = ['ls-files', '--stage'];
1615
const relativePath = this.sanitizeRelativePath(path);
1616
1617
if (relativePath) {
1618
args.push('--', relativePath);
1619
}
1620
1621
const { stdout } = await this.exec(args);
1622
return parseLsFiles(stdout);
1623
}
1624
1625
async getGitFilePath(ref: string, filePath: string): Promise<string> {
1626
const elements: { file: string }[] = ref
1627
? await this.lstree(ref, undefined, { recursive: true })
1628
: await this.lsfiles(this.repositoryRoot);
1629
1630
const relativePathLowercase = this.sanitizeRelativePath(filePath).toLowerCase();
1631
const element = elements.find(file => file.file.toLowerCase() === relativePathLowercase);
1632
1633
if (!element) {
1634
throw new GitError({
1635
message: `Git relative path not found. Was looking for ${relativePathLowercase} among ${JSON.stringify(elements.map(({ file }) => file), null, 2)}`,
1636
});
1637
}
1638
1639
return path.join(this.repositoryRoot, element.file);
1640
}
1641
1642
async detectObjectType(object: string): Promise<{ mimetype: string; encoding?: string }> {
1643
const child = await this.stream(['show', '--textconv', object]);
1644
const buffer = await readBytes(child.stdout!, 4100);
1645
1646
try {
1647
child.kill();
1648
} catch (err) {
1649
// noop
1650
}
1651
1652
const encoding = detectUnicodeEncoding(buffer);
1653
let isText = true;
1654
1655
if (encoding !== Encoding.UTF16be && encoding !== Encoding.UTF16le) {
1656
for (let i = 0; i < buffer.length; i++) {
1657
if (buffer.readInt8(i) === 0) {
1658
isText = false;
1659
break;
1660
}
1661
}
1662
}
1663
1664
if (!isText) {
1665
const result = await filetype.fromBuffer(buffer);
1666
1667
if (!result) {
1668
return { mimetype: 'application/octet-stream' };
1669
} else {
1670
return { mimetype: result.mime };
1671
}
1672
}
1673
1674
if (encoding) {
1675
return { mimetype: 'text/plain', encoding };
1676
} else {
1677
// TODO@JOAO: read the setting OUTSIDE!
1678
return { mimetype: 'text/plain' };
1679
}
1680
}
1681
1682
async apply(patch: string, options?: { reverse?: boolean; threeWay?: boolean; allowEmpty?: boolean }): Promise<void> {
1683
const args = ['apply', patch];
1684
1685
if (options?.allowEmpty) {
1686
args.push('--allow-empty');
1687
}
1688
1689
if (options?.reverse) {
1690
args.push('--reverse');
1691
}
1692
1693
if (options?.threeWay) {
1694
args.push('--3way');
1695
}
1696
1697
try {
1698
await this.exec(args);
1699
} catch (err) {
1700
if (/patch does not apply/.test(err.stderr)) {
1701
err.gitErrorCode = GitErrorCodes.PatchDoesNotApply;
1702
}
1703
1704
throw err;
1705
}
1706
}
1707
1708
async diff(cached = false): Promise<string> {
1709
const args = ['diff'];
1710
1711
if (cached) {
1712
args.push('--cached');
1713
}
1714
1715
const result = await this.exec(args);
1716
return result.stdout;
1717
}
1718
1719
diffWithHEAD(): Promise<Change[]>;
1720
diffWithHEAD(path: string): Promise<string>;
1721
diffWithHEAD(path?: string | undefined): Promise<string | Change[]>;
1722
async diffWithHEAD(path?: string | undefined): Promise<string | Change[]> {
1723
if (!path) {
1724
return await this.diffFiles(undefined, { cached: false });
1725
}
1726
1727
const args = ['diff', '--', this.sanitizeRelativePath(path)];
1728
const result = await this.exec(args);
1729
return result.stdout;
1730
}
1731
1732
async diffWithHEADShortStats(path?: string): Promise<CommitShortStat> {
1733
return this.diffFilesShortStat(undefined, { cached: false, path });
1734
}
1735
1736
diffWith(ref: string): Promise<Change[]>;
1737
diffWith(ref: string, path: string): Promise<string>;
1738
diffWith(ref: string, path?: string | undefined): Promise<string | Change[]>;
1739
async diffWith(ref: string, path?: string): Promise<string | Change[]> {
1740
if (!path) {
1741
return await this.diffFiles(ref, { cached: false });
1742
}
1743
1744
const args = ['diff', ref, '--', this.sanitizeRelativePath(path)];
1745
const result = await this.exec(args);
1746
return result.stdout;
1747
}
1748
1749
diffIndexWithHEAD(): Promise<Change[]>;
1750
diffIndexWithHEAD(path: string): Promise<string>;
1751
diffIndexWithHEAD(path?: string | undefined): Promise<Change[]>;
1752
async diffIndexWithHEAD(path?: string): Promise<string | Change[]> {
1753
if (!path) {
1754
return await this.diffFiles(undefined, { cached: true });
1755
}
1756
1757
const args = ['diff', '--cached', '--', this.sanitizeRelativePath(path)];
1758
const result = await this.exec(args);
1759
return result.stdout;
1760
}
1761
1762
async diffIndexWithHEADShortStats(path?: string): Promise<CommitShortStat> {
1763
return this.diffFilesShortStat(undefined, { cached: true, path });
1764
}
1765
1766
diffIndexWith(ref: string): Promise<Change[]>;
1767
diffIndexWith(ref: string, path: string): Promise<string>;
1768
diffIndexWith(ref: string, path?: string | undefined): Promise<string | Change[]>;
1769
async diffIndexWith(ref: string, path?: string): Promise<string | Change[]> {
1770
if (!path) {
1771
return await this.diffFiles(ref, { cached: true });
1772
}
1773
1774
const args = ['diff', '--cached', ref, '--', this.sanitizeRelativePath(path)];
1775
const result = await this.exec(args);
1776
return result.stdout;
1777
}
1778
1779
async diffBlobs(object1: string, object2: string): Promise<string> {
1780
const args = ['diff', object1, object2];
1781
const result = await this.exec(args);
1782
return result.stdout;
1783
}
1784
1785
diffBetween(ref1: string, ref2: string): Promise<Change[]>;
1786
diffBetween(ref1: string, ref2: string, path: string): Promise<string>;
1787
diffBetween(ref1: string, ref2: string, path?: string | undefined): Promise<string | Change[]>;
1788
async diffBetween(ref1: string, ref2: string, path?: string): Promise<string | Change[]> {
1789
const range = `${ref1}...${ref2}`;
1790
if (!path) {
1791
return await this.diffFiles(range, { cached: false });
1792
}
1793
1794
const args = ['diff', range, '--', this.sanitizeRelativePath(path)];
1795
const result = await this.exec(args);
1796
1797
return result.stdout.trim();
1798
}
1799
1800
async diffBetweenPatch(ref: string, options: { path?: string }): Promise<string> {
1801
const args = ['diff', ref, '--'];
1802
1803
if (options.path) {
1804
args.push(this.sanitizeRelativePath(options.path));
1805
}
1806
1807
const result = await this.exec(args);
1808
return result.stdout;
1809
}
1810
1811
async diffBetweenWithStats(ref: string, options: { path?: string; similarityThreshold?: number }): Promise<DiffChange[]> {
1812
const args = ['diff', '--raw', '--numstat', '--diff-filter=ADMR', '-z',];
1813
1814
if (options.similarityThreshold) {
1815
args.push(`--find-renames=${options.similarityThreshold}%`);
1816
}
1817
1818
args.push(...[ref, '--']);
1819
if (options.path) {
1820
args.push(this.sanitizeRelativePath(options.path));
1821
}
1822
1823
const gitResult = await this.exec(args);
1824
if (gitResult.exitCode) {
1825
return [];
1826
}
1827
1828
return parseGitChangesRaw(this.repositoryRoot, gitResult.stdout);
1829
}
1830
1831
private async diffFiles(ref: string | undefined, options: { cached: boolean; similarityThreshold?: number }): Promise<Change[]> {
1832
const args = ['diff', '--name-status', '-z', '--diff-filter=ADMR'];
1833
1834
if (options.cached) {
1835
args.push('--cached');
1836
}
1837
1838
if (options.similarityThreshold) {
1839
args.push(`--find-renames=${options.similarityThreshold}%`);
1840
}
1841
1842
if (ref) {
1843
args.push(ref);
1844
}
1845
1846
args.push('--');
1847
1848
const gitResult = await this.exec(args);
1849
if (gitResult.exitCode) {
1850
return [];
1851
}
1852
1853
return parseGitChanges(this.repositoryRoot, gitResult.stdout);
1854
}
1855
1856
private async diffFilesShortStat(ref: string | undefined, options: { cached: boolean; path?: string }): Promise<CommitShortStat> {
1857
const args = ['diff', '--shortstat'];
1858
1859
if (options.cached) {
1860
args.push('--cached');
1861
}
1862
1863
if (ref !== undefined) {
1864
args.push(ref);
1865
}
1866
1867
args.push('--');
1868
1869
if (options.path) {
1870
args.push(this.sanitizeRelativePath(options.path));
1871
}
1872
1873
const result = await this.exec(args);
1874
if (result.exitCode) {
1875
return { files: 0, insertions: 0, deletions: 0 };
1876
}
1877
1878
return parseGitDiffShortStat(result.stdout.trim());
1879
}
1880
1881
1882
async diffTrees(treeish1: string, treeish2?: string, options?: { similarityThreshold?: number }): Promise<DiffChange[]> {
1883
const args = ['diff-tree', '-r', '--raw', '--numstat', '--diff-filter=ADMR', '-z'];
1884
1885
if (options?.similarityThreshold) {
1886
args.push(`--find-renames=${options.similarityThreshold}%`);
1887
}
1888
1889
args.push(treeish1);
1890
1891
if (treeish2) {
1892
args.push(treeish2);
1893
}
1894
1895
args.push('--');
1896
1897
const gitResult = await this.exec(args);
1898
if (gitResult.exitCode) {
1899
return [];
1900
}
1901
1902
return parseGitChangesRaw(this.repositoryRoot, gitResult.stdout);
1903
}
1904
1905
async getMergeBase(ref1: string, ref2: string, ...refs: string[]): Promise<string | undefined> {
1906
try {
1907
const args = ['merge-base'];
1908
if (refs.length !== 0) {
1909
args.push('--octopus');
1910
args.push(...refs);
1911
}
1912
1913
args.push(ref1, ref2);
1914
1915
const result = await this.exec(args);
1916
1917
return result.stdout.trim();
1918
}
1919
catch (err) {
1920
return undefined;
1921
}
1922
}
1923
1924
async hashObject(data: string): Promise<string> {
1925
const args = ['hash-object', '-w', '--stdin'];
1926
const result = await this.exec(args, { input: data });
1927
1928
return result.stdout.trim();
1929
}
1930
1931
async add(paths: string[], opts?: { update?: boolean }): Promise<void> {
1932
const args = ['add'];
1933
1934
if (opts && opts.update) {
1935
args.push('-u');
1936
} else {
1937
args.push('-A');
1938
}
1939
1940
if (paths && paths.length) {
1941
for (const chunk of splitInChunks(paths.map(p => this.sanitizeRelativePath(p)), MAX_CLI_LENGTH)) {
1942
await this.exec([...args, '--', ...chunk]);
1943
}
1944
} else {
1945
await this.exec([...args, '--', '.']);
1946
}
1947
}
1948
1949
async rm(paths: string[]): Promise<void> {
1950
const args = ['rm', '--'];
1951
1952
if (!paths || !paths.length) {
1953
return;
1954
}
1955
1956
args.push(...paths.map(p => this.sanitizeRelativePath(p)));
1957
1958
await this.exec(args);
1959
}
1960
1961
async stage(path: string, data: Uint8Array): Promise<void> {
1962
const relativePath = this.sanitizeRelativePath(path);
1963
const child = this.stream(['hash-object', '--stdin', '-w', '--path', relativePath], { stdio: [null, null, null] });
1964
1965
if (!child.stdin) {
1966
throw new GitError({
1967
message: 'Failed to spawn git process',
1968
exitCode: -1
1969
});
1970
}
1971
1972
child.stdin.end(data);
1973
1974
const { exitCode, stdout } = await exec(child);
1975
const hash = stdout.toString('utf8');
1976
1977
if (exitCode) {
1978
throw new GitError({
1979
message: 'Could not hash object.',
1980
exitCode: exitCode
1981
});
1982
}
1983
1984
const treeish = await this.getCommit('HEAD').then(() => 'HEAD', () => '');
1985
let mode: string;
1986
let add: string = '';
1987
1988
try {
1989
const details = await this.getObjectDetails(treeish, path);
1990
mode = details.mode;
1991
} catch (err) {
1992
if (err.gitErrorCode !== GitErrorCodes.UnknownPath) {
1993
throw err;
1994
}
1995
1996
mode = '100644';
1997
add = '--add';
1998
}
1999
2000
await this.exec(['update-index', add, '--cacheinfo', mode, hash, relativePath]);
2001
}
2002
2003
async checkout(treeish: string, paths: string[], opts: { track?: boolean; detached?: boolean } = Object.create(null)): Promise<void> {
2004
const args = ['checkout', '-q'];
2005
2006
if (opts.track) {
2007
args.push('--track');
2008
}
2009
2010
if (opts.detached) {
2011
args.push('--detach');
2012
}
2013
2014
if (treeish) {
2015
args.push(treeish);
2016
}
2017
2018
try {
2019
if (paths && paths.length > 0) {
2020
for (const chunk of splitInChunks(paths.map(p => this.sanitizeRelativePath(p)), MAX_CLI_LENGTH)) {
2021
await this.exec([...args, '--', ...chunk]);
2022
}
2023
} else {
2024
await this.exec(args);
2025
}
2026
} catch (err) {
2027
if (/Please,? commit your changes or stash them/.test(err.stderr || '')) {
2028
err.gitErrorCode = GitErrorCodes.DirtyWorkTree;
2029
err.gitTreeish = treeish;
2030
} else if (/You are on a branch yet to be born/.test(err.stderr || '')) {
2031
err.gitErrorCode = GitErrorCodes.BranchNotYetBorn;
2032
}
2033
2034
throw err;
2035
}
2036
}
2037
2038
async commit(message: string | undefined, opts: CommitOptions = Object.create(null)): Promise<void> {
2039
const args = ['commit', '--quiet'];
2040
const options: SpawnOptions = {};
2041
2042
if (message) {
2043
options.input = message;
2044
args.push('--allow-empty-message', '--file', '-');
2045
}
2046
2047
if (opts.verbose) {
2048
args.push('--verbose');
2049
}
2050
2051
if (opts.all) {
2052
args.push('--all');
2053
}
2054
2055
if (opts.amend) {
2056
args.push('--amend');
2057
}
2058
2059
if (!opts.useEditor) {
2060
if (!message) {
2061
if (opts.amend) {
2062
args.push('--no-edit');
2063
} else {
2064
options.input = '';
2065
args.push('--file', '-');
2066
}
2067
}
2068
2069
args.push('--allow-empty-message');
2070
}
2071
2072
if (opts.signoff) {
2073
args.push('--signoff');
2074
}
2075
2076
if (opts.signCommit !== undefined) {
2077
if (opts.signCommit) {
2078
args.push('-S');
2079
} else {
2080
args.push('--no-gpg-sign');
2081
}
2082
}
2083
2084
if (opts.empty) {
2085
args.push('--allow-empty');
2086
}
2087
2088
if (opts.noVerify) {
2089
args.push('--no-verify');
2090
}
2091
2092
if (opts.requireUserConfig ?? true) {
2093
// Stops git from guessing at user/email
2094
args.splice(0, 0, '-c', 'user.useConfigOnly=true');
2095
}
2096
2097
try {
2098
await this.exec(args, options);
2099
} catch (commitErr) {
2100
await this.handleCommitError(commitErr);
2101
}
2102
}
2103
2104
async rebaseAbort(): Promise<void> {
2105
await this.exec(['rebase', '--abort']);
2106
}
2107
2108
async rebaseContinue(): Promise<void> {
2109
const args = ['rebase', '--continue'];
2110
2111
try {
2112
await this.exec(args, { env: { GIT_EDITOR: 'true' } });
2113
} catch (commitErr) {
2114
await this.handleCommitError(commitErr);
2115
}
2116
}
2117
2118
2119
private async handleCommitError(commitErr: unknown): Promise<void> {
2120
if (commitErr instanceof GitError && /not possible because you have unmerged files/.test(commitErr.stderr || '')) {
2121
commitErr.gitErrorCode = GitErrorCodes.UnmergedChanges;
2122
throw commitErr;
2123
} else if (commitErr instanceof GitError && /Aborting commit due to empty commit message/.test(commitErr.stderr || '')) {
2124
commitErr.gitErrorCode = GitErrorCodes.EmptyCommitMessage;
2125
throw commitErr;
2126
}
2127
2128
try {
2129
await this.exec(['config', '--get-all', 'user.name']);
2130
} catch (err) {
2131
err.gitErrorCode = GitErrorCodes.NoUserNameConfigured;
2132
throw err;
2133
}
2134
2135
try {
2136
await this.exec(['config', '--get-all', 'user.email']);
2137
} catch (err) {
2138
err.gitErrorCode = GitErrorCodes.NoUserEmailConfigured;
2139
throw err;
2140
}
2141
2142
throw commitErr;
2143
}
2144
2145
async branch(name: string, checkout: boolean, ref?: string): Promise<void> {
2146
const args = checkout ? ['checkout', '-q', '-b', name, '--no-track'] : ['branch', '-q', name];
2147
2148
if (ref) {
2149
args.push(ref);
2150
}
2151
2152
await this.exec(args);
2153
}
2154
2155
async deleteBranch(name: string, force?: boolean): Promise<void> {
2156
const args = ['branch', force ? '-D' : '-d', name];
2157
await this.exec(args);
2158
}
2159
2160
async renameBranch(name: string): Promise<void> {
2161
const args = ['branch', '-m', name];
2162
await this.exec(args);
2163
}
2164
2165
async move(from: string, to: string): Promise<void> {
2166
const args = ['mv', from, to];
2167
await this.exec(args);
2168
}
2169
2170
async setBranchUpstream(name: string, upstream: string): Promise<void> {
2171
const args = ['branch', '--set-upstream-to', upstream, name];
2172
await this.exec(args);
2173
}
2174
2175
async deleteRef(ref: string): Promise<void> {
2176
const args = ['update-ref', '-d', ref];
2177
await this.exec(args);
2178
}
2179
2180
async merge(ref: string): Promise<void> {
2181
const args = ['merge', ref];
2182
2183
try {
2184
await this.exec(args);
2185
} catch (err) {
2186
if (/^CONFLICT /m.test(err.stdout || '')) {
2187
err.gitErrorCode = GitErrorCodes.Conflict;
2188
}
2189
2190
throw err;
2191
}
2192
}
2193
2194
async mergeAbort(): Promise<void> {
2195
await this.exec(['merge', '--abort']);
2196
}
2197
2198
async tag(options: { name: string; message?: string; ref?: string }): Promise<void> {
2199
let args = ['tag'];
2200
2201
if (options.message) {
2202
args = [...args, '-a', options.name, '-m', options.message];
2203
} else {
2204
args = [...args, options.name];
2205
}
2206
2207
if (options.ref) {
2208
args.push(options.ref);
2209
}
2210
2211
await this.exec(args);
2212
}
2213
2214
async deleteTag(name: string): Promise<void> {
2215
const args = ['tag', '-d', name];
2216
await this.exec(args);
2217
}
2218
2219
async addWorktree(options: { path: string; commitish: string; branch?: string }): Promise<void> {
2220
const args = ['worktree', 'add'];
2221
2222
if (options.branch) {
2223
args.push('-b', options.branch);
2224
}
2225
2226
args.push(options.path, options.commitish);
2227
2228
await this.exec(args);
2229
}
2230
2231
async deleteWorktree(path: string, options?: { force?: boolean }): Promise<void> {
2232
const args = ['worktree', 'remove'];
2233
2234
if (options?.force) {
2235
args.push('--force');
2236
}
2237
2238
args.push(path);
2239
await this.exec(args);
2240
}
2241
2242
async deleteRemoteRef(remoteName: string, refName: string, options?: { force?: boolean }): Promise<void> {
2243
const args = ['push', remoteName, '--delete'];
2244
2245
if (options?.force) {
2246
args.push('--force');
2247
}
2248
2249
args.push(refName);
2250
await this.exec(args);
2251
}
2252
2253
async clean(paths: string[]): Promise<void> {
2254
const pathsByGroup = groupBy(paths.map(sanitizePath), p => path.dirname(p));
2255
const groups = Object.keys(pathsByGroup).map(k => pathsByGroup[k]);
2256
2257
const limiter = new Limiter<IExecutionResult<string>>(5);
2258
const promises: Promise<IExecutionResult<string>>[] = [];
2259
const args = ['clean', '-f', '-q'];
2260
2261
for (const paths of groups) {
2262
for (const chunk of splitInChunks(paths.map(p => this.sanitizeRelativePath(p)), MAX_CLI_LENGTH)) {
2263
promises.push(limiter.queue(() => this.exec([...args, '--', ...chunk])));
2264
}
2265
}
2266
2267
await Promise.all(promises);
2268
}
2269
2270
async undo(): Promise<void> {
2271
await this.exec(['clean', '-fd']);
2272
2273
try {
2274
await this.exec(['checkout', '--', '.']);
2275
} catch (err) {
2276
if (/did not match any file\(s\) known to git\./.test(err.stderr || '')) {
2277
return;
2278
}
2279
2280
throw err;
2281
}
2282
}
2283
2284
async reset(treeish: string, hard: boolean = false): Promise<void> {
2285
const args = ['reset', hard ? '--hard' : '--soft', treeish];
2286
await this.exec(args);
2287
}
2288
2289
async revert(treeish: string, paths: string[]): Promise<void> {
2290
const result = await this.exec(['branch']);
2291
let args: string[];
2292
2293
// In case there are no branches, we must use rm --cached
2294
if (!result.stdout) {
2295
args = ['rm', '--cached', '-r'];
2296
} else {
2297
args = ['reset', '-q', treeish];
2298
}
2299
2300
try {
2301
if (paths && paths.length > 0) {
2302
for (const chunk of splitInChunks(paths.map(p => this.sanitizeRelativePath(p)), MAX_CLI_LENGTH)) {
2303
await this.exec([...args, '--', ...chunk]);
2304
}
2305
} else {
2306
await this.exec([...args, '--', '.']);
2307
}
2308
} catch (err) {
2309
// In case there are merge conflicts to be resolved, git reset will output
2310
// some "needs merge" data. We try to get around that.
2311
if (/([^:]+: needs merge\n)+/m.test(err.stdout || '')) {
2312
return;
2313
}
2314
2315
throw err;
2316
}
2317
}
2318
2319
async addRemote(name: string, url: string): Promise<void> {
2320
const args = ['remote', 'add', name, url];
2321
await this.exec(args);
2322
}
2323
2324
async removeRemote(name: string): Promise<void> {
2325
const args = ['remote', 'remove', name];
2326
await this.exec(args);
2327
}
2328
2329
async renameRemote(name: string, newName: string): Promise<void> {
2330
const args = ['remote', 'rename', name, newName];
2331
await this.exec(args);
2332
}
2333
2334
async fetch(options: { remote?: string; ref?: string; all?: boolean; prune?: boolean; depth?: number; silent?: boolean; readonly cancellationToken?: CancellationToken } = {}): Promise<void> {
2335
const args = ['fetch'];
2336
const spawnOptions: SpawnOptions = {
2337
cancellationToken: options.cancellationToken,
2338
env: { 'GIT_HTTP_USER_AGENT': this.git.userAgent }
2339
};
2340
2341
if (options.remote) {
2342
args.push(options.remote);
2343
2344
if (options.ref) {
2345
args.push(options.ref);
2346
}
2347
} else if (options.all) {
2348
args.push('--all');
2349
}
2350
2351
if (options.prune) {
2352
args.push('--prune');
2353
}
2354
2355
if (typeof options.depth === 'number') {
2356
args.push(`--depth=${options.depth}`);
2357
}
2358
2359
if (options.silent) {
2360
spawnOptions.env!['VSCODE_GIT_FETCH_SILENT'] = 'true';
2361
}
2362
2363
try {
2364
await this.exec(args, spawnOptions);
2365
} catch (err) {
2366
if (/No remote repository specified\./.test(err.stderr || '')) {
2367
err.gitErrorCode = GitErrorCodes.NoRemoteRepositorySpecified;
2368
} else if (/Could not read from remote repository/.test(err.stderr || '')) {
2369
err.gitErrorCode = GitErrorCodes.RemoteConnectionError;
2370
} else if (/! \[rejected\].*\(non-fast-forward\)/m.test(err.stderr || '')) {
2371
// The local branch has outgoing changes and it cannot be fast-forwarded.
2372
err.gitErrorCode = GitErrorCodes.BranchFastForwardRejected;
2373
}
2374
2375
throw err;
2376
}
2377
}
2378
2379
async fetchTags(options: { remote: string; tags: string[]; force?: boolean }): Promise<void> {
2380
const args = ['fetch'];
2381
const spawnOptions: SpawnOptions = {
2382
env: { 'GIT_HTTP_USER_AGENT': this.git.userAgent }
2383
};
2384
2385
args.push(options.remote);
2386
2387
for (const tag of options.tags) {
2388
args.push(`refs/tags/${tag}:refs/tags/${tag}`);
2389
}
2390
2391
if (options.force) {
2392
args.push('--force');
2393
}
2394
2395
await this.exec(args, spawnOptions);
2396
}
2397
2398
async pull(rebase?: boolean, remote?: string, branch?: string, options: PullOptions = {}): Promise<void> {
2399
const args = ['pull'];
2400
2401
if (options.tags) {
2402
args.push('--tags');
2403
}
2404
2405
if (options.unshallow) {
2406
args.push('--unshallow');
2407
}
2408
2409
// --auto-stash option is only available `git pull --merge` starting with git 2.27.0
2410
if (options.autoStash && this._git.compareGitVersionTo('2.27.0') !== -1) {
2411
args.push('--autostash');
2412
}
2413
2414
if (rebase) {
2415
args.push('-r');
2416
}
2417
2418
if (remote && branch) {
2419
args.push(remote);
2420
args.push(branch);
2421
}
2422
2423
try {
2424
await this.exec(args, {
2425
cancellationToken: options.cancellationToken,
2426
env: { 'GIT_HTTP_USER_AGENT': this.git.userAgent }
2427
});
2428
} catch (err) {
2429
if (/^CONFLICT \([^)]+\): \b/m.test(err.stdout || '')) {
2430
err.gitErrorCode = GitErrorCodes.Conflict;
2431
} else if (/Please tell me who you are\./.test(err.stderr || '')) {
2432
err.gitErrorCode = GitErrorCodes.NoUserNameConfigured;
2433
} else if (/Could not read from remote repository/.test(err.stderr || '')) {
2434
err.gitErrorCode = GitErrorCodes.RemoteConnectionError;
2435
} 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)) {
2436
err.stderr = err.stderr.replace(/Cannot pull with rebase: You have unstaged changes/i, 'Cannot pull with rebase, you have unstaged changes');
2437
err.gitErrorCode = GitErrorCodes.DirtyWorkTree;
2438
} else if (/cannot lock ref|unable to update local ref/i.test(err.stderr || '')) {
2439
err.gitErrorCode = GitErrorCodes.CantLockRef;
2440
} else if (/cannot rebase onto multiple branches/i.test(err.stderr || '')) {
2441
err.gitErrorCode = GitErrorCodes.CantRebaseMultipleBranches;
2442
} else if (/! \[rejected\].*\(would clobber existing tag\)/m.test(err.stderr || '')) {
2443
err.gitErrorCode = GitErrorCodes.TagConflict;
2444
}
2445
2446
throw err;
2447
}
2448
}
2449
2450
async rebase(branch: string, options: PullOptions = {}): Promise<void> {
2451
const args = ['rebase'];
2452
2453
args.push(branch);
2454
2455
try {
2456
await this.exec(args, options);
2457
} catch (err) {
2458
if (/^CONFLICT \([^)]+\): \b/m.test(err.stdout || '')) {
2459
err.gitErrorCode = GitErrorCodes.Conflict;
2460
} else if (/cannot rebase onto multiple branches/i.test(err.stderr || '')) {
2461
err.gitErrorCode = GitErrorCodes.CantRebaseMultipleBranches;
2462
}
2463
2464
throw err;
2465
}
2466
}
2467
2468
async push(remote?: string, name?: string, setUpstream: boolean = false, followTags = false, forcePushMode?: ForcePushMode, tags = false): Promise<void> {
2469
const args = ['push'];
2470
2471
if (forcePushMode === ForcePushMode.ForceWithLease || forcePushMode === ForcePushMode.ForceWithLeaseIfIncludes) {
2472
args.push('--force-with-lease');
2473
if (forcePushMode === ForcePushMode.ForceWithLeaseIfIncludes && this._git.compareGitVersionTo('2.30') !== -1) {
2474
args.push('--force-if-includes');
2475
}
2476
} else if (forcePushMode === ForcePushMode.Force) {
2477
args.push('--force');
2478
}
2479
2480
if (setUpstream) {
2481
args.push('-u');
2482
}
2483
2484
if (followTags) {
2485
args.push('--follow-tags');
2486
}
2487
2488
if (tags) {
2489
args.push('--tags');
2490
}
2491
2492
if (remote) {
2493
args.push(remote);
2494
}
2495
2496
if (name) {
2497
args.push(name);
2498
}
2499
2500
try {
2501
await this.exec(args, { env: { 'GIT_HTTP_USER_AGENT': this.git.userAgent } });
2502
} catch (err) {
2503
if (/^error: failed to push some refs to\b/m.test(err.stderr || '')) {
2504
if (forcePushMode === ForcePushMode.ForceWithLease && /! \[rejected\].*\(stale info\)/m.test(err.stderr || '')) {
2505
err.gitErrorCode = GitErrorCodes.ForcePushWithLeaseRejected;
2506
} else if (forcePushMode === ForcePushMode.ForceWithLeaseIfIncludes && /! \[rejected\].*\(remote ref updated since checkout\)/m.test(err.stderr || '')) {
2507
err.gitErrorCode = GitErrorCodes.ForcePushWithLeaseIfIncludesRejected;
2508
} else {
2509
err.gitErrorCode = GitErrorCodes.PushRejected;
2510
}
2511
} else if (/Permission.*denied/.test(err.stderr || '')) {
2512
err.gitErrorCode = GitErrorCodes.PermissionDenied;
2513
} else if (/Could not read from remote repository/.test(err.stderr || '')) {
2514
err.gitErrorCode = GitErrorCodes.RemoteConnectionError;
2515
} else if (/^fatal: The current branch .* has no upstream branch/.test(err.stderr || '')) {
2516
err.gitErrorCode = GitErrorCodes.NoUpstreamBranch;
2517
}
2518
2519
throw err;
2520
}
2521
}
2522
2523
async cherryPick(commitHash: string): Promise<void> {
2524
try {
2525
await this.exec(['cherry-pick', commitHash]);
2526
} catch (err) {
2527
if (/The previous cherry-pick is now empty, possibly due to conflict resolution./.test(err.stderr ?? '')) {
2528
// Abort cherry-pick
2529
await this.cherryPickAbort();
2530
2531
err.gitErrorCode = GitErrorCodes.CherryPickEmpty;
2532
} else {
2533
// Conflict during cherry-pick
2534
err.gitErrorCode = GitErrorCodes.CherryPickConflict;
2535
}
2536
2537
throw err;
2538
}
2539
}
2540
2541
async cherryPickAbort(): Promise<void> {
2542
await this.exec(['cherry-pick', '--abort']);
2543
}
2544
2545
async blame(path: string): Promise<string> {
2546
try {
2547
const args = ['blame', '--', this.sanitizeRelativePath(path)];
2548
const result = await this.exec(args);
2549
return result.stdout.trim();
2550
} catch (err) {
2551
if (/^fatal: no such path/.test(err.stderr || '')) {
2552
err.gitErrorCode = GitErrorCodes.NoPathFound;
2553
}
2554
2555
throw err;
2556
}
2557
}
2558
2559
async blame2(path: string, ref?: string, ignoreWhitespace?: boolean): Promise<BlameInformation[] | undefined> {
2560
try {
2561
const args = ['blame', '--root', '--incremental'];
2562
2563
if (ignoreWhitespace) {
2564
args.push('-w');
2565
}
2566
2567
if (ref) {
2568
args.push(ref);
2569
}
2570
2571
args.push('--', this.sanitizeRelativePath(path));
2572
2573
const result = await this.exec(args);
2574
2575
return parseGitBlame(result.stdout.trim());
2576
}
2577
catch (err) {
2578
return undefined;
2579
}
2580
}
2581
2582
async createStash(message?: string, includeUntracked?: boolean, staged?: boolean): Promise<void> {
2583
try {
2584
const args = ['stash', 'push'];
2585
2586
if (includeUntracked) {
2587
args.push('-u');
2588
}
2589
2590
if (staged) {
2591
args.push('-S');
2592
}
2593
2594
if (message) {
2595
args.push('-m', message);
2596
}
2597
2598
await this.exec(args);
2599
} catch (err) {
2600
if (/No local changes to save/.test(err.stderr || '')) {
2601
err.gitErrorCode = GitErrorCodes.NoLocalChanges;
2602
}
2603
2604
throw err;
2605
}
2606
}
2607
2608
async popStash(index?: number, options?: { reinstateStagedChanges?: boolean }): Promise<void> {
2609
const args = ['stash', 'pop'];
2610
if (options?.reinstateStagedChanges) {
2611
args.push('--index');
2612
}
2613
await this.popOrApplyStash(args, index);
2614
}
2615
2616
async applyStash(index?: number, options?: { reinstateStagedChanges?: boolean }): Promise<void> {
2617
const args = ['stash', 'apply'];
2618
if (options?.reinstateStagedChanges) {
2619
args.push('--index');
2620
}
2621
await this.popOrApplyStash(args, index);
2622
}
2623
2624
private async popOrApplyStash(args: string[], index?: number): Promise<void> {
2625
try {
2626
if (typeof index === 'number') {
2627
args.push(`stash@{${index}}`);
2628
}
2629
2630
await this.exec(args);
2631
} catch (err) {
2632
if (/No stash found/.test(err.stderr || '')) {
2633
err.gitErrorCode = GitErrorCodes.NoStashFound;
2634
} else if (/error: Your local changes to the following files would be overwritten/.test(err.stderr || '')) {
2635
err.gitErrorCode = GitErrorCodes.LocalChangesOverwritten;
2636
} else if (/^CONFLICT/m.test(err.stdout || '')) {
2637
err.gitErrorCode = GitErrorCodes.StashConflict;
2638
}
2639
2640
throw err;
2641
}
2642
}
2643
2644
async dropStash(index?: number): Promise<void> {
2645
const args = ['stash'];
2646
2647
if (typeof index === 'number') {
2648
args.push('drop');
2649
args.push(`stash@{${index}}`);
2650
} else {
2651
args.push('clear');
2652
}
2653
2654
try {
2655
await this.exec(args);
2656
} catch (err) {
2657
if (/No stash found/.test(err.stderr || '')) {
2658
err.gitErrorCode = GitErrorCodes.NoStashFound;
2659
}
2660
2661
throw err;
2662
}
2663
}
2664
2665
async showStash(index: number): Promise<Change[] | undefined> {
2666
const args = ['stash', 'show', `stash@{${index}}`, '--name-status', '-z', '-u'];
2667
2668
try {
2669
const result = await this.exec(args);
2670
if (result.exitCode) {
2671
return [];
2672
}
2673
2674
return parseGitChanges(this.repositoryRoot, result.stdout.trim());
2675
} catch (err) {
2676
if (/No stash found/.test(err.stderr || '')) {
2677
return undefined;
2678
}
2679
2680
throw err;
2681
}
2682
}
2683
2684
async getStatus(opts?: { limit?: number; ignoreSubmodules?: boolean; similarityThreshold?: number; untrackedChanges?: 'mixed' | 'separate' | 'hidden'; cancellationToken?: CancellationToken }): Promise<{ status: IFileStatus[]; statusLength: number; didHitLimit: boolean }> {
2685
if (opts?.cancellationToken && opts?.cancellationToken.isCancellationRequested) {
2686
throw new CancellationError();
2687
}
2688
2689
const disposables: IDisposable[] = [];
2690
2691
const env = { GIT_OPTIONAL_LOCKS: '0' };
2692
const args = ['status', '-z'];
2693
2694
if (opts?.untrackedChanges === 'hidden') {
2695
args.push('-uno');
2696
} else {
2697
args.push('-uall');
2698
}
2699
2700
if (opts?.ignoreSubmodules) {
2701
args.push('--ignore-submodules');
2702
}
2703
2704
// --find-renames option is only available starting with git 2.18.0
2705
if (opts?.similarityThreshold && opts.similarityThreshold !== 50 && this._git.compareGitVersionTo('2.18.0') !== -1) {
2706
args.push(`--find-renames=${opts.similarityThreshold}%`);
2707
}
2708
2709
const child = this.stream(args, { env });
2710
2711
let result = new Promise<{ status: IFileStatus[]; statusLength: number; didHitLimit: boolean }>((c, e) => {
2712
const parser = new GitStatusParser();
2713
2714
const onClose = (exitCode: number) => {
2715
if (exitCode !== 0) {
2716
const stderr = stderrData.join('');
2717
return e(new GitError({
2718
message: 'Failed to execute git',
2719
stderr,
2720
exitCode,
2721
gitErrorCode: getGitErrorCode(stderr),
2722
gitCommand: 'status',
2723
gitArgs: args
2724
}));
2725
}
2726
2727
c({ status: parser.status, statusLength: parser.status.length, didHitLimit: false });
2728
};
2729
2730
const limit = opts?.limit ?? 10000;
2731
const onStdoutData = (raw: string) => {
2732
parser.update(raw);
2733
2734
if (limit !== 0 && parser.status.length > limit) {
2735
child.removeListener('close', onClose);
2736
child.stdout?.removeListener('data', onStdoutData);
2737
child.kill();
2738
2739
c({ status: parser.status.slice(0, limit), statusLength: parser.status.length, didHitLimit: true });
2740
}
2741
};
2742
2743
if (child.stdout) {
2744
child.stdout.setEncoding('utf8');
2745
child.stdout.on('data', onStdoutData);
2746
}
2747
2748
const stderrData: string[] = [];
2749
if (child.stderr) {
2750
child.stderr.setEncoding('utf8');
2751
child.stderr.on('data', raw => stderrData.push(raw as string));
2752
}
2753
2754
child.on('error', cpErrorHandler(e));
2755
child.on('close', onClose);
2756
});
2757
2758
if (opts?.cancellationToken) {
2759
const cancellationPromise = new Promise<{ status: IFileStatus[]; statusLength: number; didHitLimit: boolean }>((_, e) => {
2760
disposables.push(onceEvent(opts.cancellationToken!.onCancellationRequested)(() => {
2761
try {
2762
child.kill();
2763
} catch (err) {
2764
// noop
2765
}
2766
2767
e(new CancellationError());
2768
}));
2769
});
2770
2771
result = Promise.race([result, cancellationPromise]);
2772
}
2773
2774
try {
2775
const { status, statusLength, didHitLimit } = await result;
2776
return { status, statusLength, didHitLimit };
2777
}
2778
finally {
2779
dispose(disposables);
2780
}
2781
}
2782
2783
async getHEADRef(): Promise<Branch | undefined> {
2784
let HEAD: Branch | undefined;
2785
2786
try {
2787
HEAD = await this.getHEAD();
2788
2789
if (HEAD.name) {
2790
// Branch
2791
HEAD = await this.getBranch(HEAD.name);
2792
2793
// Upstream commit
2794
if (HEAD && HEAD.upstream) {
2795
const ref = HEAD.upstream.remote !== '.'
2796
? `refs/remotes/${HEAD.upstream.remote}/${HEAD.upstream.name}`
2797
: `refs/heads/${HEAD.upstream.name}`;
2798
const commit = await this.revParse(ref);
2799
HEAD = { ...HEAD, upstream: { ...HEAD.upstream, commit } };
2800
}
2801
} else if (HEAD.commit) {
2802
// Tag || Commit
2803
const tags = await this.getRefs({ pattern: 'refs/tags' });
2804
const tag = tags.find(tag => tag.commit === HEAD!.commit);
2805
2806
if (tag) {
2807
HEAD = { ...HEAD, name: tag.name, type: RefType.Tag };
2808
}
2809
}
2810
} catch (err) {
2811
// noop
2812
}
2813
2814
return HEAD;
2815
}
2816
2817
async getHEAD(): Promise<Ref> {
2818
if (!this._isUsingRefTable) {
2819
try {
2820
// Attempt to parse the HEAD file
2821
const result = await this.getHEADFS();
2822
2823
// Git 2.45 adds support for a new reference storage backend called "reftable", promising
2824
// faster lookups, reads, and writes for repositories with any number of references. For
2825
// backwards compatibility the `.git/HEAD` file contains `ref: refs/heads/.invalid`. More
2826
// details are available at https://git-scm.com/docs/reftable
2827
if (result.name === '.invalid') {
2828
this._isUsingRefTable = true;
2829
this.logger.warn(`[Git][getHEAD] Failed to parse HEAD file: Repository is using reftable format.`);
2830
} else {
2831
return result;
2832
}
2833
}
2834
catch (err) {
2835
this.logger.warn(`[Git][getHEAD] Failed to parse HEAD file: ${err.message}`);
2836
}
2837
}
2838
2839
try {
2840
// Fallback to using git to determine HEAD
2841
const result = await this.exec(['symbolic-ref', '--short', 'HEAD']);
2842
2843
if (!result.stdout) {
2844
throw new Error('Not in a branch');
2845
}
2846
2847
return { name: result.stdout.trim(), commit: undefined, type: RefType.Head };
2848
}
2849
catch (err) { }
2850
2851
// Detached HEAD
2852
const result = await this.exec(['rev-parse', 'HEAD']);
2853
2854
if (!result.stdout) {
2855
throw new Error('Error parsing HEAD');
2856
}
2857
2858
return { name: undefined, commit: result.stdout.trim(), type: RefType.Head };
2859
}
2860
2861
async getHEADFS(): Promise<Ref> {
2862
const raw = await fs.readFile(path.join(this.dotGit.path, 'HEAD'), 'utf8');
2863
2864
// Branch
2865
const branchMatch = raw.match(/^ref: refs\/heads\/(?<name>.*)$/m);
2866
if (branchMatch?.groups?.name) {
2867
return { name: branchMatch.groups.name, commit: undefined, type: RefType.Head };
2868
}
2869
2870
// Detached
2871
const commitMatch = raw.match(/^(?<commit>[0-9a-f]{40})$/m);
2872
if (commitMatch?.groups?.commit) {
2873
return { name: undefined, commit: commitMatch.groups.commit, type: RefType.Head };
2874
}
2875
2876
throw new Error(`Unable to parse HEAD file. HEAD file contents: ${raw}.`);
2877
}
2878
2879
async findTrackingBranches(upstreamBranch: string): Promise<Branch[]> {
2880
const result = await this.exec(['for-each-ref', '--format', '%(refname:short)%00%(upstream:short)', 'refs/heads']);
2881
return result.stdout.trim().split('\n')
2882
.map(line => line.trim().split('\0'))
2883
.filter(([_, upstream]) => upstream === upstreamBranch)
2884
.map(([ref]): Branch => ({ name: ref, type: RefType.Head }));
2885
}
2886
2887
async getRefs(query: RefQuery, cancellationToken?: CancellationToken): Promise<(Ref | Branch)[]> {
2888
if (cancellationToken && cancellationToken.isCancellationRequested) {
2889
throw new CancellationError();
2890
}
2891
2892
const args = ['for-each-ref'];
2893
2894
if (query.count) {
2895
args.push(`--count=${query.count}`);
2896
}
2897
2898
if (query.sort && query.sort !== 'alphabetically') {
2899
args.push('--sort', `-${query.sort}`);
2900
}
2901
2902
if (query.includeCommitDetails) {
2903
const format = this._git.compareGitVersionTo('1.9.0') !== -1
2904
? `${REFS_WITH_DETAILS_FORMAT}%00%(upstream:track)`
2905
: REFS_WITH_DETAILS_FORMAT;
2906
args.push('--format', format);
2907
} else {
2908
args.push('--format', REFS_FORMAT);
2909
}
2910
2911
if (query.pattern) {
2912
const patterns = Array.isArray(query.pattern) ? query.pattern : [query.pattern];
2913
for (const pattern of patterns) {
2914
args.push(pattern.startsWith('refs/') ? pattern : `refs/${pattern}`);
2915
}
2916
}
2917
2918
if (query.contains) {
2919
args.push('--contains', query.contains);
2920
}
2921
2922
const result = await this.exec(args, { cancellationToken });
2923
return parseRefs(result.stdout);
2924
}
2925
2926
async getRemoteRefs(remote: string, opts?: { heads?: boolean; tags?: boolean; cancellationToken?: CancellationToken }): Promise<Ref[]> {
2927
if (opts?.cancellationToken && opts?.cancellationToken.isCancellationRequested) {
2928
throw new CancellationError();
2929
}
2930
2931
const args = ['ls-remote'];
2932
2933
if (opts?.heads) {
2934
args.push('--heads');
2935
}
2936
2937
if (opts?.tags) {
2938
args.push('--tags');
2939
}
2940
2941
args.push(remote);
2942
2943
const result = await this.exec(args, { cancellationToken: opts?.cancellationToken });
2944
2945
const fn = (line: string): Ref | null => {
2946
let match: RegExpExecArray | null;
2947
2948
if (match = /^([0-9a-f]{40})\trefs\/heads\/([^ ]+)$/.exec(line)) {
2949
return { name: match[1], commit: match[2], type: RefType.Head };
2950
} else if (match = /^([0-9a-f]{40})\trefs\/tags\/([^ ]+)$/.exec(line)) {
2951
return { name: match[2], commit: match[1], type: RefType.Tag };
2952
}
2953
2954
return null;
2955
};
2956
2957
return result.stdout.split('\n')
2958
.filter(line => !!line)
2959
.map(fn)
2960
.filter(ref => !!ref) as Ref[];
2961
}
2962
2963
async getStashes(): Promise<Stash[]> {
2964
const result = await this.exec(['stash', 'list', `--format=${STASH_FORMAT}`, '-z']);
2965
return parseGitStashes(result.stdout.trim());
2966
}
2967
2968
async getWorktrees(): Promise<Worktree[]> {
2969
return await this.getWorktreesFS();
2970
}
2971
2972
private async getWorktreesFS(): Promise<Worktree[]> {
2973
const result: Worktree[] = [];
2974
const mainRepositoryPath = this.dotGit.commonPath ?? this.dotGit.path;
2975
2976
try {
2977
if (!this.dotGit.isBare) {
2978
// Add main worktree for a non-bare repository
2979
const headPath = path.join(mainRepositoryPath, 'HEAD');
2980
const headContent = (await fs.readFile(headPath, 'utf8')).trim();
2981
2982
const mainRepositoryWorktreeName = path.basename(path.dirname(mainRepositoryPath));
2983
2984
result.push({
2985
name: mainRepositoryWorktreeName,
2986
path: path.dirname(mainRepositoryPath),
2987
ref: headContent.replace(/^ref: /, ''),
2988
detached: !headContent.startsWith('ref: '),
2989
main: true
2990
} satisfies Worktree);
2991
}
2992
2993
// List all worktree folder names
2994
const worktreesPath = path.join(mainRepositoryPath, 'worktrees');
2995
const dirents = await fs.readdir(worktreesPath, { withFileTypes: true });
2996
2997
for (const dirent of dirents) {
2998
if (!dirent.isDirectory()) {
2999
continue;
3000
}
3001
3002
try {
3003
const headPath = path.join(worktreesPath, dirent.name, 'HEAD');
3004
const headContent = (await fs.readFile(headPath, 'utf8')).trim();
3005
3006
const gitdirPath = path.join(worktreesPath, dirent.name, 'gitdir');
3007
const gitdirContent = (await fs.readFile(gitdirPath, 'utf8')).trim();
3008
3009
result.push({
3010
name: dirent.name,
3011
// Remove '/.git' suffix
3012
path: gitdirContent.replace(/\/.git.*$/, ''),
3013
// Remove 'ref: ' prefix
3014
ref: headContent.replace(/^ref: /, ''),
3015
// Detached if HEAD does not start with 'ref: '
3016
detached: !headContent.startsWith('ref: '),
3017
main: false
3018
});
3019
} catch (err) {
3020
if (/ENOENT/.test(err.message)) {
3021
continue;
3022
}
3023
3024
throw err;
3025
}
3026
}
3027
3028
return result;
3029
}
3030
catch (err) {
3031
if (/ENOENT/.test(err.message) || /ENOTDIR/.test(err.message)) {
3032
return result;
3033
}
3034
3035
throw err;
3036
}
3037
}
3038
3039
async getRemotes(): Promise<Remote[]> {
3040
const remotes: MutableRemote[] = [];
3041
3042
try {
3043
// Attempt to parse the config file
3044
remotes.push(...await this.getRemotesFS());
3045
3046
if (remotes.length === 0) {
3047
this.logger.info('[Git][getRemotes] No remotes found in the git config file');
3048
}
3049
}
3050
catch (err) {
3051
this.logger.warn(`[Git][getRemotes] Error: ${err.message}`);
3052
3053
// Fallback to using git to get the remotes
3054
remotes.push(...await this.getRemotesGit());
3055
}
3056
3057
for (const remote of remotes) {
3058
// https://github.com/microsoft/vscode/issues/45271
3059
remote.isReadOnly = remote.pushUrl === undefined || remote.pushUrl === 'no_push';
3060
}
3061
3062
return remotes;
3063
}
3064
3065
private async getRemotesFS(): Promise<MutableRemote[]> {
3066
const raw = await fs.readFile(path.join(this.dotGit.commonPath ?? this.dotGit.path, 'config'), 'utf8');
3067
return parseGitRemotes(raw);
3068
}
3069
3070
private async getRemotesGit(): Promise<MutableRemote[]> {
3071
const remotes: MutableRemote[] = [];
3072
3073
const result = await this.exec(['remote', '--verbose']);
3074
const lines = result.stdout.trim().split('\n').filter(l => !!l);
3075
3076
for (const line of lines) {
3077
const parts = line.split(/\s/);
3078
const [name, url, type] = parts;
3079
3080
let remote = remotes.find(r => r.name === name);
3081
3082
if (!remote) {
3083
remote = { name, isReadOnly: false };
3084
remotes.push(remote);
3085
}
3086
3087
if (/fetch/i.test(type)) {
3088
remote.fetchUrl = url;
3089
} else if (/push/i.test(type)) {
3090
remote.pushUrl = url;
3091
} else {
3092
remote.fetchUrl = url;
3093
remote.pushUrl = url;
3094
}
3095
}
3096
3097
return remotes;
3098
}
3099
3100
async getBranch(name: string): Promise<Branch> {
3101
if (name === 'HEAD') {
3102
return this.getHEAD();
3103
}
3104
3105
const args = ['for-each-ref'];
3106
3107
let supportsAheadBehind = true;
3108
if (this._git.compareGitVersionTo('1.9.0') === -1) {
3109
args.push('--format=%(refname)%00%(upstream:short)%00%(objectname)');
3110
supportsAheadBehind = false;
3111
} else if (this._git.compareGitVersionTo('2.16.0') === -1) {
3112
args.push('--format=%(refname)%00%(upstream:short)%00%(objectname)%00%(upstream:track)');
3113
} else {
3114
args.push('--format=%(refname)%00%(upstream:short)%00%(objectname)%00%(upstream:track)%00%(upstream:remotename)%00%(upstream:remoteref)');
3115
}
3116
3117
// On Windows and macOS ref names are case insensitive so we add --ignore-case
3118
// to handle the scenario where the user switched to a branch with incorrect
3119
// casing
3120
if (this.git.compareGitVersionTo('2.12') !== -1 && (isWindows || isMacintosh)) {
3121
args.push('--ignore-case');
3122
}
3123
3124
if (/^refs\/(heads|remotes)\//i.test(name)) {
3125
args.push(name);
3126
} else {
3127
args.push(`refs/heads/${name}`, `refs/remotes/${name}`);
3128
}
3129
3130
const result = await this.exec(args);
3131
const branches: Branch[] = result.stdout.trim().split('\n').map<Branch | undefined>(line => {
3132
let [branchName, upstream, ref, status, remoteName, upstreamRef] = line.trim().split('\0');
3133
3134
if (branchName.startsWith('refs/heads/')) {
3135
branchName = branchName.substring(11);
3136
const index = upstream.indexOf('/');
3137
3138
let ahead;
3139
let behind;
3140
const match = /\[(?:ahead ([0-9]+))?[,\s]*(?:behind ([0-9]+))?]|\[gone]/.exec(status);
3141
if (match) {
3142
[, ahead, behind] = match;
3143
}
3144
3145
return {
3146
type: RefType.Head,
3147
name: branchName,
3148
upstream: upstream !== '' && status !== '[gone]' ? {
3149
name: upstreamRef ? upstreamRef.substring(11) : upstream.substring(index + 1),
3150
remote: remoteName ? remoteName : upstream.substring(0, index)
3151
} : undefined,
3152
commit: ref || undefined,
3153
ahead: Number(ahead) || 0,
3154
behind: Number(behind) || 0,
3155
};
3156
} else if (branchName.startsWith('refs/remotes/')) {
3157
branchName = branchName.substring(13);
3158
const index = branchName.indexOf('/');
3159
3160
return {
3161
type: RefType.RemoteHead,
3162
name: branchName.substring(index + 1),
3163
remote: branchName.substring(0, index),
3164
commit: ref,
3165
};
3166
} else {
3167
return undefined;
3168
}
3169
}).filter((b?: Branch): b is Branch => !!b);
3170
3171
if (branches.length) {
3172
const [branch] = branches;
3173
3174
if (!supportsAheadBehind && branch.upstream) {
3175
try {
3176
const result = await this.exec(['rev-list', '--left-right', '--count', `${branch.name}...${branch.upstream.remote}/${branch.upstream.name}`]);
3177
const [ahead, behind] = result.stdout.trim().split('\t');
3178
3179
(branch as Mutable<Branch>).ahead = Number(ahead) || 0;
3180
(branch as Mutable<Branch>).behind = Number(behind) || 0;
3181
} catch { }
3182
}
3183
3184
return branch;
3185
}
3186
3187
this.logger.warn(`[Git][getBranch] No such branch: ${name}`);
3188
return Promise.reject<Branch>(new Error(`No such branch: ${name}.`));
3189
}
3190
3191
async getDefaultBranch(remoteName: string): Promise<Branch> {
3192
const result = await this.exec(['symbolic-ref', '--short', `refs/remotes/${remoteName}/HEAD`]);
3193
if (!result.stdout || result.stderr) {
3194
throw new Error('No default branch');
3195
}
3196
3197
return this.getBranch(result.stdout.trim());
3198
}
3199
3200
// TODO: Support core.commentChar
3201
stripCommitMessageComments(message: string): string {
3202
return message.replace(/^\s*#.*$\n?/gm, '').trim();
3203
}
3204
3205
async getSquashMessage(): Promise<string | undefined> {
3206
const squashMsgPath = path.join(this.repositoryRoot, '.git', 'SQUASH_MSG');
3207
3208
try {
3209
const raw = await fs.readFile(squashMsgPath, 'utf8');
3210
return this.stripCommitMessageComments(raw);
3211
} catch {
3212
return undefined;
3213
}
3214
}
3215
3216
async getMergeMessage(): Promise<string | undefined> {
3217
const mergeMsgPath = path.join(this.repositoryRoot, '.git', 'MERGE_MSG');
3218
3219
try {
3220
const raw = await fs.readFile(mergeMsgPath, 'utf8');
3221
return this.stripCommitMessageComments(raw);
3222
} catch {
3223
return undefined;
3224
}
3225
}
3226
3227
async getCommitTemplate(): Promise<string> {
3228
try {
3229
const result = await this.exec(['config', '--get', 'commit.template']);
3230
3231
if (!result.stdout) {
3232
return '';
3233
}
3234
3235
// https://github.com/git/git/blob/3a0f269e7c82aa3a87323cb7ae04ac5f129f036b/path.c#L612
3236
const homedir = os.homedir();
3237
let templatePath = result.stdout.trim()
3238
.replace(/^~([^\/]*)\//, (_, user) => `${user ? path.join(path.dirname(homedir), user) : homedir}/`);
3239
3240
if (!path.isAbsolute(templatePath)) {
3241
templatePath = path.join(this.repositoryRoot, templatePath);
3242
}
3243
3244
const raw = await fs.readFile(templatePath, 'utf8');
3245
return this.stripCommitMessageComments(raw);
3246
} catch (err) {
3247
return '';
3248
}
3249
}
3250
3251
async getCommit(ref: string): Promise<Commit> {
3252
const result = await this.exec(['show', '-s', '--decorate=full', '--shortstat', `--format=${COMMIT_FORMAT}`, '-z', ref, '--']);
3253
const commits = parseGitCommits(result.stdout);
3254
if (commits.length === 0) {
3255
return Promise.reject<Commit>('bad commit format');
3256
}
3257
return commits[0];
3258
}
3259
3260
async showChanges(ref: string): Promise<string> {
3261
try {
3262
const result = await this.exec(['log', '-p', '-n1', ref, '--']);
3263
return result.stdout.trim();
3264
} catch (err) {
3265
if (/^fatal: bad revision '.+'/.test(err.stderr || '')) {
3266
err.gitErrorCode = GitErrorCodes.BadRevision;
3267
}
3268
3269
throw err;
3270
}
3271
}
3272
3273
async showChangesBetween(ref1: string, ref2: string, path?: string): Promise<string> {
3274
try {
3275
const args = ['log', '-p', `${ref1}..${ref2}`, '--'];
3276
if (path) {
3277
args.push(this.sanitizeRelativePath(path));
3278
}
3279
3280
const result = await this.exec(args);
3281
return result.stdout.trim();
3282
} catch (err) {
3283
if (/^fatal: bad revision '.+'/.test(err.stderr || '')) {
3284
err.gitErrorCode = GitErrorCodes.BadRevision;
3285
}
3286
3287
throw err;
3288
}
3289
}
3290
3291
async revList(ref1: string, ref2: string): Promise<string[]> {
3292
const result = await this.exec(['rev-list', `${ref1}..${ref2}`]);
3293
if (result.stderr) {
3294
return [];
3295
}
3296
3297
return result.stdout.trim().split('\n');
3298
}
3299
3300
async revParse(ref: string): Promise<string | undefined> {
3301
try {
3302
const result = await fs.readFile(path.join(this.dotGit.path, ref), 'utf8');
3303
return result.trim();
3304
} catch (err) {
3305
this.logger.warn(`[Git][revParse] Unable to read file: ${err.message}`);
3306
}
3307
3308
try {
3309
const result = await this.exec(['rev-parse', ref]);
3310
if (result.stderr) {
3311
return undefined;
3312
}
3313
return result.stdout.trim();
3314
} catch (err) {
3315
return undefined;
3316
}
3317
}
3318
3319
async updateSubmodules(paths: string[]): Promise<void> {
3320
const args = ['submodule', 'update'];
3321
3322
for (const chunk of splitInChunks(paths.map(p => this.sanitizeRelativePath(p)), MAX_CLI_LENGTH)) {
3323
await this.exec([...args, '--', ...chunk]);
3324
}
3325
}
3326
3327
async getSubmodules(): Promise<Submodule[]> {
3328
const gitmodulesPath = path.join(this.root, '.gitmodules');
3329
3330
try {
3331
const gitmodulesRaw = await fs.readFile(gitmodulesPath, 'utf8');
3332
return parseGitmodules(gitmodulesRaw);
3333
} catch (err) {
3334
if (/ENOENT/.test(err.message)) {
3335
return [];
3336
}
3337
3338
throw err;
3339
}
3340
}
3341
3342
private sanitizeRelativePath(filePath: string): string {
3343
this.logger.trace(`[Git][sanitizeRelativePath] filePath: ${filePath}`);
3344
3345
// Relative path
3346
if (!path.isAbsolute(filePath)) {
3347
filePath = sanitizeRelativePath(filePath);
3348
this.logger.trace(`[Git][sanitizeRelativePath] relativePath (noop): ${filePath}`);
3349
return filePath;
3350
}
3351
3352
let relativePath: string | undefined;
3353
3354
// Repository root real path
3355
if (this.repositoryRootRealPath) {
3356
relativePath = relativePathWithNoFallback(this.repositoryRootRealPath, filePath);
3357
if (relativePath) {
3358
relativePath = sanitizeRelativePath(relativePath);
3359
this.logger.trace(`[Git][sanitizeRelativePath] relativePath (real path): ${relativePath}`);
3360
return relativePath;
3361
}
3362
}
3363
3364
// Repository root path
3365
relativePath = relativePathWithNoFallback(this.repositoryRoot, filePath);
3366
if (relativePath) {
3367
relativePath = sanitizeRelativePath(relativePath);
3368
this.logger.trace(`[Git][sanitizeRelativePath] relativePath (path): ${relativePath}`);
3369
return relativePath;
3370
}
3371
3372
// Fallback to relative()
3373
filePath = sanitizeRelativePath(path.relative(this.repositoryRoot, filePath));
3374
this.logger.trace(`[Git][sanitizeRelativePath] relativePath (fallback): ${filePath}`);
3375
return filePath;
3376
}
3377
}
3378
3379