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