Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/chatSessions/vscode-node/folderRepositoryManagerImpl.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 * as vscode from 'vscode';
8
import { LanguageModelTextPart } from 'vscode';
9
import { IFileSystemService } from '../../../platform/filesystem/common/fileSystemService';
10
import { getGitHubRepoInfoFromContext, IGitService } from '../../../platform/git/common/gitService';
11
import { ILogService } from '../../../platform/log/common/logService';
12
import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService';
13
import { raceCancellation } from '../../../util/vs/base/common/async';
14
import { Disposable, DisposableStore } from '../../../util/vs/base/common/lifecycle';
15
import { ResourceSet } from '../../../util/vs/base/common/map';
16
import { isEqual } from '../../../util/vs/base/common/resources';
17
import { createTimeout } from '../../inlineEdits/common/common';
18
import { IToolsService } from '../../tools/common/toolsService';
19
import { RepositoryProperties, IChatSessionMetadataStore } from '../common/chatSessionMetadataStore';
20
import { IChatSessionWorkspaceFolderService } from '../common/chatSessionWorkspaceFolderService';
21
import { ChatSessionWorktreeProperties, IChatSessionWorktreeService } from '../common/chatSessionWorktreeService';
22
import {
23
FolderRepositoryInfo,
24
FolderRepositoryMRUEntry,
25
GetFolderRepositoryOptions,
26
IFolderRepositoryManager,
27
InitializeFolderRepositoryOptions
28
} from '../common/folderRepositoryManager';
29
import { isUntitledSessionId } from '../common/utils';
30
import { isWelcomeView } from '../copilotcli/node/copilotCli';
31
import { IClaudeSessionStateService } from '../claude/common/claudeSessionStateService';
32
import { ICopilotCLISessionService } from '../copilotcli/node/copilotcliSessionService';
33
34
/**
35
* Message shown when user needs to trust a folder to continue.
36
*/
37
export const UNTRUSTED_FOLDER_MESSAGE = l10n.t('The selected folder is not trusted. Please trust the folder to continue with the {0}.', 'Copilot CLI');
38
39
// #region FolderRepositoryManager (abstract base)
40
41
/**
42
* Abstract base implementation of IFolderRepositoryManager.
43
*
44
* This service centralizes all shared folder/repository management logic including:
45
* - Tracking folder selection for untitled sessions
46
* - Resolving folder/repository/worktree information for new sessions
47
* - Creating worktrees for git repositories
48
* - Verifying trust status
49
* - Tracking MRU (Most Recently Used) folders
50
*
51
* Subclasses must implement {@link getFolderRepository} to provide session-type-specific
52
* resolution of folder information for existing (named) sessions.
53
*/
54
export abstract class FolderRepositoryManager extends Disposable implements IFolderRepositoryManager {
55
declare _serviceBrand: undefined;
56
57
/**
58
* In-memory storage for new session folder selections.
59
* Maps session ID → folder URI.
60
*/
61
protected readonly _newSessionFolders = new Map<string, { uri: vscode.Uri; lastAccessTime: number }>();
62
63
constructor(
64
protected readonly worktreeService: IChatSessionWorktreeService,
65
protected readonly workspaceFolderService: IChatSessionWorkspaceFolderService,
66
protected readonly gitService: IGitService,
67
protected readonly workspaceService: IWorkspaceService,
68
protected readonly logService: ILogService,
69
protected readonly toolsService: IToolsService,
70
protected readonly metadataStore: IChatSessionMetadataStore
71
72
) {
73
super();
74
}
75
76
/**
77
* @deprecated
78
*/
79
setNewSessionFolder(sessionId: string, folderUri: vscode.Uri): void {
80
this._newSessionFolders.set(sessionId, { uri: folderUri, lastAccessTime: Date.now() });
81
}
82
83
/**
84
* @deprecated
85
*/
86
deleteNewSessionFolder(sessionId: string): void {
87
this._newSessionFolders.delete(sessionId);
88
}
89
90
/**
91
* Subclasses provide a fallback folder URI when no worktree or workspace
92
* folder is found for a named session.
93
*/
94
protected abstract getSessionFallbackFolder(sessionId: string): Promise<vscode.Uri | undefined>;
95
96
/**
97
* @inheritdoc
98
*/
99
async getFolderRepository(
100
sessionId: string,
101
options: GetFolderRepositoryOptions | undefined,
102
_token: vscode.CancellationToken
103
): Promise<FolderRepositoryInfo> {
104
// For untitled sessions, use whatever is in memory.
105
if (isUntitledSessionId(sessionId)) {
106
if (options) {
107
const { folder, repository, repositoryProperties, trusted } = await this.getFolderRepositoryForNewSession(sessionId, undefined, options.stream, _token);
108
return { folder, repository, repositoryProperties, worktree: undefined, worktreeProperties: undefined, trusted };
109
} else {
110
const folder = this._newSessionFolders.get(sessionId)?.uri
111
?? await this.workspaceFolderService.getSessionWorkspaceFolder(sessionId);
112
return { folder, repository: undefined, repositoryProperties: undefined, worktree: undefined, trusted: undefined, worktreeProperties: undefined };
113
}
114
}
115
116
// For named sessions, check worktree properties first
117
const worktreeProperties = await this.worktreeService.getWorktreeProperties(sessionId);
118
if (worktreeProperties) {
119
const repositoryUri = vscode.Uri.file(worktreeProperties.repositoryPath);
120
const worktreeUri = vscode.Uri.file(worktreeProperties.worktreePath);
121
122
// Trust check on repository path (not worktree path)
123
let trusted: boolean | undefined;
124
if (options) {
125
trusted = await this.verifyTrust(repositoryUri, options.stream);
126
}
127
128
return {
129
folder: repositoryUri,
130
repository: repositoryUri,
131
repositoryProperties: undefined,
132
worktree: worktreeUri,
133
worktreeProperties,
134
trusted
135
};
136
}
137
138
// Check session workspace folder
139
const sessionWorkspaceFolderEntry = await this.workspaceFolderService.getSessionWorkspaceFolderEntry(sessionId);
140
if (sessionWorkspaceFolderEntry) {
141
const repositoryProperties = await this.workspaceFolderService.getRepositoryProperties(sessionId);
142
let trusted: boolean | undefined;
143
if (options) {
144
trusted = await this.verifyTrust(vscode.Uri.file(sessionWorkspaceFolderEntry.folderPath), options.stream);
145
}
146
147
return {
148
folder: vscode.Uri.file(sessionWorkspaceFolderEntry.folderPath),
149
repository: repositoryProperties?.repositoryPath
150
? vscode.Uri.file(repositoryProperties.repositoryPath)
151
: undefined,
152
repositoryProperties,
153
worktree: undefined,
154
worktreeProperties: undefined,
155
trusted
156
};
157
}
158
159
// Fall back to subclass-specific folder resolution
160
const fallbackFolder = await this.getSessionFallbackFolder(sessionId);
161
if (fallbackFolder) {
162
let trusted: boolean | undefined;
163
if (options) {
164
trusted = await this.verifyTrust(fallbackFolder, options.stream);
165
}
166
167
return {
168
folder: fallbackFolder,
169
repository: undefined,
170
repositoryProperties: undefined,
171
worktree: undefined,
172
worktreeProperties: undefined,
173
trusted
174
};
175
}
176
177
return { folder: undefined, repository: undefined, repositoryProperties: undefined, worktree: undefined, trusted: undefined, worktreeProperties: undefined };
178
}
179
180
/**
181
* @inheritdoc
182
*/
183
async getRepositoryInfo(
184
folder: vscode.Uri,
185
_token: vscode.CancellationToken
186
): Promise<{ repository: vscode.Uri | undefined; headBranchName: string | undefined }> {
187
const repoContext = await this.gitService.getRepository(folder, true);
188
return {
189
repository: repoContext?.rootUri,
190
headBranchName: repoContext?.headBranchName
191
};
192
}
193
194
protected async getFolderRepositoryForNewSession(sessionId: string | undefined, selectedFolder: vscode.Uri | undefined, stream: vscode.ChatResponseStream, token: vscode.CancellationToken): Promise<FolderRepositoryInfo> {
195
// Use the explicitly provided folder, or fall back to the session's stored folder
196
selectedFolder = selectedFolder ?? (sessionId ? (this._newSessionFolders.get(sessionId)?.uri
197
?? await this.workspaceFolderService.getSessionWorkspaceFolder(sessionId)) : undefined);
198
199
// If no folder selected and we have a single workspace folder, use active repository
200
let repositoryUri: vscode.Uri | undefined;
201
let folderUri = selectedFolder;
202
let worktree: vscode.Uri | undefined = undefined;
203
let worktreeProperties: ChatSessionWorktreeProperties | undefined = undefined;
204
let repositoryProperties: RepositoryProperties | undefined = undefined;
205
206
// If we have just one folder opened in workspace, use that as default
207
// TODO: @DonJayamanne Handle Session View.
208
if (!selectedFolder && !isWelcomeView(this.workspaceService) && this.workspaceService.getWorkspaceFolders().length === 1) {
209
const activeRepo = this.gitService.activeRepository.get();
210
repositoryUri = activeRepo?.rootUri;
211
folderUri = repositoryUri ?? this.workspaceService.getWorkspaceFolders()[0];
212
213
// If we're in a single folder workspace, possible the user has opened the worktree folder directly.
214
if (sessionId && folderUri) {
215
const worktreeSessionIds = this.metadataStore.getWorktreeSessions(folderUri);
216
worktreeProperties = worktreeSessionIds.length ? await this.worktreeService.getWorktreeProperties(worktreeSessionIds[0]) : undefined;
217
worktree = worktreeProperties ? vscode.Uri.file(worktreeProperties.worktreePath) : undefined;
218
repositoryUri = worktreeProperties ? vscode.Uri.file(worktreeProperties.repositoryPath) : repositoryUri;
219
}
220
} else if (selectedFolder) {
221
// First check if user trusts the folder.
222
// We need to do this before looking for git repos to avoid prompting for trust twice.
223
// Using getRepository will prompt user to trust the repo, and if not trusted
224
// then undefined is returned and we cannot distinguish between "not a git repo" and "not trusted".
225
const trusted = await this.workspaceService.requestResourceTrust({
226
uri: selectedFolder,
227
message: UNTRUSTED_FOLDER_MESSAGE
228
});
229
230
if (!trusted) {
231
stream.warning(l10n.t('The selected folder is not trusted.'));
232
return {
233
folder: selectedFolder,
234
repository: undefined,
235
repositoryProperties: undefined,
236
trusted: false,
237
worktree,
238
worktreeProperties
239
};
240
}
241
242
// If we're in a single folder workspace, possible the user has opened the worktree folder directly.
243
if (sessionId && folderUri) {
244
const worktreeSessionIds = this.metadataStore.getWorktreeSessions(folderUri);
245
worktreeProperties = worktreeSessionIds.length ? await this.worktreeService.getWorktreeProperties(worktreeSessionIds[0]) : undefined;
246
worktree = worktreeProperties ? vscode.Uri.file(worktreeProperties.worktreePath) : undefined;
247
repositoryUri = worktreeProperties ? vscode.Uri.file(worktreeProperties.repositoryPath) : repositoryUri;
248
}
249
250
// Now look for a git repository in the selected folder.
251
// If found, use it. If not, proceed without isolation.`
252
if (worktreeProperties) {
253
repositoryUri = vscode.Uri.file(worktreeProperties.repositoryPath);
254
} else {
255
const repoContext = await this.gitService.getRepository(selectedFolder);
256
const branchBase = repoContext?.headBranchName && repoContext.headCommitHash
257
? await this.gitService.getBranchBase(repoContext.rootUri, repoContext.headBranchName)
258
: undefined;
259
260
const mergeBaseCommit = repoContext?.headBranchName && branchBase?.commit
261
? await this.gitService.getMergeBase(repoContext.rootUri, repoContext.headBranchName, branchBase.commit)
262
: undefined;
263
264
const gitHubRemote = repoContext
265
? getGitHubRepoInfoFromContext(repoContext)
266
: undefined;
267
const incomingChanges = repoContext?.headIncomingChanges ?? 0;
268
const outgoingChanges = repoContext?.headOutgoingChanges ?? 0;
269
const uncommittedChanges = (repoContext?.changes?.mergeChanges.length ?? 0) +
270
(repoContext?.changes?.indexChanges.length ?? 0) +
271
(repoContext?.changes?.workingTree.length ?? 0) +
272
(repoContext?.changes?.untrackedChanges.length ?? 0);
273
274
repositoryUri = repoContext?.rootUri;
275
repositoryProperties = repoContext
276
? {
277
repositoryPath: repoContext.rootUri.fsPath,
278
branchName: repoContext.headBranchName,
279
baseBranchName: branchBase && branchBase.remote && branchBase.name
280
? `${branchBase.remote}/${branchBase.name}`
281
: undefined,
282
upstreamBranchName: repoContext?.upstreamRemote && repoContext?.upstreamBranchName
283
? `${repoContext.upstreamRemote}/${repoContext.upstreamBranchName}`
284
: undefined,
285
baseCommit: repoContext.headCommitHash,
286
mergeBaseCommit,
287
hasGitHubRemote: gitHubRemote !== undefined,
288
incomingChanges,
289
outgoingChanges,
290
uncommittedChanges
291
} satisfies RepositoryProperties
292
: undefined;
293
}
294
295
// If no git repo found, use folder directly without isolation
296
if (!repositoryUri) {
297
return {
298
folder: selectedFolder,
299
repository: undefined,
300
repositoryProperties: undefined,
301
trusted: true,
302
worktree,
303
worktreeProperties
304
};
305
}
306
}
307
308
if (!repositoryUri) {
309
// No folder or repository selected
310
if (folderUri) {
311
const trusted = await this.verifyTrust(folderUri, stream);
312
return {
313
folder: folderUri,
314
repository: undefined,
315
repositoryProperties: undefined,
316
trusted,
317
worktree,
318
worktreeProperties
319
};
320
}
321
322
return {
323
folder: undefined,
324
repository: undefined,
325
repositoryProperties: undefined,
326
trusted: true,
327
worktree,
328
worktreeProperties
329
};
330
}
331
332
// Verify trust on repository path
333
const trusted = await this.verifyTrust(repositoryUri, stream);
334
335
if (!trusted) {
336
return {
337
folder: folderUri ?? repositoryUri,
338
repository: repositoryUri,
339
repositoryProperties,
340
trusted: false,
341
worktree,
342
worktreeProperties
343
};
344
}
345
346
return {
347
folder: folderUri ?? repositoryUri,
348
repository: repositoryUri,
349
repositoryProperties,
350
trusted: true,
351
worktree,
352
worktreeProperties
353
};
354
}
355
356
/**
357
* @inheritdoc
358
*/
359
async initializeFolderRepository(
360
sessionId: string | undefined,
361
options: InitializeFolderRepositoryOptions,
362
token: vscode.CancellationToken
363
): Promise<FolderRepositoryInfo> {
364
const { stream, toolInvocationToken, branch, isolation } = options;
365
366
let { folder, repository, repositoryProperties, trusted, worktree, worktreeProperties } = await this.getFolderRepositoryForNewSession(sessionId, options.folder, stream, token);
367
if (trusted === false) {
368
return { folder, repository, repositoryProperties, worktree, worktreeProperties, trusted };
369
}
370
if (!repository) {
371
// No git repository found, proceed without isolation
372
return { folder, repository, repositoryProperties, worktree, worktreeProperties, trusted: true };
373
}
374
375
// If user explicitly chose workspace mode, skip worktree creation
376
if (isolation === 'workspace') {
377
this.logService.info(`[FolderRepositoryManager] Workspace isolation mode selected for session ${sessionId}, skipping worktree creation`);
378
return {
379
folder: folder ?? repository,
380
repository,
381
repositoryProperties,
382
worktree: undefined,
383
worktreeProperties: undefined,
384
trusted: true
385
};
386
}
387
388
// Check for uncommitted changes and prompt user before creating worktree
389
let uncommittedChangesAction: 'move' | 'copy' | 'skip' | 'cancel' | undefined = undefined;
390
if (!worktreeProperties) {
391
uncommittedChangesAction = await this.promptForUncommittedChangesAction(sessionId, repository, branch, toolInvocationToken, token);
392
if (uncommittedChangesAction === 'cancel') {
393
return { folder, repository, repositoryProperties, worktree, worktreeProperties, trusted: true, cancelled: true };
394
}
395
}
396
397
// Create worktree for the git repository
398
let newBranchName: string | undefined = undefined;
399
try {
400
newBranchName = options.newBranch ? await options.newBranch : undefined;
401
} catch (ex) {
402
const error = ex instanceof Error ? ex : new Error(String(ex));
403
this.logService.error(error, 'Failed to generate a new branch name for worktree creation');
404
}
405
worktreeProperties = worktreeProperties ?? await this.worktreeService.createWorktree(repository, stream, branch, newBranchName);
406
407
if (!worktreeProperties) {
408
stream.warning(l10n.t('Failed to create worktree. Proceeding without isolation.'));
409
410
return {
411
folder: folder ?? repository,
412
repository,
413
repositoryProperties,
414
worktree,
415
worktreeProperties,
416
trusted
417
};
418
}
419
420
// Store worktree properties for the session
421
// Note: The caller is responsible for calling setWorktreeProperties after getting the real session ID
422
423
this.logService.info(`[FolderRepositoryManager] Created worktree for session ${sessionId}: ${worktreeProperties.worktreePath}`);
424
425
// Migrate changes from active repository to worktree if requested
426
if (uncommittedChangesAction === 'move' || uncommittedChangesAction === 'copy') {
427
await this.moveOrCopyChangesToWorkTree(
428
repository,
429
worktree ?? vscode.Uri.file(worktreeProperties.worktreePath),
430
uncommittedChangesAction,
431
stream,
432
token
433
);
434
}
435
436
return {
437
folder: folder ?? repository,
438
repository,
439
repositoryProperties,
440
worktree: worktree ?? vscode.Uri.file(worktreeProperties.worktreePath),
441
worktreeProperties,
442
trusted: true
443
};
444
}
445
446
async initializeMultiRootFolderRepositories(
447
sessionId: string,
448
primaryFolder: vscode.Uri,
449
additionalFolders: vscode.Uri[],
450
options: InitializeFolderRepositoryOptions,
451
token: vscode.CancellationToken
452
): Promise<{ primary: FolderRepositoryInfo; additional: FolderRepositoryInfo[] }> {
453
const { stream, toolInvocationToken, isolation } = options;
454
const allFolders = [primaryFolder, ...additionalFolders];
455
456
// 1. Resolve all folder/repo info
457
const folderInfos = await Promise.all(
458
allFolders.map(folder => this.getFolderRepositoryForNewSession(sessionId, folder, stream, token))
459
);
460
461
// 2. Filter out untrusted folders
462
const trustedInfos: { folder: vscode.Uri; info: FolderRepositoryInfo }[] = [];
463
for (let i = 0; i < allFolders.length; i++) {
464
if (folderInfos[i].trusted === false) {
465
this.logService.warn(`[FolderRepositoryManager] Multi-root: folder ${allFolders[i].fsPath} is not trusted, excluding`);
466
continue;
467
}
468
trustedInfos.push({ folder: allFolders[i], info: folderInfos[i] });
469
}
470
471
if (trustedInfos.length === 0) {
472
return {
473
primary: { folder: primaryFolder, repository: undefined, repositoryProperties: undefined, worktree: undefined, worktreeProperties: undefined, trusted: false },
474
additional: []
475
};
476
}
477
478
// 3. If workspace mode, skip worktree creation — return all as-is
479
if (isolation === 'workspace') {
480
this.logService.info(`[FolderRepositoryManager] Multi-root: workspace isolation mode, skipping worktree creation for all folders`);
481
const primary = trustedInfos.find(t => t.folder.fsPath === primaryFolder.fsPath)?.info
482
?? { folder: primaryFolder, repository: undefined, repositoryProperties: undefined, worktree: undefined, worktreeProperties: undefined, trusted: true };
483
const additional = trustedInfos
484
.filter(t => t.folder.fsPath !== primaryFolder.fsPath)
485
.map(t => ({
486
folder: t.info.folder ?? t.folder,
487
repository: undefined,
488
repositoryProperties: undefined,
489
worktree: undefined,
490
worktreeProperties: undefined,
491
trusted: true as boolean | undefined,
492
}));
493
return {
494
primary: { ...primary, repository: undefined, repositoryProperties: undefined, worktree: undefined, worktreeProperties: undefined },
495
additional
496
};
497
}
498
499
// 4. Collect uncommitted changes from ALL git repos into one combined list
500
const reposWithChanges: { folder: vscode.Uri; repository: vscode.Uri; modifiedFiles: Array<{ uri: vscode.Uri; originalUri?: vscode.Uri; insertions?: number; deletions?: number }> }[] = [];
501
for (const { folder, info } of trustedInfos) {
502
if (!info.repository) {
503
continue;
504
}
505
const repo = await this.gitService.getRepository(info.repository, false);
506
if (!repo) {
507
continue;
508
}
509
const modifiedFiles = await this.getModifiedFilesForConfirmation(info.repository, repo, token);
510
if (modifiedFiles.length > 0) {
511
reposWithChanges.push({ folder, repository: info.repository, modifiedFiles });
512
}
513
}
514
515
// 5. Show ONE combined prompt if any repo has uncommitted changes
516
let uncommittedChangesAction: 'move' | 'copy' | 'skip' | 'cancel' | undefined = undefined;
517
if (reposWithChanges.length > 0) {
518
const allModifiedFiles = reposWithChanges.flatMap(r => r.modifiedFiles);
519
uncommittedChangesAction = await this._promptForMultiRootUncommittedChanges(toolInvocationToken, allModifiedFiles, token);
520
if (uncommittedChangesAction === 'cancel') {
521
return {
522
primary: { folder: primaryFolder, repository: undefined, repositoryProperties: undefined, worktree: undefined, worktreeProperties: undefined, trusted: true, cancelled: true },
523
additional: []
524
};
525
}
526
}
527
528
// 6. Create worktrees for all git repo folders in parallel
529
const results: { folder: vscode.Uri; info: FolderRepositoryInfo }[] = [];
530
const worktreeCreationResults = await Promise.allSettled(
531
trustedInfos.map(async ({ folder, info }) => {
532
if (!info.repository) {
533
// Non-git folder — keep as plain folder
534
return { folder, info };
535
}
536
537
const worktreeProperties = await this.worktreeService.createWorktree(info.repository, stream);
538
if (!worktreeProperties) {
539
this.logService.warn(`[FolderRepositoryManager] Multi-root: failed to create worktree for ${info.repository.fsPath}, proceeding without isolation`);
540
return { folder, info };
541
}
542
543
this.logService.info(`[FolderRepositoryManager] Multi-root: created worktree for ${info.repository.fsPath}: ${worktreeProperties.worktreePath}`);
544
return {
545
folder,
546
info: {
547
folder: info.folder ?? info.repository,
548
repository: info.repository,
549
repositoryProperties: info.repositoryProperties,
550
worktree: vscode.Uri.file(worktreeProperties.worktreePath),
551
worktreeProperties,
552
trusted: true as boolean | undefined,
553
}
554
};
555
})
556
);
557
558
for (const result of worktreeCreationResults) {
559
if (result.status === 'fulfilled') {
560
results.push(result.value);
561
} else {
562
this.logService.error(`[FolderRepositoryManager] Multi-root: worktree creation failed: ${result.reason}`);
563
}
564
}
565
566
// 7. Migrate changes to worktrees if requested
567
if (uncommittedChangesAction === 'move' || uncommittedChangesAction === 'copy') {
568
const reposWithChangesSet = new Set(reposWithChanges.map(r => r.repository.fsPath));
569
await Promise.allSettled(
570
results
571
.filter(r => r.info.repository && r.info.worktree && reposWithChangesSet.has(r.info.repository.fsPath))
572
.map(r => this.moveOrCopyChangesToWorkTree(r.info.repository!, r.info.worktree!, uncommittedChangesAction!, stream, token))
573
);
574
}
575
576
// 8. Build result
577
const primaryResult = results.find(r => r.folder.fsPath === primaryFolder.fsPath)?.info
578
?? { folder: primaryFolder, repository: undefined, repositoryProperties: undefined, worktree: undefined, worktreeProperties: undefined, trusted: true };
579
const additionalResults = results
580
.filter(r => r.folder.fsPath !== primaryFolder.fsPath)
581
.map(r => r.info);
582
583
return { primary: primaryResult, additional: additionalResults };
584
}
585
586
private async _promptForMultiRootUncommittedChanges(
587
toolInvocationToken: vscode.ChatParticipantToolToken,
588
modifiedFiles: Array<{ uri: vscode.Uri; originalUri?: vscode.Uri; insertions?: number; deletions?: number }>,
589
token: vscode.CancellationToken
590
): Promise<'move' | 'copy' | 'skip' | 'cancel'> {
591
const title = l10n.t('Uncommitted Changes');
592
const message = l10n.t('Some repositories have uncommitted changes. Should these changes be included in the new worktrees?');
593
const copyChanges = l10n.t('Copy Changes');
594
const moveChanges = l10n.t('Move Changes');
595
const skipChanges = l10n.t('Skip Changes');
596
const options = [copyChanges, moveChanges, skipChanges];
597
const input = { title, message, options, modifiedFiles };
598
const result = await this.toolsService.invokeTool('vscode_get_modified_files_confirmation', { input, toolInvocationToken }, token);
599
const selection = this.getSelectedUncommittedChangesAction(result, options);
600
switch (selection?.toUpperCase()) {
601
case moveChanges.toUpperCase(): return 'move';
602
case copyChanges.toUpperCase(): return 'copy';
603
case skipChanges.toUpperCase(): return 'skip';
604
default: return 'cancel';
605
}
606
}
607
608
/**
609
* @inheritdoc
610
*/
611
async getFolderMRU(): Promise<FolderRepositoryMRUEntry[]> {
612
const latestReposAndFolders: FolderRepositoryMRUEntry[] = [];
613
const seenUris = new ResourceSet();
614
615
for (const { uri, lastAccessTime } of this._newSessionFolders.values()) {
616
if (seenUris.has(uri)) {
617
continue;
618
}
619
seenUris.add(uri);
620
latestReposAndFolders.push({
621
folder: uri,
622
repository: undefined,
623
lastAccessed: lastAccessTime,
624
});
625
}
626
627
// Add recent git repositories
628
for (const repo of this.gitService.getRecentRepositories()) {
629
if (seenUris.has(repo.rootUri)) {
630
continue;
631
}
632
seenUris.add(repo.rootUri);
633
latestReposAndFolders.push({
634
folder: repo.rootUri,
635
repository: repo.rootUri,
636
lastAccessed: repo.lastAccessTime,
637
});
638
}
639
640
// Sort by last access time descending and limit
641
latestReposAndFolders.sort((a, b) => b.lastAccessed - a.lastAccessed);
642
643
return latestReposAndFolders;
644
}
645
646
/**
647
* Check for uncommitted changes and prompt user for action.
648
*
649
* @returns The user's chosen action, or `undefined` if there are no uncommitted changes.
650
*/
651
private async promptForUncommittedChangesAction(
652
sessionId: string | undefined,
653
repositoryUri: vscode.Uri,
654
branch: string | undefined,
655
toolInvocationToken: vscode.ChatParticipantToolToken,
656
token: vscode.CancellationToken
657
): Promise<'move' | 'copy' | 'skip' | 'cancel' | undefined> {
658
const uncommittedChanges = await this.getUncommittedChanges(repositoryUri, branch, token);
659
if (!uncommittedChanges) {
660
return undefined;
661
}
662
663
const isDelegation = !sessionId;
664
const title = isDelegation
665
? l10n.t('Delegate to Copilot CLI')
666
: l10n.t('Uncommitted Changes');
667
const message = isDelegation
668
? l10n.t('Copilot CLI will work in an isolated worktree to implement your requested changes.')
669
+ '\n\n'
670
+ l10n.t('The selected repository has uncommitted changes. Should these changes be included in the new worktree?')
671
: l10n.t('The selected repository has uncommitted changes. Should these changes be included in the new worktree?');
672
673
const copyChanges = l10n.t('Copy Changes');
674
const moveChanges = l10n.t('Move Changes');
675
const skipChanges = l10n.t('Skip Changes');
676
const options = [copyChanges, moveChanges, skipChanges];
677
const input = {
678
title,
679
message,
680
options,
681
modifiedFiles: uncommittedChanges.modifiedFiles
682
};
683
const result = await this.toolsService.invokeTool('vscode_get_modified_files_confirmation', { input, toolInvocationToken }, token);
684
685
const selection = this.getSelectedUncommittedChangesAction(result, options);
686
687
switch (selection?.toUpperCase()) {
688
case moveChanges.toUpperCase():
689
return 'move';
690
case copyChanges.toUpperCase():
691
return 'copy';
692
case skipChanges.toUpperCase():
693
return 'skip';
694
default:
695
return 'cancel';
696
}
697
}
698
699
private getSelectedUncommittedChangesAction(
700
result: vscode.LanguageModelToolResult,
701
options: readonly string[]
702
): string | undefined {
703
for (const part of result.content) {
704
if (!(part instanceof LanguageModelTextPart)) {
705
continue;
706
}
707
708
const matchedOption = options.find(option => option.toUpperCase() === part.value.toUpperCase());
709
if (matchedOption) {
710
return matchedOption;
711
}
712
}
713
714
return undefined;
715
}
716
717
private async getUncommittedChanges(
718
folderPath: vscode.Uri,
719
branch: string | undefined,
720
token: vscode.CancellationToken
721
): Promise<{ repository: vscode.Uri; modifiedFiles: Array<{ uri: vscode.Uri; originalUri?: vscode.Uri; insertions?: number; deletions?: number }> } | undefined> {
722
const repository = await this.gitService.getRepository(folderPath);
723
if (!repository) {
724
return undefined;
725
}
726
727
// If the current branch is not the same as the requested branch, we cannot reliably determine the uncommitted changes, so skip the confirmation.
728
if (branch && repository.headBranchName !== branch) {
729
return undefined;
730
}
731
732
const modifiedFiles = await this.getModifiedFilesForConfirmation(repository.rootUri, repository, token);
733
if (modifiedFiles.length === 0) {
734
return undefined;
735
}
736
737
return {
738
repository: repository.rootUri,
739
modifiedFiles
740
};
741
}
742
743
private async getModifiedFilesForConfirmation(
744
repositoryUri: vscode.Uri,
745
repository: NonNullable<ReturnType<IGitService['activeRepository']['get']>>,
746
token: vscode.CancellationToken
747
): Promise<Array<{ uri: vscode.Uri; originalUri?: vscode.Uri; insertions?: number; deletions?: number }>> {
748
749
if (token.isCancellationRequested || !repository.changes) {
750
return [];
751
}
752
753
const modifiedFiles = new Map<string, { uri: vscode.Uri; originalUri?: vscode.Uri; insertions?: number; deletions?: number }>();
754
for (const change of [...repository.changes.indexChanges, ...repository.changes.workingTree]) {
755
const changePath = (change as { path?: string }).path;
756
const fileUri = change.uri ?? (changePath ? vscode.Uri.joinPath(repositoryUri, changePath) : undefined);
757
modifiedFiles.set(fileUri.toString(), {
758
uri: fileUri,
759
originalUri: change.originalUri
760
});
761
}
762
763
return [...modifiedFiles.values()];
764
}
765
766
/**
767
* Verify trust for a folder/repository and report via stream if not trusted.
768
*/
769
protected async verifyTrust(folderUri: vscode.Uri, stream: vscode.ChatResponseStream): Promise<boolean> {
770
const trusted = await this.workspaceService.requestResourceTrust({
771
uri: folderUri,
772
message: UNTRUSTED_FOLDER_MESSAGE
773
});
774
775
if (!trusted) {
776
stream.warning(l10n.t('The selected folder is not trusted.'));
777
return false;
778
}
779
780
return true;
781
}
782
783
/**
784
* Move or copy uncommitted changes from the active repository to the worktree.
785
*/
786
private async moveOrCopyChangesToWorkTree(
787
repositoryPath: vscode.Uri,
788
worktreePath: vscode.Uri,
789
moveOrCopyChanges: 'move' | 'copy',
790
stream: vscode.ChatResponseStream,
791
token: vscode.CancellationToken
792
): Promise<void> {
793
// Migrate changes from active repository to worktree
794
const activeRepository = await this.gitService.getRepository(repositoryPath);
795
if (!activeRepository) {
796
return;
797
}
798
const hasUncommittedChanges = activeRepository.changes
799
? (activeRepository.changes.indexChanges.length > 0 || activeRepository.changes.workingTree.length > 0)
800
: false;
801
if (!hasUncommittedChanges) {
802
return;
803
}
804
805
const disposables = new DisposableStore();
806
try {
807
// Wait for the worktree repository to be ready
808
stream.progress(l10n.t('Migrating changes to worktree...'));
809
const worktreeRepo = await raceCancellation(new Promise<typeof activeRepository | undefined>((resolve) => {
810
disposables.add(this.gitService.onDidOpenRepository(repo => {
811
if (isEqual(repo.rootUri, worktreePath)) {
812
resolve(repo);
813
}
814
}));
815
816
this.gitService.getRepository(worktreePath).then(repo => {
817
if (repo) {
818
resolve(repo);
819
}
820
});
821
822
disposables.add(createTimeout(10_000, () => resolve(undefined)));
823
}), token);
824
825
if (!worktreeRepo) {
826
stream.warning(l10n.t('Failed to get worktree repository. Proceeding without migration.'));
827
} else {
828
await this.gitService.migrateChanges(worktreeRepo.rootUri, activeRepository.rootUri, {
829
confirmation: false,
830
deleteFromSource: moveOrCopyChanges === 'move',
831
untracked: true
832
});
833
stream.markdown(l10n.t('Changes migrated to worktree.\n'));
834
}
835
} catch (error) {
836
// Continue even if migration fails
837
stream.warning(l10n.t('Failed to migrate some changes: {0}. Continuing with worktree creation.', error instanceof Error ? error.message : String(error)));
838
} finally {
839
disposables.dispose();
840
}
841
}
842
}
843
844
// #endregion
845
846
// #region CopilotCLIFolderRepositoryManager
847
848
/**
849
* CopilotCLI-specific implementation that resolves folder information for
850
* existing sessions using the CLI session service as a fallback.
851
*/
852
export class CopilotCLIFolderRepositoryManager extends FolderRepositoryManager {
853
constructor(
854
@IChatSessionWorktreeService worktreeService: IChatSessionWorktreeService,
855
@IChatSessionWorkspaceFolderService workspaceFolderService: IChatSessionWorkspaceFolderService,
856
@ICopilotCLISessionService private readonly sessionService: ICopilotCLISessionService,
857
@IGitService gitService: IGitService,
858
@IWorkspaceService workspaceService: IWorkspaceService,
859
@ILogService logService: ILogService,
860
@IToolsService toolsService: IToolsService,
861
@IFileSystemService private readonly fileSystem: IFileSystemService,
862
@IChatSessionMetadataStore metadataStore: IChatSessionMetadataStore
863
) {
864
super(worktreeService, workspaceFolderService, gitService, workspaceService, logService, toolsService, metadataStore);
865
}
866
867
/**
868
* @inheritdoc
869
*/
870
protected async getSessionFallbackFolder(sessionId: string): Promise<vscode.Uri | undefined> {
871
const cwd = this.sessionService.getSessionWorkingDirectory(sessionId);
872
if (cwd && (await checkPathExists(cwd, this.fileSystem))) {
873
return cwd;
874
}
875
return undefined;
876
}
877
}
878
879
async function checkPathExists(filePath: vscode.Uri, fileSystem: IFileSystemService): Promise<boolean> {
880
try {
881
await fileSystem.stat(filePath);
882
return true;
883
} catch (error) {
884
return false;
885
}
886
}
887
888
// #endregion
889
890
// #region ClaudeFolderRepositoryManager
891
892
/**
893
* Claude-specific implementation that resolves folder information for
894
* existing sessions using the Claude session state service as a fallback.
895
*/
896
export class ClaudeFolderRepositoryManager extends FolderRepositoryManager {
897
constructor(
898
@IChatSessionWorktreeService worktreeService: IChatSessionWorktreeService,
899
@IChatSessionWorkspaceFolderService workspaceFolderService: IChatSessionWorkspaceFolderService,
900
@IGitService gitService: IGitService,
901
@IWorkspaceService workspaceService: IWorkspaceService,
902
@ILogService logService: ILogService,
903
@IToolsService toolsService: IToolsService,
904
@IClaudeSessionStateService private readonly sessionStateService: IClaudeSessionStateService,
905
@IFileSystemService private readonly fileSystem: IFileSystemService,
906
@IChatSessionMetadataStore metadataStore: IChatSessionMetadataStore
907
) {
908
super(worktreeService, workspaceFolderService, gitService, workspaceService, logService, toolsService, metadataStore);
909
}
910
911
/**
912
* @inheritdoc
913
*/
914
protected async getSessionFallbackFolder(sessionId: string): Promise<vscode.Uri | undefined> {
915
const folderInfo = this.sessionStateService.getFolderInfoForSession(sessionId);
916
if (folderInfo && (await checkPathExists(vscode.Uri.file(folderInfo.cwd), this.fileSystem))) {
917
return vscode.Uri.file(folderInfo.cwd);
918
}
919
return undefined;
920
}
921
}
922
923
// #endregion
924
925