Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/platform/agentHost/node/agentHostGitService.ts
13394 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 * as cp from 'child_process';
7
import { URI } from '../../../base/common/uri.js';
8
import { VSBuffer } from '../../../base/common/buffer.js';
9
import { generateUuid } from '../../../base/common/uuid.js';
10
import { INativeEnvironmentService } from '../../environment/common/environment.js';
11
import { IFileService } from '../../files/common/files.js';
12
import { createDecorator } from '../../instantiation/common/instantiation.js';
13
import { FileEditKind, type ISessionFileDiff, type ISessionGitState } from '../common/state/sessionState.js';
14
import { buildGitBlobUri } from './gitDiffContent.js';
15
16
export const IAgentHostGitService = createDecorator<IAgentHostGitService>('agentHostGitService');
17
18
export interface IAgentHostGitService {
19
readonly _serviceBrand: undefined;
20
isInsideWorkTree(workingDirectory: URI): Promise<boolean>;
21
getCurrentBranch(workingDirectory: URI): Promise<string | undefined>;
22
getDefaultBranch(workingDirectory: URI): Promise<string | undefined>;
23
getBranches(workingDirectory: URI, options?: { readonly query?: string; readonly limit?: number }): Promise<string[]>;
24
getRepositoryRoot(workingDirectory: URI): Promise<URI | undefined>;
25
getWorktreeRoots(workingDirectory: URI): Promise<URI[]>;
26
addWorktree(repositoryRoot: URI, worktree: URI, branchName: string, startPoint: string): Promise<void>;
27
/**
28
* Adds a worktree for an existing branch (no `-b`). Used when restoring
29
* a worktree whose branch was preserved (e.g. unarchiving a session
30
* whose worktree was previously cleaned up on archive).
31
*/
32
addExistingWorktree(repositoryRoot: URI, worktree: URI, branchName: string): Promise<void>;
33
removeWorktree(repositoryRoot: URI, worktree: URI): Promise<void>;
34
/**
35
* Returns true when the named branch exists in the repository
36
* (`refs/heads/<branchName>` resolves). Used by archive cleanup to
37
* confirm the branch is preserved before deleting the worktree, and by
38
* the unarchive path to confirm the branch is still around before
39
* recreating the worktree.
40
*/
41
branchExists(repositoryRoot: URI, branchName: string): Promise<boolean>;
42
/**
43
* Returns true when the working tree has any tracked, staged, or
44
* untracked changes. Used by archive cleanup to skip removing a
45
* worktree that still contains uncommitted work.
46
*/
47
hasUncommittedChanges(workingDirectory: URI): Promise<boolean>;
48
/**
49
* Computes the {@link ISessionGitState} for the working directory by
50
* shelling out to `git`. Returns undefined if the directory is not a
51
* git work tree. Called on session open and after each turn completes
52
* so the UI always reflects current branch/remote/change state.
53
*/
54
getSessionGitState(workingDirectory: URI): Promise<ISessionGitState | undefined>;
55
56
/**
57
* Computes per-file diffs for the session by shelling out to `git
58
* diff --raw --numstat --diff-filter=ADMR -z` against the merge base of
59
* the current branch and {@link IComputeSessionFileDiffsOptions.baseBranch}
60
* (or `HEAD` if no base branch is available). When the working tree has
61
* untracked files, the diff is computed via a temp index so the
62
* untracked content is included.
63
*
64
* Returns `undefined` when {@link workingDirectory} is not a git work
65
* tree, so callers can fall back to other diff sources.
66
*
67
* Each returned {@link ISessionFileDiff} has its `before.content` set to
68
* a `git-blob:` URI ({@link buildGitBlobUri}); `after.content` is a
69
* `file:` URI on the working-tree path. Adds and deletes drop the
70
* missing side.
71
*/
72
computeSessionFileDiffs(workingDirectory: URI, options: IComputeSessionFileDiffsOptions): Promise<readonly ISessionFileDiff[] | undefined>;
73
74
/**
75
* Reads a single git blob via `git show <sha>:<repoRelativePath>` from
76
* the given working directory. Returns `undefined` when the blob does
77
* not exist or the directory is not a git work tree.
78
*/
79
showBlob(workingDirectory: URI, sha: string, repoRelativePath: string): Promise<VSBuffer | undefined>;
80
}
81
82
/**
83
* Provider-agnostic session-database metadata key under which agents
84
* persist the branch they want git-driven diffs anchored to. Read by
85
* {@link AgentSideEffects} when computing per-session file diffs; absent
86
* value means the diff falls back to anchoring at HEAD.
87
*/
88
export const META_DIFF_BASE_BRANCH = 'agentHost.diffBaseBranch';
89
90
/** Options for {@link IAgentHostGitService.computeSessionFileDiffs}. */
91
export interface IComputeSessionFileDiffsOptions {
92
/**
93
* The session URI, used as the authority of the produced
94
* `git-blob:` URIs so the resolver can find the session's working
95
* directory.
96
*/
97
readonly sessionUri: string;
98
/**
99
* The branch to diff against. Typically the worktree's start-point
100
* branch (for worktree sessions) or the repository's default branch.
101
* When undefined or unresolvable, the diff is taken against `HEAD`,
102
* which surfaces uncommitted work but no committed-on-branch work.
103
*/
104
readonly baseBranch?: string;
105
}
106
107
function getCommonBranchPriority(branch: string): number {
108
if (branch === 'main') {
109
return 0;
110
}
111
if (branch === 'master') {
112
return 1;
113
}
114
return 2;
115
}
116
117
export function getBranchCompletions(branches: readonly string[], options?: { readonly query?: string; readonly limit?: number }): string[] {
118
const normalizedQuery = options?.query?.toLowerCase();
119
const filtered = normalizedQuery
120
? branches.filter(branch => branch.toLowerCase().includes(normalizedQuery))
121
: [...branches];
122
123
filtered.sort((a, b) => getCommonBranchPriority(a) - getCommonBranchPriority(b));
124
return options?.limit ? filtered.slice(0, options.limit) : filtered;
125
}
126
127
export class AgentHostGitService implements IAgentHostGitService {
128
declare readonly _serviceBrand: undefined;
129
130
constructor(
131
@IFileService private readonly _fileService: IFileService,
132
@INativeEnvironmentService private readonly _environmentService: INativeEnvironmentService,
133
) { }
134
135
async isInsideWorkTree(workingDirectory: URI): Promise<boolean> {
136
return (await this._runGit(workingDirectory, ['rev-parse', '--is-inside-work-tree']))?.trim() === 'true';
137
}
138
139
async getCurrentBranch(workingDirectory: URI): Promise<string | undefined> {
140
return (await this._runGit(workingDirectory, ['branch', '--show-current']))?.trim()
141
|| (await this._runGit(workingDirectory, ['rev-parse', '--short', 'HEAD']))?.trim()
142
|| undefined;
143
}
144
145
async getDefaultBranch(workingDirectory: URI): Promise<string | undefined> {
146
// Try to read the default branch from the remote HEAD reference
147
const remoteRef = (await this._runGit(workingDirectory, ['symbolic-ref', 'refs/remotes/origin/HEAD']))?.trim();
148
if (remoteRef) {
149
if (!remoteRef.startsWith('refs/remotes/origin/')) {
150
return remoteRef;
151
}
152
153
const branch = remoteRef.substring('refs/remotes/origin/'.length);
154
// Check whether a local branch exists; if not, use the remote-tracking ref
155
// so that 'git worktree add ... <startPoint>' resolves correctly.
156
const hasLocalBranch = (await this._runGit(workingDirectory, ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`])) !== undefined;
157
return hasLocalBranch ? branch : `origin/${branch}`;
158
}
159
return undefined;
160
}
161
162
async getBranches(workingDirectory: URI, options?: { readonly query?: string; readonly limit?: number }): Promise<string[]> {
163
const args = ['for-each-ref', '--format=%(refname:short)', '--sort=-committerdate'];
164
args.push('refs/heads');
165
166
const output = await this._runGit(workingDirectory, args);
167
if (!output) {
168
return [];
169
}
170
const branches = output.split(/\r?\n/g).map(line => line.trim()).filter(branch => branch.length > 0);
171
return getBranchCompletions(branches, options);
172
}
173
174
async getRepositoryRoot(workingDirectory: URI): Promise<URI | undefined> {
175
const repositoryRootPath = (await this._runGit(workingDirectory, ['rev-parse', '--show-toplevel']))?.trim();
176
return repositoryRootPath ? URI.file(repositoryRootPath) : undefined;
177
}
178
179
async getWorktreeRoots(workingDirectory: URI): Promise<URI[]> {
180
const output = await this._runGit(workingDirectory, ['worktree', 'list', '--porcelain']);
181
if (!output) {
182
return [];
183
}
184
return output.split(/\r?\n/g)
185
.filter(line => line.startsWith('worktree '))
186
.map(line => URI.file(line.substring('worktree '.length)));
187
}
188
189
async addWorktree(repositoryRoot: URI, worktree: URI, branchName: string, startPoint: string): Promise<void> {
190
await this._runGit(repositoryRoot, ['worktree', 'add', '-b', branchName, worktree.fsPath, startPoint], { timeout: 30_000, throwOnError: true });
191
}
192
193
async addExistingWorktree(repositoryRoot: URI, worktree: URI, branchName: string): Promise<void> {
194
await this._runGit(repositoryRoot, ['worktree', 'add', worktree.fsPath, branchName], { timeout: 30_000, throwOnError: true });
195
}
196
197
async removeWorktree(repositoryRoot: URI, worktree: URI): Promise<void> {
198
await this._runGit(repositoryRoot, ['worktree', 'remove', '--force', worktree.fsPath], { timeout: 30_000, throwOnError: true });
199
}
200
201
async branchExists(repositoryRoot: URI, branchName: string): Promise<boolean> {
202
// `show-ref --verify --quiet` exits 0 when the ref exists and 1 otherwise.
203
// `_runGit` returns undefined on non-zero exit, so `!== undefined` is the existence signal.
204
const output = await this._runGit(repositoryRoot, ['show-ref', '--verify', '--quiet', `refs/heads/${branchName}`]);
205
return output !== undefined;
206
}
207
208
async hasUncommittedChanges(workingDirectory: URI): Promise<boolean> {
209
const output = await this._runGit(workingDirectory, ['status', '--porcelain']);
210
return !!output && output.trim().length > 0;
211
}
212
213
async computeSessionFileDiffs(workingDirectory: URI, options: IComputeSessionFileDiffsOptions): Promise<readonly ISessionFileDiff[] | undefined> {
214
// Bail fast if not inside a git work tree so callers can fall back
215
// to other diff sources.
216
const inside = await this._runGit(workingDirectory, ['rev-parse', '--is-inside-work-tree']);
217
if (inside?.trim() !== 'true') {
218
return undefined;
219
}
220
221
// All git invocations run from the working tree's repository root so
222
// `--raw` paths are repo-relative — that's what `git show <sha>:<path>`
223
// expects when we resolve `git-blob:` URIs later.
224
const repositoryRootPath = (await this._runGit(workingDirectory, ['rev-parse', '--show-toplevel']))?.trim();
225
if (!repositoryRootPath) {
226
return undefined;
227
}
228
const repositoryRoot = URI.file(repositoryRootPath);
229
230
// Resolve the merge-base commit. With a base branch, this is
231
// `merge-base HEAD <base>` so the diff stays anchored even when the
232
// base branch advances. Without one, fall back to HEAD itself, which
233
// surfaces uncommitted work but no committed-on-branch work — the
234
// best we can do without context. For empty repos with no HEAD, fall
235
// back to the well-known empty-tree object.
236
let mergeBaseCommit: string | undefined;
237
if (options.baseBranch) {
238
mergeBaseCommit = (await this._runGit(repositoryRoot, ['merge-base', 'HEAD', options.baseBranch]))?.trim();
239
}
240
if (!mergeBaseCommit) {
241
mergeBaseCommit = (await this._runGit(repositoryRoot, ['rev-parse', 'HEAD']))?.trim();
242
}
243
if (!mergeBaseCommit) {
244
mergeBaseCommit = EMPTY_TREE_OBJECT;
245
}
246
247
// Detect whether the working tree has any untracked files. If so we
248
// have to use the temp-index trick so the untracked content is
249
// included in `--cached --raw` output; otherwise a plain `git diff`
250
// is sufficient and avoids the temp-dir overhead.
251
const statusOut = await this._runGit(repositoryRoot, ['status', '--porcelain=v1', '-z', '--untracked-files=all']);
252
const untracked = parseUntrackedPaths(statusOut);
253
254
let rawDiffOutput: string | undefined;
255
if (untracked.length === 0) {
256
rawDiffOutput = await this._runGit(repositoryRoot, ['diff', '--raw', '--numstat', '--diff-filter=ADMR', '-z', mergeBaseCommit, '--']);
257
} else {
258
rawDiffOutput = await this._runWithTempIndex(repositoryRoot, mergeBaseCommit);
259
}
260
261
if (rawDiffOutput === undefined) {
262
return undefined;
263
}
264
265
return parseGitDiffRawNumstat(rawDiffOutput, repositoryRoot, options.sessionUri, mergeBaseCommit);
266
}
267
268
private async _runWithTempIndex(repositoryRoot: URI, mergeBaseCommit: string): Promise<string | undefined> {
269
// Build a throwaway index so we can stage the entire working tree
270
// (including untracked files) without disturbing the user's real
271
// index. `read-tree HEAD` seeds it; in empty repos that fails so we
272
// fall back to the empty tree, leaving everything as "added".
273
const tempDir = URI.joinPath(this._environmentService.tmpDir, `agent-host-git-diff-${generateUuid()}`);
274
await this._fileService.createFolder(tempDir);
275
// `GIT_INDEX_FILE` is consumed by the `git` subprocess so it must be
276
// a real OS path string, not a URI.
277
const indexFile = URI.joinPath(tempDir, 'index').fsPath;
278
const env: Record<string, string> = { GIT_INDEX_FILE: indexFile };
279
// GVFS (Virtual File System) repos use a hook that acquires a lock around
280
// git commands. Setting COMMAND_HOOK_LOCK=1 prevents the temp-index
281
// operations from blocking the main working-tree lock. This mirrors what
282
// the extension's `buildTempIndexEnv` does for the same reason.
283
env.COMMAND_HOOK_LOCK = '1';
284
try {
285
const seeded = await this._runGit(repositoryRoot, ['read-tree', 'HEAD'], { env });
286
if (seeded === undefined) {
287
// Empty repo (no HEAD yet) - `read-tree` of the empty tree always succeeds.
288
await this._runGit(repositoryRoot, ['read-tree', EMPTY_TREE_OBJECT], { env });
289
}
290
// Stage every change in the working tree (modified, deleted,
291
// untracked, renamed). `add -A` plus an explicit `:/` pathspec
292
// covers the entire repo from any cwd.
293
await this._runGit(repositoryRoot, ['add', '-A', '--', ':/'], { env });
294
return await this._runGit(repositoryRoot, ['diff', '--cached', '--raw', '--numstat', '--diff-filter=ADMR', '-z', mergeBaseCommit, '--'], { env });
295
} finally {
296
try { await this._fileService.del(tempDir, { recursive: true, useTrash: false }); } catch { /* best-effort */ }
297
}
298
}
299
300
async showBlob(workingDirectory: URI, sha: string, repoRelativePath: string): Promise<VSBuffer | undefined> {
301
// Validate sha before passing it to git. `git show <sha>:<path>` parses
302
// its argument as a revision, so an attacker-controlled sha that starts
303
// with `-` could inject options, and a non-hex value could resolve to
304
// commit could resolve to surprising refs. Object names are 4-64 lowercase hex chars.
305
if (!/^[0-9a-f]{4,64}$/.test(sha)) {
306
return undefined;
307
}
308
const inside = await this._runGit(workingDirectory, ['rev-parse', '--is-inside-work-tree']);
309
if (inside?.trim() !== 'true') {
310
return undefined;
311
}
312
// `git show` exits non-zero when the path didn't exist at that
313
// commit; `_runGit` swallows that into `undefined` which is exactly
314
// the contract callers want.
315
return new Promise((resolve) => {
316
cp.execFile('git', ['show', `${sha}:${repoRelativePath}`], { cwd: workingDirectory.fsPath, timeout: 5000, encoding: 'buffer', maxBuffer: 32 * 1024 * 1024 }, (error, stdout) => {
317
if (error) {
318
resolve(undefined);
319
return;
320
}
321
resolve(VSBuffer.wrap(stdout as Buffer));
322
});
323
});
324
}
325
326
async getSessionGitState(workingDirectory: URI): Promise<ISessionGitState | undefined> {
327
return this._computeSessionGitState(workingDirectory);
328
}
329
330
private async _computeSessionGitState(workingDirectory: URI): Promise<ISessionGitState | undefined> {
331
// Bail fast if not inside a git work tree.
332
const inside = await this._runGit(workingDirectory, ['rev-parse', '--is-inside-work-tree']);
333
if (inside?.trim() !== 'true') {
334
return undefined;
335
}
336
337
// Run all probes in parallel. Each handles its own errors and returns
338
// undefined on failure so we can populate fields independently.
339
const [
340
statusOutput,
341
remotesOutput,
342
defaultBranchRef,
343
] = await Promise.all([
344
this._runGit(workingDirectory, ['status', '-b', '--porcelain=v2']),
345
this._runGit(workingDirectory, ['remote', '-v']),
346
this._runGit(workingDirectory, ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD']),
347
]);
348
349
const status = parseGitStatusV2(statusOutput);
350
const hasGitHubRemote = parseHasGitHubRemote(remotesOutput);
351
const baseBranchName = parseDefaultBranchRef(defaultBranchRef);
352
353
// `git status -b --porcelain=v2` only emits ahead/behind counts when the
354
// branch has an upstream tracking ref. For agent-host worktrees the
355
// branch is typically created locally with no upstream, so the user can
356
// have committed work that we'd otherwise report as 0 outgoing changes
357
// and the "Create PR" button would never appear. Fall back to counting
358
// commits relative to the base branch — that matches what the user
359
// actually cares about for "is there work to PR?".
360
let outgoingChanges = status.outgoingChanges;
361
if (outgoingChanges === undefined && baseBranchName && status.branchName && status.branchName !== baseBranchName) {
362
const ahead = await this._runGit(workingDirectory, ['rev-list', '--count', `${baseBranchName}..HEAD`]);
363
const parsed = ahead === undefined ? NaN : Number(ahead.trim());
364
if (Number.isFinite(parsed)) {
365
outgoingChanges = parsed;
366
}
367
}
368
369
const result: ISessionGitState = {
370
hasGitHubRemote,
371
branchName: status.branchName,
372
baseBranchName,
373
upstreamBranchName: status.upstreamBranchName,
374
incomingChanges: status.incomingChanges,
375
outgoingChanges,
376
uncommittedChanges: status.uncommittedChanges,
377
};
378
// Strip undefined fields so the resulting object is the same regardless
379
// of which probes succeeded — easier to compare in tests.
380
return stripUndefined(result);
381
}
382
383
private _runGit(workingDirectory: URI, args: readonly string[], options?: { readonly timeout?: number; readonly throwOnError?: boolean; readonly env?: Record<string, string>; readonly maxBuffer?: number }): Promise<string | undefined> {
384
return new Promise((resolve, reject) => {
385
const env = options?.env ? { ...process.env, ...options.env } : undefined;
386
// Default maxBuffer is 32MB — Node's default is ~1MB, which is
387
// easy to exceed for diff output in large repos. Exceeding it
388
// causes execFile to error and we'd silently drop the diff.
389
cp.execFile('git', [...args], { cwd: workingDirectory.fsPath, timeout: options?.timeout ?? 5000, env, maxBuffer: options?.maxBuffer ?? 32 * 1024 * 1024 }, (error, stdout, stderr) => {
390
if (error) {
391
if (options?.throwOnError) {
392
reject(new Error(stderr || error.message));
393
return;
394
}
395
resolve(undefined);
396
return;
397
}
398
resolve(stdout);
399
});
400
});
401
}
402
}
403
404
/**
405
* The well-known SHA-1 of git's empty tree, used as a fallback when a
406
* repository has no commits (no `HEAD` to read into the temp index).
407
*/
408
export const EMPTY_TREE_OBJECT = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
409
410
/**
411
* Parses NUL-separated `git status --porcelain=v1 -z --untracked-files=all`
412
* output and returns the repo-relative paths of untracked entries (status
413
* `??`). Other entries are ignored; we only need to know whether any
414
* untracked files exist to decide whether to use the temp-index path.
415
*
416
* Exported for tests.
417
*/
418
export function parseUntrackedPaths(output: string | undefined): string[] {
419
if (!output) {
420
return [];
421
}
422
const result: string[] = [];
423
const segments = output.split('\x00');
424
for (let i = 0; i < segments.length; i++) {
425
const seg = segments[i];
426
if (!seg) { continue; }
427
// Each entry is "XY <path>"; for renames v1 emits a second NUL-separated
428
// "from" path that we have to skip. We only care about untracked here.
429
const status = seg.substring(0, 2);
430
const path = seg.substring(3);
431
if (status === '??') {
432
result.push(path);
433
} else if (status[0] === 'R' || status[0] === 'C') {
434
// Skip the "from" path for renames/copies.
435
i++;
436
}
437
}
438
return result;
439
}
440
441
/**
442
* Parses combined `--raw --numstat -z` output produced by
443
* {@link IAgentHostGitService.computeSessionFileDiffs} and converts each
444
* change into an {@link ISessionFileDiff} ready for the protocol.
445
*
446
* The combined NUL-separated stream alternates between `--raw` segments
447
* (start with `:`) and `--numstat` segments. For renames the raw segment
448
* is followed by two extra path segments (old, new); the numstat segment
449
* has an empty path field followed by old/new path segments.
450
*
451
* Exported for tests.
452
*/
453
export function parseGitDiffRawNumstat(output: string, repositoryRoot: URI, sessionUri: string, mergeBaseCommit: string): ISessionFileDiff[] {
454
const segments = output.split('\x00');
455
const changes: { kind: FileEditKind; oldPath?: string; newPath?: string }[] = [];
456
const numStats = new Map<string, { added: number; removed: number }>();
457
458
let i = 0;
459
while (i < segments.length) {
460
const segment = segments[i++];
461
if (!segment) { continue; }
462
463
if (segment.startsWith(':')) {
464
// Raw line: ":<srcMode> <dstMode> <srcSha> <dstSha> <status>"
465
// followed by NUL-separated path(s).
466
const fields = segment.split(' ');
467
const status = fields[4] ?? '';
468
const path1 = segments[i++];
469
if (!path1) { continue; }
470
471
switch (status[0]) {
472
case 'A':
473
changes.push({ kind: FileEditKind.Create, newPath: path1 });
474
break;
475
case 'M':
476
changes.push({ kind: FileEditKind.Edit, oldPath: path1, newPath: path1 });
477
break;
478
case 'D':
479
changes.push({ kind: FileEditKind.Delete, oldPath: path1 });
480
break;
481
case 'R': {
482
const path2 = segments[i++];
483
if (!path2) { continue; }
484
changes.push({ kind: FileEditKind.Rename, oldPath: path1, newPath: path2 });
485
break;
486
}
487
default:
488
break;
489
}
490
} else {
491
// Numstat line: "<added>\t<removed>\t<path>" or, for renames,
492
// "<added>\t<removed>\t" followed by NUL-separated old/new paths.
493
const [addedStr, removedStr, filePath] = segment.split('\t');
494
let key: string;
495
if (filePath === '' || filePath === undefined) {
496
const oldPath = segments[i++];
497
const newPath = segments[i++];
498
key = newPath ?? oldPath ?? '';
499
} else {
500
key = filePath;
501
}
502
if (!key) { continue; }
503
numStats.set(key, {
504
added: addedStr === '-' ? 0 : Number(addedStr) || 0,
505
removed: removedStr === '-' ? 0 : Number(removedStr) || 0,
506
});
507
}
508
}
509
510
return changes.map(change => {
511
const stats = numStats.get(change.newPath ?? change.oldPath ?? '');
512
const hasBefore = change.kind !== FileEditKind.Create;
513
const hasAfter = change.kind !== FileEditKind.Delete;
514
return {
515
...(hasBefore && change.oldPath ? {
516
before: {
517
uri: URI.joinPath(repositoryRoot, change.oldPath).toString(),
518
content: { uri: buildGitBlobUri(sessionUri, mergeBaseCommit, change.oldPath) },
519
},
520
} : {}),
521
...(hasAfter && change.newPath ? {
522
after: {
523
uri: URI.joinPath(repositoryRoot, change.newPath).toString(),
524
content: { uri: URI.joinPath(repositoryRoot, change.newPath).toString() },
525
},
526
} : {}),
527
diff: { added: stats?.added ?? 0, removed: stats?.removed ?? 0 },
528
};
529
});
530
}
531
532
/**
533
* Parses output of `git status -b --porcelain=v2`. The format is documented
534
* at https://git-scm.com/docs/git-status. We care about a few header lines:
535
*
536
* # branch.head <name>
537
* # branch.upstream <name>
538
* # branch.ab +<ahead> -<behind>
539
*
540
* and the count of non-header lines (one per changed entry).
541
*
542
* Exported for tests.
543
*/
544
export function parseGitStatusV2(output: string | undefined): {
545
branchName?: string;
546
upstreamBranchName?: string;
547
outgoingChanges?: number;
548
incomingChanges?: number;
549
uncommittedChanges?: number;
550
} {
551
if (!output) {
552
return {};
553
}
554
let branchName: string | undefined;
555
let upstreamBranchName: string | undefined;
556
let outgoingChanges: number | undefined;
557
let incomingChanges: number | undefined;
558
let uncommittedChanges = 0;
559
for (const rawLine of output.split(/\r?\n/g)) {
560
const line = rawLine.trimEnd();
561
if (!line) { continue; }
562
if (line.startsWith('# branch.head ')) {
563
const head = line.substring('# branch.head '.length).trim();
564
// `(detached)` is what git emits for a detached HEAD. Treat as no branch.
565
branchName = head === '(detached)' ? undefined : head;
566
} else if (line.startsWith('# branch.upstream ')) {
567
upstreamBranchName = line.substring('# branch.upstream '.length).trim();
568
} else if (line.startsWith('# branch.ab ')) {
569
const m = /^# branch\.ab \+(\d+) -(\d+)$/.exec(line);
570
if (m) {
571
outgoingChanges = Number(m[1]);
572
incomingChanges = Number(m[2]);
573
}
574
} else if (!line.startsWith('#')) {
575
uncommittedChanges++;
576
}
577
}
578
return { branchName, upstreamBranchName, outgoingChanges, incomingChanges, uncommittedChanges };
579
}
580
581
/** Exported for tests. */
582
export function parseHasGitHubRemote(remotesOutput: string | undefined): boolean | undefined {
583
if (remotesOutput === undefined) {
584
return undefined;
585
}
586
if (!remotesOutput.trim()) {
587
return false;
588
}
589
return /github\.com[:\/]/i.test(remotesOutput);
590
}
591
592
/** Exported for tests. */
593
export function parseDefaultBranchRef(symbolicRefOutput: string | undefined): string | undefined {
594
const ref = symbolicRefOutput?.trim();
595
if (!ref) { return undefined; }
596
const prefix = 'refs/remotes/origin/';
597
return ref.startsWith(prefix) ? ref.substring(prefix.length) : ref;
598
}
599
600
function stripUndefined<T extends object>(obj: T): T {
601
const out: Record<string, unknown> = {};
602
for (const [k, v] of Object.entries(obj)) {
603
if (v !== undefined) { out[k] = v; }
604
}
605
return out as T;
606
}
607
608