Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorktreeServiceImpl.ts
13399 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 l10n from '@vscode/l10n';
7
import { promises as fs } from 'fs';
8
import * as vscode from 'vscode';
9
import { CancellationToken } from 'vscode-languageserver-protocol';
10
import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService';
11
import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext';
12
import { IGitCommitMessageService } from '../../../platform/git/common/gitCommitMessageService';
13
import { getGitHubRepoInfoFromContext, IGitService, RepoContext } from '../../../platform/git/common/gitService';
14
import { toGitUri } from '../../../platform/git/common/utils';
15
import { buildTempIndexEnv, getUncommittedFilePaths, parseGitChangesRaw } from '../../../platform/git/vscode-node/utils';
16
import { DiffChange } from '../../../platform/git/vscode/git';
17
import { ILogService } from '../../../platform/log/common/logService';
18
import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService';
19
import { Disposable } from '../../../util/vs/base/common/lifecycle';
20
import * as path from '../../../util/vs/base/common/path';
21
import { generateUuid } from '../../../util/vs/base/common/uuid';
22
import { IAgentSessionsWorkspace } from '../common/agentSessionsWorkspace';
23
import { IChatSessionMetadataStore } from '../common/chatSessionMetadataStore';
24
import { ChatSessionWorktreeFile, ChatSessionWorktreeProperties, ChatSessionWorktreePropertiesV2, IChatSessionWorktreeService } from '../common/chatSessionWorktreeService';
25
26
// const CHAT_SESSION_WORKTREE_MEMENTO_KEY = 'github.copilot.cli.sessionWorktrees';
27
28
export class ChatSessionWorktreeService extends Disposable implements IChatSessionWorktreeService {
29
declare _serviceBrand: undefined;
30
31
private _sessionWorktrees: Map<string, string | ChatSessionWorktreeProperties> = new Map();
32
private readonly _onDidChangeWorktreeChanges = this._register(new vscode.EventEmitter<{ sessionId: string }>());
33
readonly onDidChangeWorktreeChanges = this._onDidChangeWorktreeChanges.event;
34
constructor(
35
@IAgentSessionsWorkspace private readonly agentSessionsWorkspace: IAgentSessionsWorkspace,
36
@IConfigurationService private readonly configurationService: IConfigurationService,
37
@IGitCommitMessageService private readonly gitCommitMessageService: IGitCommitMessageService,
38
@IGitService private readonly gitService: IGitService,
39
@ILogService private readonly logService: ILogService,
40
@IVSCodeExtensionContext private readonly extensionContext: IVSCodeExtensionContext,
41
@IWorkspaceService private readonly workspaceService: IWorkspaceService,
42
@IChatSessionMetadataStore private readonly metadataStore: IChatSessionMetadataStore,
43
) {
44
super();
45
// This is not used.
46
// void this.extensionContext.globalState.update(CHAT_SESSION_WORKTREE_MEMENTO_KEY, undefined);
47
}
48
49
async createWorktree(repositoryPath: vscode.Uri, stream?: vscode.ChatResponseStream, baseBranch?: string, branchName?: string): Promise<ChatSessionWorktreeProperties | undefined> {
50
if (!stream) {
51
return this._createWorktree(repositoryPath, undefined, baseBranch, branchName);
52
}
53
54
return new Promise<ChatSessionWorktreeProperties | undefined>((resolve) => {
55
stream.progress(l10n.t('Creating isolated worktree for Copilot CLI session...'), async progress => {
56
const result = await this._createWorktree(repositoryPath, progress, baseBranch, branchName);
57
resolve(result);
58
if (result) {
59
return l10n.t('Created isolated worktree for branch {0}', result.branchName);
60
}
61
return undefined;
62
});
63
});
64
}
65
66
private async _createWorktree(repositoryPath: vscode.Uri, progress?: vscode.Progress<vscode.ChatResponsePart>, baseBranch?: string, branchName?: string): Promise<ChatSessionWorktreeProperties | undefined> {
67
try {
68
const activeRepository = await this.gitService.getRepository(repositoryPath);
69
if (!activeRepository) {
70
progress?.report(new vscode.ChatResponseWarningPart(vscode.l10n.t('Failed to create worktree for isolation, using default workspace directory')));
71
this.logService.error('[ChatSessionWorktreeService][_createWorktree] No active repository found to create worktree for isolation.');
72
return undefined;
73
}
74
75
const autoCommit = this.configurationService.getConfig<boolean>(ConfigKey.Advanced.CLIAutoCommitEnabled);
76
77
let baseCommit: string | undefined = undefined;
78
const branch = await this.generateBranchName(branchName, activeRepository);
79
80
// When a base branch is provided, we attempt to resolve it, to see whether it has an
81
// upstream. If there is an upstream, we use the upstream as the base for the worktree
82
// since that is more likely to be up to date.
83
if (this.agentSessionsWorkspace.isAgentSessionsWorkspace && baseBranch) {
84
try {
85
// Attempt to resolve the provided base branch
86
const branchDetails = await this.gitService.getBranch(activeRepository.rootUri, baseBranch);
87
if (branchDetails?.upstream?.remote && branchDetails.upstream?.name) {
88
const upstreamBranchName = `${branchDetails.upstream.remote}/${branchDetails.upstream.name}`;
89
90
try {
91
// Attempt to resolve the upstream branch before using it as the base for the worktree
92
const upstreamBranch = await this.gitService.getBranch(activeRepository.rootUri, upstreamBranchName);
93
if (upstreamBranch) {
94
baseBranch = upstreamBranchName;
95
baseCommit = upstreamBranch.commit;
96
}
97
} catch (error) {
98
const errorMessage = error instanceof Error ? error.message : String(error);
99
this.logService.warn(`[ChatSessionWorktreeService][_createWorktree] Failed to resolve upstream branch ${upstreamBranchName}. Error: ${errorMessage}`);
100
}
101
}
102
} catch (error) {
103
const errorMessage = error instanceof Error ? error.message : String(error);
104
this.logService.warn(`[ChatSessionWorktreeService][_createWorktree] Failed to resolve base branch ${baseBranch}. Error: ${errorMessage}`);
105
}
106
}
107
108
const worktreePath = await this.gitService.createWorktree(activeRepository.rootUri, { branch, commitish: baseBranch, noTrack: true });
109
110
if (worktreePath && activeRepository.headCommitHash && activeRepository.headBranchName) {
111
const baseBranchName = baseBranch ?? activeRepository.headBranchName;
112
const baseBranchProtected = await this.gitService.isBranchProtected(activeRepository.rootUri, baseBranchName);
113
114
if (baseBranch && !baseCommit) {
115
const refs = await this.gitService.getRefs(activeRepository.rootUri, { pattern: `refs/heads/${baseBranch}` });
116
baseCommit = refs.length === 1 && refs[0].commit ? refs[0].commit : undefined;
117
}
118
119
const gitHubRemote = getGitHubRepoInfoFromContext(activeRepository);
120
const incomingChanges = activeRepository.headIncomingChanges ?? 0;
121
const outgoingChanges = activeRepository.headOutgoingChanges ?? 0;
122
const uncommittedChanges = (activeRepository.changes?.mergeChanges.length ?? 0) +
123
(activeRepository.changes?.indexChanges.length ?? 0) +
124
(activeRepository.changes?.workingTree.length ?? 0) +
125
(activeRepository.changes?.untrackedChanges.length ?? 0);
126
127
return {
128
autoCommit,
129
branchName: branch,
130
baseCommit: baseCommit ?? activeRepository.headCommitHash,
131
baseBranchName,
132
baseBranchProtected,
133
upstreamBranchName: activeRepository.upstreamRemote && activeRepository.upstreamBranchName
134
? `${activeRepository.upstreamRemote}/${activeRepository.upstreamBranchName}`
135
: undefined,
136
mergeBaseCommit: baseCommit ?? activeRepository.headCommitHash,
137
hasGitHubRemote: gitHubRemote !== undefined,
138
incomingChanges,
139
outgoingChanges,
140
uncommittedChanges,
141
repositoryPath: activeRepository.rootUri.fsPath,
142
worktreePath,
143
version: 2
144
} satisfies ChatSessionWorktreeProperties;
145
}
146
progress?.report(new vscode.ChatResponseWarningPart(vscode.l10n.t('Failed to create worktree for isolation, using default workspace directory')));
147
this.logService.error('[ChatSessionWorktreeService][_createWorktree] Failed to create worktree for isolation.');
148
return undefined;
149
} catch (error) {
150
progress?.report(new vscode.ChatResponseWarningPart(vscode.l10n.t('Error creating worktree for isolation: {0}', error instanceof Error ? error.message : String(error))));
151
this.logService.error('[ChatSessionWorktreeService][_createWorktree] Error creating worktree for isolation: ', error);
152
return undefined;
153
}
154
}
155
156
private async generateBranchName(preferredName: string | undefined, repository: RepoContext) {
157
const branchPrefixConfig = vscode.workspace.getConfiguration('git').get<string>('branchPrefix') ?? '';
158
const branchPrefix = this.agentSessionsWorkspace.isAgentSessionsWorkspace ? 'agents' : 'copilot';
159
160
if (preferredName) {
161
let branchName = `${branchPrefixConfig}${branchPrefix}/${preferredName}`;
162
// Check if we already have a branch with the preferred name, and if not, then use it.
163
// Else suffix the preferred name with a random string to avoid conflicts.
164
const refs = await this.gitService.getRefs(repository.rootUri, { pattern: `refs/heads/${branchName}` });
165
if (refs.some(ref => ref.name === branchName)) {
166
branchName = `${branchName}-${generateUuid().replaceAll('-', '').substring(0, 8).toLowerCase()}`;
167
}
168
169
return branchName;
170
}
171
172
// Attempt to generate a random branch name for the worktree
173
const randomBranchName = await this.gitService.generateRandomBranchName(repository.rootUri);
174
175
const branch = randomBranchName ? `${branchPrefixConfig}${branchPrefix}/${randomBranchName.substring(branchPrefixConfig.length)}`
176
: `${branchPrefixConfig}${branchPrefix}/worktree-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}`;
177
178
return branch;
179
}
180
181
async getWorktreeProperties(sessionId: string): Promise<ChatSessionWorktreeProperties | undefined> {
182
const properties = this._sessionWorktrees.get(sessionId);
183
if (properties !== undefined) {
184
return typeof properties === 'string' ? undefined : properties;
185
}
186
// Fall back to metadata store (file-based)
187
return this.metadataStore.getWorktreeProperties(sessionId);
188
}
189
190
async setWorktreeProperties(sessionId: string, properties: ChatSessionWorktreeProperties): Promise<void> {
191
this._sessionWorktrees.set(sessionId, properties);
192
await this.metadataStore.storeWorktreeInfo(sessionId, properties);
193
// If we're explicitly clearing the changes.
194
if ('changes' in properties && !properties.changes) {
195
this._onDidChangeWorktreeChanges.fire({ sessionId });
196
}
197
}
198
199
async getWorktreeRepository(sessionId: string): Promise<RepoContext | undefined> {
200
const worktreeProperties = await this.getWorktreeProperties(sessionId);
201
if (typeof worktreeProperties === 'string' || !worktreeProperties?.repositoryPath) {
202
return undefined;
203
}
204
205
return this.gitService.getRepository(vscode.Uri.file(worktreeProperties.repositoryPath));
206
}
207
208
async getWorktreePath(sessionId: string): Promise<vscode.Uri | undefined> {
209
const worktreeProperties = await this.getWorktreeProperties(sessionId);
210
if (!worktreeProperties) {
211
return undefined;
212
} else if (typeof worktreeProperties === 'string') {
213
// Legacy worktree path
214
return vscode.Uri.file(worktreeProperties);
215
} else {
216
// Worktree properties v1
217
return vscode.Uri.file(worktreeProperties.worktreePath);
218
}
219
}
220
221
async applyWorktreeChanges(sessionId: string): Promise<void> {
222
const worktreeProperties = await this.getWorktreeProperties(sessionId);
223
224
if (worktreeProperties === undefined || (worktreeProperties.version === 1 && worktreeProperties.autoCommit === false)) {
225
// Legacy background session that has the changes staged in the worktree.
226
// To apply the changes, we need to migrate them from the worktree to the
227
// main repository using a stash.
228
const worktreePath = await this.getWorktreePath(sessionId);
229
if (!worktreePath) {
230
return;
231
}
232
233
const activeRepository = worktreeProperties?.repositoryPath
234
? await this.gitService.getRepository(vscode.Uri.file(worktreeProperties.repositoryPath))
235
: this.workspaceService.getWorkspaceFolders().length === 1 ? this.gitService.activeRepository.get() : undefined;
236
237
if (!activeRepository) {
238
return;
239
}
240
241
// Migrate the changes from the worktree to the main repository
242
await this.gitService.migrateChanges(activeRepository.rootUri, worktreePath, {
243
confirmation: false,
244
deleteFromSource: false,
245
untracked: true
246
});
247
248
// Delete worktree changes cache
249
if (worktreeProperties) {
250
await this.setWorktreeProperties(sessionId, {
251
...worktreeProperties,
252
changes: undefined
253
});
254
}
255
256
return;
257
}
258
259
// Copilot CLI session that has the changes committed in the worktree. To apply the
260
// changes, we need to migrate them from the worktree to the main repository using
261
// a patch file.
262
const patch = await this.gitService.diffBetweenPatch(
263
vscode.Uri.file(worktreeProperties.worktreePath),
264
worktreeProperties.baseCommit,
265
worktreeProperties.branchName);
266
267
if (!patch) {
268
return;
269
}
270
271
// Write the patch to a temporary file
272
const encoder = new TextEncoder();
273
const patchFilePath = path.join(worktreeProperties.repositoryPath, '.git', `${worktreeProperties.branchName}.patch`);
274
const patchFileUri = vscode.Uri.file(patchFilePath);
275
await vscode.workspace.fs.writeFile(patchFileUri, encoder.encode(patch));
276
277
try {
278
// Apply patch
279
await this.gitService.applyPatch(vscode.Uri.file(worktreeProperties.repositoryPath), patchFilePath);
280
} catch (error) {
281
this.logService.error(`[ChatSessionWorktreeService][applyWorktreeChanges] Error applying patch file ${patchFilePath} to repository ${worktreeProperties.repositoryPath}: `, error);
282
throw error;
283
} finally {
284
await vscode.workspace.fs.delete(patchFileUri);
285
}
286
287
// Update base commit for the worktree after applying the changes
288
const ref = await this.gitService.getRefs(vscode.Uri.file(worktreeProperties.repositoryPath), {
289
pattern: `refs/heads/${worktreeProperties.branchName}`
290
});
291
292
if (ref.length === 1 && ref[0].commit && ref[0].commit !== worktreeProperties.baseCommit) {
293
// Update baseCommit to the new HEAD of the worktree branch. We are doing this to
294
// clear the list of changes for the session since all changes have been applied
295
// to the main repository at this point.
296
await this.setWorktreeProperties(sessionId, {
297
...worktreeProperties,
298
baseCommit: ref[0].commit,
299
changes: undefined
300
});
301
} else {
302
// Clear the changes cache even if we couldn't determine the new HEAD
303
await this.setWorktreeProperties(sessionId, {
304
...worktreeProperties,
305
changes: undefined
306
});
307
}
308
}
309
310
async hasCachedChanges(sessionId: string): Promise<boolean> {
311
const worktreeProperties = await this.getWorktreeProperties(sessionId);
312
if (!worktreeProperties || typeof worktreeProperties === 'string') {
313
return false;
314
}
315
return !!worktreeProperties.changes;
316
}
317
318
async getWorktreeChanges(sessionId: string): Promise<readonly vscode.ChatSessionChangedFile[] | undefined> {
319
const worktreeProperties = await this.getWorktreeProperties(sessionId);
320
if (!worktreeProperties || typeof worktreeProperties === 'string') {
321
return undefined;
322
}
323
324
// Return cached changes
325
if (worktreeProperties.changes) {
326
return worktreeProperties.changes
327
.map(change => this._toChatSessionChangedFile2(sessionId, change, worktreeProperties));
328
}
329
330
try {
331
// Ensure the initial repository discovery is completed and the repository
332
// states are initialized in the vscode.git extension. This is needed as these
333
// will be the repositories that we use to compute the worktree changes. We do
334
// not have to open each worktree individually since the changes are committed
335
// so we can get them from the main repository or discovered worktree.
336
await this.gitService.initialize();
337
338
// Legacy - these changes are staged in the worktree but not yet committed. Since
339
// the changes are not committed, we need to get them from the worktree repository
340
// state. To do that we need to open the worktree repository. The source control
341
// provider will not be shown in the Source Control view since it is being hidden.
342
if (worktreeProperties.version === 1 && worktreeProperties.autoCommit === false) {
343
const changes = await this._getWorktreeChangesFromIndex(worktreeProperties) ?? [];
344
await this.setWorktreeProperties(sessionId, {
345
...worktreeProperties, changes
346
});
347
348
return changes.map(change => this._toChatSessionChangedFile2(sessionId, change, worktreeProperties));
349
}
350
351
// Auto-commit is enabled which means that following each turn the changes are
352
// committed. We can use the commit history of the worktree branch to compute
353
// the changes. For the Sessions app, we do want to provide updated changes
354
// while the session is in progress.
355
if (worktreeProperties.version === 2 && worktreeProperties.autoCommit === true) {
356
const properties = vscode.workspace.isAgentSessionsWorkspace
357
? await this._getWorktreeChanges(sessionId, worktreeProperties)
358
: await this._getWorktreeChangesFromCommits(worktreeProperties);
359
360
if (properties) {
361
await this.setWorktreeProperties(sessionId, {
362
...worktreeProperties, ...properties
363
});
364
}
365
366
return properties?.changes.map(change => this._toChatSessionChangedFile2(sessionId, change, worktreeProperties)) ?? [];
367
}
368
369
// Use checkpoints to compute the changes
370
const properties = await this._getWorktreeChanges(sessionId, worktreeProperties);
371
if (properties) {
372
await this.setWorktreeProperties(sessionId, {
373
...worktreeProperties, ...properties
374
});
375
}
376
377
return properties?.changes.map(change => this._toChatSessionChangedFile2(sessionId, change, worktreeProperties)) ?? [];
378
} catch (error) {
379
const errorMessage = error instanceof Error ? error.message : String(error);
380
this.logService.warn(`[ChatSessionWorktreeCheckpointService][getWorktreeChanges] Session ${sessionId}: error computing diff for committed changes, returning empty. Error: ${errorMessage}`);
381
await this.setWorktreeProperties(sessionId, {
382
...worktreeProperties, changes: []
383
});
384
385
return [];
386
}
387
}
388
389
async handleRequestCompleted(sessionId: string): Promise<void> {
390
const worktreeProperties = await this.getWorktreeProperties(sessionId);
391
if (!worktreeProperties) {
392
return;
393
}
394
395
// Auto-commit is disabled for this worktree
396
if (worktreeProperties.autoCommit === false) {
397
this.logService.trace(`[ChatSessionWorktreeService][handleRequestCompleted] Auto-commit is disabled, skipping commit of worktree changes for session ${sessionId}`);
398
399
// Delete worktree changes cache
400
await this.setWorktreeProperties(sessionId, {
401
...worktreeProperties,
402
changes: undefined
403
});
404
405
return;
406
}
407
408
const worktreePath = worktreeProperties.worktreePath;
409
410
// Commit all changes in the worktree
411
const repository = await this.gitCommitMessageService.getRepository(vscode.Uri.file(worktreePath));
412
if (!repository) {
413
this.logService.error(`[ChatSessionWorktreeService][handleRequestCompleted] Unable to find repository for working directory ${worktreePath}`);
414
throw new Error(`Unable to find repository for working directory ${worktreePath}`);
415
}
416
417
if (repository.state.workingTreeChanges.length === 0 && repository.state.indexChanges.length === 0 && repository.state.untrackedChanges.length === 0) {
418
this.logService.trace(`[ChatSessionWorktreeService][handleRequestCompleted] No changes to commit in working directory ${worktreePath}`);
419
420
// Delete worktree changes cache
421
await this.setWorktreeProperties(sessionId, {
422
...worktreeProperties,
423
changes: undefined
424
});
425
426
return;
427
}
428
429
let message: string | undefined;
430
try {
431
this.logService.trace(`[ChatSessionWorktreeService][handleRequestCompleted] Generating commit message for working directory ${worktreePath}. Repository state: ${JSON.stringify(repository.state)}`);
432
message = await this.gitCommitMessageService.generateCommitMessage(repository, CancellationToken.None);
433
} catch (error) {
434
const errorMessage = error instanceof Error ? error.message : String(error);
435
this.logService.error(`[ChatSessionWorktreeService][handleRequestCompleted] Error generating commit message for working directory ${worktreePath}. Repository state: ${JSON.stringify(repository.state)}. Error: ${errorMessage}`);
436
}
437
438
if (!message) {
439
// Fallback commit message
440
this.logService.warn(`[ChatSessionWorktreeService][handleRequestCompleted] Unable to generate commit message for working directory ${worktreePath}. Repository state: ${JSON.stringify(repository.state)}`);
441
message = `Copilot CLI session ${sessionId} changes`;
442
}
443
444
// Commit the changes
445
await this.gitService.commit(vscode.Uri.file(worktreePath), message, { all: true, noVerify: true, signCommit: false });
446
this.logService.trace(`[ChatSessionWorktreeService] Committed all changes in working directory ${worktreePath}`);
447
448
// Delete worktree changes cache
449
await this.setWorktreeProperties(sessionId, {
450
...worktreeProperties,
451
changes: undefined
452
});
453
}
454
455
async cleanupWorktreeOnArchive(sessionId: string): Promise<{ cleaned: boolean; reason?: string }> {
456
const worktreeProperties = await this.getWorktreeProperties(sessionId);
457
if (!worktreeProperties) {
458
return { cleaned: false, reason: 'no-worktree' };
459
}
460
461
const worktreePath = worktreeProperties.worktreePath;
462
463
// Check if the worktree directory exists
464
try {
465
await fs.access(worktreePath);
466
} catch {
467
this.logService.trace(`[ChatSessionWorktreeService][cleanupWorktreeOnArchive] Worktree path does not exist: ${worktreePath}`);
468
return { cleaned: false, reason: 'worktree-not-found' };
469
}
470
471
// Get the git repository for the worktree
472
const repository = await this.gitCommitMessageService.getRepository(vscode.Uri.file(worktreePath));
473
if (!repository) {
474
this.logService.warn(`[ChatSessionWorktreeService][cleanupWorktreeOnArchive] Unable to find repository for worktree ${worktreePath}`);
475
return { cleaned: false, reason: 'no-repository' };
476
}
477
478
const hasUncommittedChanges = repository.state.workingTreeChanges.length > 0
479
|| repository.state.indexChanges.length > 0
480
|| repository.state.untrackedChanges.length > 0;
481
482
if (hasUncommittedChanges) {
483
// For auto-commit sessions, commit changes before cleanup
484
if (worktreeProperties.autoCommit !== false) {
485
this.logService.trace(`[ChatSessionWorktreeService][cleanupWorktreeOnArchive] Auto-committing changes before cleanup for session ${sessionId}`);
486
try {
487
await this.handleRequestCompleted(sessionId);
488
} catch (error) {
489
const errorMessage = error instanceof Error ? error.message : String(error);
490
this.logService.error(`[ChatSessionWorktreeService][cleanupWorktreeOnArchive] Failed to auto-commit: ${errorMessage}`);
491
return { cleaned: false, reason: 'auto-commit-failed' };
492
}
493
} else {
494
// Non-auto-commit sessions with uncommitted changes: skip cleanup
495
this.logService.trace(`[ChatSessionWorktreeService][cleanupWorktreeOnArchive] Skipping cleanup for session ${sessionId}: has uncommitted changes and auto-commit is disabled`);
496
return { cleaned: false, reason: 'uncommitted-changes' };
497
}
498
}
499
500
// Verify the branch exists before deleting the worktree
501
try {
502
const refs = await this.gitService.getRefs(
503
vscode.Uri.file(worktreeProperties.repositoryPath),
504
{ pattern: `refs/heads/${worktreeProperties.branchName}` }
505
);
506
if (!refs || refs.length === 0) {
507
this.logService.warn(`[ChatSessionWorktreeService][cleanupWorktreeOnArchive] Branch ${worktreeProperties.branchName} not found, skipping cleanup`);
508
return { cleaned: false, reason: 'branch-not-found' };
509
}
510
} catch (error) {
511
const errorMessage = error instanceof Error ? error.message : String(error);
512
this.logService.warn(`[ChatSessionWorktreeService][cleanupWorktreeOnArchive] Failed to verify branch: ${errorMessage}`);
513
return { cleaned: false, reason: 'branch-check-failed' };
514
}
515
516
// Delete the worktree
517
try {
518
const parentRepository = await this.gitService.getRepository(vscode.Uri.file(worktreeProperties.repositoryPath), true);
519
if (!parentRepository) {
520
this.logService.warn(`[ChatSessionWorktreeService][cleanupWorktreeOnArchive] No parent repository found for ${worktreeProperties.repositoryPath}`);
521
return { cleaned: false, reason: 'no-parent-repository' };
522
}
523
await this.gitService.deleteWorktree(parentRepository.rootUri, worktreePath);
524
this.logService.trace(`[ChatSessionWorktreeService][cleanupWorktreeOnArchive] Deleted worktree ${worktreePath} for session ${sessionId}`);
525
return { cleaned: true };
526
} catch (error) {
527
const errorMessage = error instanceof Error ? error.message : String(error);
528
this.logService.error(`[ChatSessionWorktreeService][cleanupWorktreeOnArchive] Failed to delete worktree: ${errorMessage}`);
529
return { cleaned: false, reason: 'delete-failed' };
530
}
531
}
532
533
async recreateWorktreeOnUnarchive(sessionId: string): Promise<{ recreated: boolean; reason?: string }> {
534
const worktreeProperties = await this.getWorktreeProperties(sessionId);
535
if (!worktreeProperties) {
536
return { recreated: false, reason: 'no-worktree-properties' };
537
}
538
539
const worktreePath = worktreeProperties.worktreePath;
540
541
// Check if the worktree already exists on disk
542
try {
543
await fs.access(worktreePath);
544
this.logService.trace(`[ChatSessionWorktreeService][recreateWorktreeOnUnarchive] Worktree already exists at ${worktreePath}`);
545
return { recreated: false, reason: 'already-exists' };
546
} catch {
547
// Expected — worktree was cleaned up on archive
548
}
549
550
// Verify the branch still exists in the parent repository
551
try {
552
const refs = await this.gitService.getRefs(
553
vscode.Uri.file(worktreeProperties.repositoryPath),
554
{ pattern: `refs/heads/${worktreeProperties.branchName}` }
555
);
556
if (!refs || refs.length === 0) {
557
this.logService.warn(`[ChatSessionWorktreeService][recreateWorktreeOnUnarchive] Branch ${worktreeProperties.branchName} no longer exists`);
558
return { recreated: false, reason: 'branch-not-found' };
559
}
560
} catch (error) {
561
const errorMessage = error instanceof Error ? error.message : String(error);
562
this.logService.warn(`[ChatSessionWorktreeService][recreateWorktreeOnUnarchive] Failed to verify branch: ${errorMessage}`);
563
return { recreated: false, reason: 'branch-check-failed' };
564
}
565
566
// Recreate the worktree from the existing branch
567
try {
568
const parentRepository = await this.gitService.getRepository(vscode.Uri.file(worktreeProperties.repositoryPath), true);
569
if (!parentRepository) {
570
this.logService.warn(`[ChatSessionWorktreeService][recreateWorktreeOnUnarchive] No parent repository found for ${worktreeProperties.repositoryPath}`);
571
return { recreated: false, reason: 'no-parent-repository' };
572
}
573
574
// Use commitish (existing branch) without branch (no -b flag) to checkout the existing branch
575
const createdPath = await this.gitService.createWorktree(parentRepository.rootUri, {
576
path: worktreePath,
577
commitish: worktreeProperties.branchName,
578
});
579
580
if (!createdPath) {
581
this.logService.error(`[ChatSessionWorktreeService][recreateWorktreeOnUnarchive] createWorktree returned no path`);
582
return { recreated: false, reason: 'create-failed' };
583
}
584
585
this.logService.trace(`[ChatSessionWorktreeService][recreateWorktreeOnUnarchive] Recreated worktree at ${createdPath} for session ${sessionId}`);
586
return { recreated: true };
587
} catch (error) {
588
const errorMessage = error instanceof Error ? error.message : String(error);
589
this.logService.error(`[ChatSessionWorktreeService][recreateWorktreeOnUnarchive] Failed to recreate worktree: ${errorMessage}`);
590
return { recreated: false, reason: 'create-failed' };
591
}
592
}
593
594
private async _getWorktreeChangesFromIndex(worktreeProperties: ChatSessionWorktreeProperties): Promise<readonly ChatSessionWorktreeFile[] | undefined> {
595
const worktreePath = vscode.Uri.file(worktreeProperties.worktreePath);
596
const worktreeRepository = await this.gitService.getRepository(worktreePath);
597
598
if (!worktreeRepository?.changes) {
599
return [];
600
}
601
602
const changes: ChatSessionWorktreeFile[] = [];
603
for (const change of [...worktreeRepository.changes.indexChanges, ...worktreeRepository.changes.workingTree]) {
604
try {
605
const fileStats = await this.gitService.diffIndexWithHEADShortStats(change.uri);
606
changes.push({
607
filePath: change.uri.fsPath,
608
originalFilePath: change.status !== 1 /* INDEX_ADDED */
609
? change.originalUri?.fsPath
610
: undefined,
611
modifiedFilePath: change.status !== 2 /* INDEX_DELETED */
612
? change.uri.fsPath
613
: undefined,
614
statistics: {
615
additions: fileStats?.insertions ?? 0,
616
deletions: fileStats?.deletions ?? 0
617
}
618
} satisfies ChatSessionWorktreeFile);
619
} catch (error) { }
620
}
621
622
return changes;
623
}
624
625
private async _getWorktreeChangesFromCommits(worktreeProperties: ChatSessionWorktreePropertiesV2): Promise<{ changes: readonly ChatSessionWorktreeFile[] } | undefined> {
626
// Open the main repository that contains the worktree. We have to open
627
// the repository so that we can run do `git diff` against the repository
628
// to get the committed changes in the worktree branch.
629
const repository = await this.gitService.getRepository(vscode.Uri.file(worktreeProperties.repositoryPath));
630
631
if (!repository) {
632
return undefined;
633
}
634
635
// These changes are committed in the worktree branch but since they are
636
// committed we can get the changes from the main repository and we do
637
// not need to open the worktree repository.
638
const diff = await this.gitService.diffBetweenWithStats(
639
repository.rootUri,
640
worktreeProperties.baseCommit,
641
worktreeProperties.branchName);
642
643
if (!diff) {
644
return { changes: [] };
645
}
646
647
const changes = diff.map(change => {
648
// Since the diff was computed using the main repository, the file paths in the diff are relative to the
649
// main repository. We need to convert them to absolute paths by joining them with the repository path.
650
const worktreeFilePath = path.join(worktreeProperties.worktreePath, path.relative(worktreeProperties.repositoryPath, change.uri.fsPath));
651
const worktreeOriginalFilePath = change.originalUri
652
? path.join(worktreeProperties.worktreePath, path.relative(worktreeProperties.repositoryPath, change.originalUri.fsPath))
653
: undefined;
654
655
return {
656
filePath: worktreeFilePath,
657
originalFilePath: change.status !== 1 /* INDEX_ADDED */
658
? worktreeOriginalFilePath
659
: undefined,
660
modifiedFilePath: change.status !== 6 /* DELETED */
661
? worktreeFilePath
662
: undefined,
663
statistics: {
664
additions: change.insertions,
665
deletions: change.deletions
666
}
667
} satisfies ChatSessionWorktreeFile;
668
});
669
670
return { changes };
671
}
672
673
private async _getWorktreeChanges(sessionId: string, worktreeProperties: ChatSessionWorktreeProperties): Promise<{
674
readonly changes: readonly ChatSessionWorktreeFile[];
675
readonly mergeBaseCommit?: string;
676
readonly hasGitHubRemote?: boolean;
677
readonly upstreamBranchName?: string;
678
readonly incomingChanges?: number;
679
readonly outgoingChanges?: number;
680
readonly uncommittedChanges?: number;
681
} | undefined> {
682
if (worktreeProperties.version !== 2) {
683
this.logService.warn(`[ChatSessionWorktreeService][_getWorktreeChanges] Worktree properties for session ${sessionId} is not version 2.`);
684
return undefined;
685
}
686
687
// We need to open the worktree repository since we need access to the worktree repository's
688
// working tree in order to compute the diff statistics. We do this to provide updates while
689
// the session is in progress, or if auto-commit is disabled
690
const worktreeRepository = await this.gitService.getRepository(vscode.Uri.file(worktreeProperties.worktreePath));
691
692
if (!worktreeRepository) {
693
this.logService.warn(`[ChatSessionWorktreeService][_getWorktreeChanges] Unable to open worktree repository for session ${sessionId} at path ${worktreeProperties.worktreePath}`);
694
return undefined;
695
}
696
697
// Check for untracked changes
698
const hasUntrackedChanges = [
699
...worktreeRepository.changes?.workingTree ?? [],
700
...worktreeRepository.changes?.untrackedChanges ?? [],
701
].some(change => change.status === 7 /* UNTRACKED */);
702
703
704
// If the repository is using a virtual file system, we need to
705
// disable rename detection to avoid expensive git operations
706
const noRenamesArg = worktreeRepository.isUsingVirtualFileSystem
707
? ['--no-renames']
708
: [];
709
710
const diffChanges: DiffChange[] = [];
711
const worktreePath = vscode.Uri.file(worktreeProperties.worktreePath);
712
713
if (hasUntrackedChanges) {
714
// Tracked + untracked changes
715
const tmpDirName = `vscode-sessions-${sessionId}-${generateUuid()}`;
716
const diffIndexFile = path.join(this.extensionContext.globalStorageUri.fsPath, tmpDirName, 'diff.index');
717
const pathspecFile = path.join(this.extensionContext.globalStorageUri.fsPath, tmpDirName, `pathspec.txt`);
718
719
const env = buildTempIndexEnv(worktreeRepository, diffIndexFile);
720
721
try {
722
// Create temp index file directory
723
await fs.mkdir(path.dirname(diffIndexFile), { recursive: true });
724
725
// Populate temp index from HEAD
726
await this.gitService.exec(worktreePath, ['read-tree', 'HEAD'], env);
727
728
// Stage entire working directory into temp index
729
const uncommittedFilePaths = getUncommittedFilePaths(worktreeRepository);
730
await fs.writeFile(pathspecFile, uncommittedFilePaths.join('\n'), 'utf8');
731
await this.gitService.exec(worktreePath, ['add', '-A', `--pathspec-from-file=${pathspecFile}`], env);
732
733
// Diff the temp index with the base branch
734
const result = await this.gitService.exec(worktreePath, ['diff', '--cached', '--raw', '--numstat', '--diff-filter=ADMR', ...noRenamesArg, '-z', '--merge-base', worktreeProperties.baseBranchName, '--'], env);
735
diffChanges.push(...parseGitChangesRaw(worktreeProperties.worktreePath, result));
736
} catch (error) {
737
this.logService.error(`[ChatSessionWorktreeService][_getWorktreeChanges] Error while processing worktree changes for session ${sessionId}: ${error}`);
738
return undefined;
739
} finally {
740
try {
741
await fs.rm(path.dirname(diffIndexFile), { recursive: true, force: true });
742
} catch (error) {
743
this.logService.error(`[ChatSessionWorktreeService][_getWorktreeChanges] Error while cleaning up temp index file for session ${sessionId}: ${error}`);
744
}
745
}
746
} else {
747
// Tracked changes
748
try {
749
const result = await this.gitService.exec(worktreePath, ['diff', '--raw', '--numstat', '--diff-filter=ADMR', ...noRenamesArg, '-z', '--merge-base', worktreeProperties.baseBranchName, '--']);
750
diffChanges.push(...parseGitChangesRaw(worktreeProperties.worktreePath, result));
751
} catch (error) {
752
this.logService.error(`[ChatSessionWorktreeService][_getWorktreeChanges] Error while processing worktree changes for session ${sessionId}: ${error}`);
753
return undefined;
754
}
755
}
756
757
// Since the diff is being computed using the merge base commit of the worktree
758
// branch and the base branch, we need to compute it as well so that we can use
759
// it as the originalRef (left-hand side) of the diff editor
760
let mergeBaseCommit: string | undefined;
761
try {
762
mergeBaseCommit = await this.gitService.getMergeBase(worktreePath, worktreeProperties.branchName, worktreeProperties.baseBranchName);
763
} catch (error) {
764
this.logService.error(`[ChatSessionWorktreeService][_getWorktreeChanges] Error while getting merge base (${worktreeProperties.branchName}, ${worktreeProperties.baseBranchName}) for session ${sessionId}: ${error}`);
765
}
766
767
const changes = diffChanges.map(change => ({
768
filePath: change.uri.fsPath,
769
originalFilePath: change.status !== 1 /* INDEX_ADDED */
770
? change.originalUri?.fsPath
771
: undefined,
772
modifiedFilePath: change.status !== 6 /* DELETED */
773
? change.uri.fsPath
774
: undefined,
775
statistics: {
776
additions: change.insertions,
777
deletions: change.deletions
778
}
779
} satisfies ChatSessionWorktreeFile));
780
781
const repositoryState = {
782
mergeBaseCommit,
783
hasGitHubRemote: getGitHubRepoInfoFromContext(worktreeRepository) !== undefined,
784
upstreamBranchName: worktreeRepository.upstreamRemote && worktreeRepository.upstreamBranchName
785
? `${worktreeRepository.upstreamRemote}/${worktreeRepository.upstreamBranchName}`
786
: undefined,
787
incomingChanges: worktreeRepository.headIncomingChanges ?? 0,
788
outgoingChanges: worktreeRepository.headOutgoingChanges ?? 0,
789
uncommittedChanges:
790
(worktreeRepository.changes?.mergeChanges.length ?? 0) +
791
(worktreeRepository.changes?.indexChanges.length ?? 0) +
792
(worktreeRepository.changes?.workingTree.length ?? 0) +
793
(worktreeRepository.changes?.untrackedChanges.length ?? 0)
794
};
795
796
return { changes, ...repositoryState };
797
}
798
799
private _toChatSessionChangedFile2(sessionId: string, change: ChatSessionWorktreeFile, worktreeProperties: ChatSessionWorktreeProperties): vscode.ChatSessionChangedFile {
800
let originalFileRef: string, modifiedFileRef: string | undefined;
801
if (worktreeProperties.version === 2) {
802
// Commit | Working tree
803
originalFileRef = vscode.workspace.isAgentSessionsWorkspace
804
? worktreeProperties.mergeBaseCommit ?? worktreeProperties.baseCommit
805
: worktreeProperties.baseCommit;
806
modifiedFileRef = vscode.workspace.isAgentSessionsWorkspace
807
? undefined
808
: worktreeProperties.branchName;
809
} else {
810
// Legacy
811
originalFileRef = worktreeProperties.baseCommit;
812
modifiedFileRef = worktreeProperties.branchName;
813
}
814
815
return new vscode.ChatSessionChangedFile(
816
vscode.Uri.file(change.filePath),
817
change.originalFilePath
818
? toGitUri(vscode.Uri.file(change.originalFilePath), originalFileRef)
819
: undefined,
820
change.modifiedFilePath
821
? modifiedFileRef
822
? toGitUri(vscode.Uri.file(change.modifiedFilePath), modifiedFileRef)
823
: vscode.Uri.file(change.modifiedFilePath)
824
: undefined,
825
change.statistics.additions,
826
change.statistics.deletions);
827
}
828
829
async getAdditionalWorktreeProperties(sessionId: string): Promise<ChatSessionWorktreeProperties[]> {
830
const additionalWorkspaces = await this.metadataStore.getAdditionalWorkspaces(sessionId);
831
return additionalWorkspaces
832
.map(ws => ws.worktreeProperties)
833
.filter((props): props is ChatSessionWorktreeProperties => !!props);
834
}
835
836
async setAdditionalWorktreeProperties(sessionId: string, properties: ChatSessionWorktreeProperties[]): Promise<void> {
837
const workspaces = properties.map(props => ({
838
folder: undefined,
839
repository: vscode.Uri.file(props.repositoryPath),
840
worktree: vscode.Uri.file(props.worktreePath),
841
worktreeProperties: props,
842
}));
843
await this.metadataStore.setAdditionalWorkspaces(sessionId, workspaces);
844
}
845
846
async handleRequestCompletedForWorktree(worktreeProperties: ChatSessionWorktreeProperties): Promise<void> {
847
if (worktreeProperties.autoCommit === false) {
848
this.logService.trace(`[ChatSessionWorktreeService][handleRequestCompletedForWorktree] Auto-commit is disabled, skipping commit for worktree ${worktreeProperties.worktreePath}`);
849
return;
850
}
851
852
const worktreePath = worktreeProperties.worktreePath;
853
const repository = await this.gitCommitMessageService.getRepository(vscode.Uri.file(worktreePath));
854
if (!repository) {
855
this.logService.error(`[ChatSessionWorktreeService][handleRequestCompletedForWorktree] Unable to find repository for working directory ${worktreePath}`);
856
throw new Error(`Unable to find repository for working directory ${worktreePath}`);
857
}
858
859
if (repository.state.workingTreeChanges.length === 0 && repository.state.indexChanges.length === 0 && repository.state.untrackedChanges.length === 0) {
860
this.logService.trace(`[ChatSessionWorktreeService][handleRequestCompletedForWorktree] No changes to commit in working directory ${worktreePath}`);
861
return;
862
}
863
864
let message: string | undefined;
865
try {
866
message = await this.gitCommitMessageService.generateCommitMessage(repository, CancellationToken.None);
867
} catch (error) {
868
const errorMessage = error instanceof Error ? error.message : String(error);
869
this.logService.error(`[ChatSessionWorktreeService][handleRequestCompletedForWorktree] Error generating commit message for ${worktreePath}: ${errorMessage}`);
870
}
871
872
if (!message) {
873
message = `Copilot CLI session changes`;
874
}
875
876
await this.gitService.commit(vscode.Uri.file(worktreePath), message, { all: true, noVerify: true, signCommit: false });
877
this.logService.trace(`[ChatSessionWorktreeService][handleRequestCompletedForWorktree] Committed all changes in working directory ${worktreePath}`);
878
}
879
}
880
881