Path: blob/main/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorktreeServiceImpl.ts
13399 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import * as l10n from '@vscode/l10n';6import { promises as fs } from 'fs';7import * as vscode from 'vscode';8import { CancellationToken } from 'vscode-languageserver-protocol';9import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService';10import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext';11import { IGitCommitMessageService } from '../../../platform/git/common/gitCommitMessageService';12import { getGitHubRepoInfoFromContext, IGitService, RepoContext } from '../../../platform/git/common/gitService';13import { toGitUri } from '../../../platform/git/common/utils';14import { buildTempIndexEnv, getUncommittedFilePaths, parseGitChangesRaw } from '../../../platform/git/vscode-node/utils';15import { DiffChange } from '../../../platform/git/vscode/git';16import { ILogService } from '../../../platform/log/common/logService';17import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService';18import { Disposable } from '../../../util/vs/base/common/lifecycle';19import * as path from '../../../util/vs/base/common/path';20import { generateUuid } from '../../../util/vs/base/common/uuid';21import { IAgentSessionsWorkspace } from '../common/agentSessionsWorkspace';22import { IChatSessionMetadataStore } from '../common/chatSessionMetadataStore';23import { ChatSessionWorktreeFile, ChatSessionWorktreeProperties, ChatSessionWorktreePropertiesV2, IChatSessionWorktreeService } from '../common/chatSessionWorktreeService';2425// const CHAT_SESSION_WORKTREE_MEMENTO_KEY = 'github.copilot.cli.sessionWorktrees';2627export class ChatSessionWorktreeService extends Disposable implements IChatSessionWorktreeService {28declare _serviceBrand: undefined;2930private _sessionWorktrees: Map<string, string | ChatSessionWorktreeProperties> = new Map();31private readonly _onDidChangeWorktreeChanges = this._register(new vscode.EventEmitter<{ sessionId: string }>());32readonly onDidChangeWorktreeChanges = this._onDidChangeWorktreeChanges.event;33constructor(34@IAgentSessionsWorkspace private readonly agentSessionsWorkspace: IAgentSessionsWorkspace,35@IConfigurationService private readonly configurationService: IConfigurationService,36@IGitCommitMessageService private readonly gitCommitMessageService: IGitCommitMessageService,37@IGitService private readonly gitService: IGitService,38@ILogService private readonly logService: ILogService,39@IVSCodeExtensionContext private readonly extensionContext: IVSCodeExtensionContext,40@IWorkspaceService private readonly workspaceService: IWorkspaceService,41@IChatSessionMetadataStore private readonly metadataStore: IChatSessionMetadataStore,42) {43super();44// This is not used.45// void this.extensionContext.globalState.update(CHAT_SESSION_WORKTREE_MEMENTO_KEY, undefined);46}4748async createWorktree(repositoryPath: vscode.Uri, stream?: vscode.ChatResponseStream, baseBranch?: string, branchName?: string): Promise<ChatSessionWorktreeProperties | undefined> {49if (!stream) {50return this._createWorktree(repositoryPath, undefined, baseBranch, branchName);51}5253return new Promise<ChatSessionWorktreeProperties | undefined>((resolve) => {54stream.progress(l10n.t('Creating isolated worktree for Copilot CLI session...'), async progress => {55const result = await this._createWorktree(repositoryPath, progress, baseBranch, branchName);56resolve(result);57if (result) {58return l10n.t('Created isolated worktree for branch {0}', result.branchName);59}60return undefined;61});62});63}6465private async _createWorktree(repositoryPath: vscode.Uri, progress?: vscode.Progress<vscode.ChatResponsePart>, baseBranch?: string, branchName?: string): Promise<ChatSessionWorktreeProperties | undefined> {66try {67const activeRepository = await this.gitService.getRepository(repositoryPath);68if (!activeRepository) {69progress?.report(new vscode.ChatResponseWarningPart(vscode.l10n.t('Failed to create worktree for isolation, using default workspace directory')));70this.logService.error('[ChatSessionWorktreeService][_createWorktree] No active repository found to create worktree for isolation.');71return undefined;72}7374const autoCommit = this.configurationService.getConfig<boolean>(ConfigKey.Advanced.CLIAutoCommitEnabled);7576let baseCommit: string | undefined = undefined;77const branch = await this.generateBranchName(branchName, activeRepository);7879// When a base branch is provided, we attempt to resolve it, to see whether it has an80// upstream. If there is an upstream, we use the upstream as the base for the worktree81// since that is more likely to be up to date.82if (this.agentSessionsWorkspace.isAgentSessionsWorkspace && baseBranch) {83try {84// Attempt to resolve the provided base branch85const branchDetails = await this.gitService.getBranch(activeRepository.rootUri, baseBranch);86if (branchDetails?.upstream?.remote && branchDetails.upstream?.name) {87const upstreamBranchName = `${branchDetails.upstream.remote}/${branchDetails.upstream.name}`;8889try {90// Attempt to resolve the upstream branch before using it as the base for the worktree91const upstreamBranch = await this.gitService.getBranch(activeRepository.rootUri, upstreamBranchName);92if (upstreamBranch) {93baseBranch = upstreamBranchName;94baseCommit = upstreamBranch.commit;95}96} catch (error) {97const errorMessage = error instanceof Error ? error.message : String(error);98this.logService.warn(`[ChatSessionWorktreeService][_createWorktree] Failed to resolve upstream branch ${upstreamBranchName}. Error: ${errorMessage}`);99}100}101} catch (error) {102const errorMessage = error instanceof Error ? error.message : String(error);103this.logService.warn(`[ChatSessionWorktreeService][_createWorktree] Failed to resolve base branch ${baseBranch}. Error: ${errorMessage}`);104}105}106107const worktreePath = await this.gitService.createWorktree(activeRepository.rootUri, { branch, commitish: baseBranch, noTrack: true });108109if (worktreePath && activeRepository.headCommitHash && activeRepository.headBranchName) {110const baseBranchName = baseBranch ?? activeRepository.headBranchName;111const baseBranchProtected = await this.gitService.isBranchProtected(activeRepository.rootUri, baseBranchName);112113if (baseBranch && !baseCommit) {114const refs = await this.gitService.getRefs(activeRepository.rootUri, { pattern: `refs/heads/${baseBranch}` });115baseCommit = refs.length === 1 && refs[0].commit ? refs[0].commit : undefined;116}117118const gitHubRemote = getGitHubRepoInfoFromContext(activeRepository);119const incomingChanges = activeRepository.headIncomingChanges ?? 0;120const outgoingChanges = activeRepository.headOutgoingChanges ?? 0;121const uncommittedChanges = (activeRepository.changes?.mergeChanges.length ?? 0) +122(activeRepository.changes?.indexChanges.length ?? 0) +123(activeRepository.changes?.workingTree.length ?? 0) +124(activeRepository.changes?.untrackedChanges.length ?? 0);125126return {127autoCommit,128branchName: branch,129baseCommit: baseCommit ?? activeRepository.headCommitHash,130baseBranchName,131baseBranchProtected,132upstreamBranchName: activeRepository.upstreamRemote && activeRepository.upstreamBranchName133? `${activeRepository.upstreamRemote}/${activeRepository.upstreamBranchName}`134: undefined,135mergeBaseCommit: baseCommit ?? activeRepository.headCommitHash,136hasGitHubRemote: gitHubRemote !== undefined,137incomingChanges,138outgoingChanges,139uncommittedChanges,140repositoryPath: activeRepository.rootUri.fsPath,141worktreePath,142version: 2143} satisfies ChatSessionWorktreeProperties;144}145progress?.report(new vscode.ChatResponseWarningPart(vscode.l10n.t('Failed to create worktree for isolation, using default workspace directory')));146this.logService.error('[ChatSessionWorktreeService][_createWorktree] Failed to create worktree for isolation.');147return undefined;148} catch (error) {149progress?.report(new vscode.ChatResponseWarningPart(vscode.l10n.t('Error creating worktree for isolation: {0}', error instanceof Error ? error.message : String(error))));150this.logService.error('[ChatSessionWorktreeService][_createWorktree] Error creating worktree for isolation: ', error);151return undefined;152}153}154155private async generateBranchName(preferredName: string | undefined, repository: RepoContext) {156const branchPrefixConfig = vscode.workspace.getConfiguration('git').get<string>('branchPrefix') ?? '';157const branchPrefix = this.agentSessionsWorkspace.isAgentSessionsWorkspace ? 'agents' : 'copilot';158159if (preferredName) {160let branchName = `${branchPrefixConfig}${branchPrefix}/${preferredName}`;161// Check if we already have a branch with the preferred name, and if not, then use it.162// Else suffix the preferred name with a random string to avoid conflicts.163const refs = await this.gitService.getRefs(repository.rootUri, { pattern: `refs/heads/${branchName}` });164if (refs.some(ref => ref.name === branchName)) {165branchName = `${branchName}-${generateUuid().replaceAll('-', '').substring(0, 8).toLowerCase()}`;166}167168return branchName;169}170171// Attempt to generate a random branch name for the worktree172const randomBranchName = await this.gitService.generateRandomBranchName(repository.rootUri);173174const branch = randomBranchName ? `${branchPrefixConfig}${branchPrefix}/${randomBranchName.substring(branchPrefixConfig.length)}`175: `${branchPrefixConfig}${branchPrefix}/worktree-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}`;176177return branch;178}179180async getWorktreeProperties(sessionId: string): Promise<ChatSessionWorktreeProperties | undefined> {181const properties = this._sessionWorktrees.get(sessionId);182if (properties !== undefined) {183return typeof properties === 'string' ? undefined : properties;184}185// Fall back to metadata store (file-based)186return this.metadataStore.getWorktreeProperties(sessionId);187}188189async setWorktreeProperties(sessionId: string, properties: ChatSessionWorktreeProperties): Promise<void> {190this._sessionWorktrees.set(sessionId, properties);191await this.metadataStore.storeWorktreeInfo(sessionId, properties);192// If we're explicitly clearing the changes.193if ('changes' in properties && !properties.changes) {194this._onDidChangeWorktreeChanges.fire({ sessionId });195}196}197198async getWorktreeRepository(sessionId: string): Promise<RepoContext | undefined> {199const worktreeProperties = await this.getWorktreeProperties(sessionId);200if (typeof worktreeProperties === 'string' || !worktreeProperties?.repositoryPath) {201return undefined;202}203204return this.gitService.getRepository(vscode.Uri.file(worktreeProperties.repositoryPath));205}206207async getWorktreePath(sessionId: string): Promise<vscode.Uri | undefined> {208const worktreeProperties = await this.getWorktreeProperties(sessionId);209if (!worktreeProperties) {210return undefined;211} else if (typeof worktreeProperties === 'string') {212// Legacy worktree path213return vscode.Uri.file(worktreeProperties);214} else {215// Worktree properties v1216return vscode.Uri.file(worktreeProperties.worktreePath);217}218}219220async applyWorktreeChanges(sessionId: string): Promise<void> {221const worktreeProperties = await this.getWorktreeProperties(sessionId);222223if (worktreeProperties === undefined || (worktreeProperties.version === 1 && worktreeProperties.autoCommit === false)) {224// Legacy background session that has the changes staged in the worktree.225// To apply the changes, we need to migrate them from the worktree to the226// main repository using a stash.227const worktreePath = await this.getWorktreePath(sessionId);228if (!worktreePath) {229return;230}231232const activeRepository = worktreeProperties?.repositoryPath233? await this.gitService.getRepository(vscode.Uri.file(worktreeProperties.repositoryPath))234: this.workspaceService.getWorkspaceFolders().length === 1 ? this.gitService.activeRepository.get() : undefined;235236if (!activeRepository) {237return;238}239240// Migrate the changes from the worktree to the main repository241await this.gitService.migrateChanges(activeRepository.rootUri, worktreePath, {242confirmation: false,243deleteFromSource: false,244untracked: true245});246247// Delete worktree changes cache248if (worktreeProperties) {249await this.setWorktreeProperties(sessionId, {250...worktreeProperties,251changes: undefined252});253}254255return;256}257258// Copilot CLI session that has the changes committed in the worktree. To apply the259// changes, we need to migrate them from the worktree to the main repository using260// a patch file.261const patch = await this.gitService.diffBetweenPatch(262vscode.Uri.file(worktreeProperties.worktreePath),263worktreeProperties.baseCommit,264worktreeProperties.branchName);265266if (!patch) {267return;268}269270// Write the patch to a temporary file271const encoder = new TextEncoder();272const patchFilePath = path.join(worktreeProperties.repositoryPath, '.git', `${worktreeProperties.branchName}.patch`);273const patchFileUri = vscode.Uri.file(patchFilePath);274await vscode.workspace.fs.writeFile(patchFileUri, encoder.encode(patch));275276try {277// Apply patch278await this.gitService.applyPatch(vscode.Uri.file(worktreeProperties.repositoryPath), patchFilePath);279} catch (error) {280this.logService.error(`[ChatSessionWorktreeService][applyWorktreeChanges] Error applying patch file ${patchFilePath} to repository ${worktreeProperties.repositoryPath}: `, error);281throw error;282} finally {283await vscode.workspace.fs.delete(patchFileUri);284}285286// Update base commit for the worktree after applying the changes287const ref = await this.gitService.getRefs(vscode.Uri.file(worktreeProperties.repositoryPath), {288pattern: `refs/heads/${worktreeProperties.branchName}`289});290291if (ref.length === 1 && ref[0].commit && ref[0].commit !== worktreeProperties.baseCommit) {292// Update baseCommit to the new HEAD of the worktree branch. We are doing this to293// clear the list of changes for the session since all changes have been applied294// to the main repository at this point.295await this.setWorktreeProperties(sessionId, {296...worktreeProperties,297baseCommit: ref[0].commit,298changes: undefined299});300} else {301// Clear the changes cache even if we couldn't determine the new HEAD302await this.setWorktreeProperties(sessionId, {303...worktreeProperties,304changes: undefined305});306}307}308309async hasCachedChanges(sessionId: string): Promise<boolean> {310const worktreeProperties = await this.getWorktreeProperties(sessionId);311if (!worktreeProperties || typeof worktreeProperties === 'string') {312return false;313}314return !!worktreeProperties.changes;315}316317async getWorktreeChanges(sessionId: string): Promise<readonly vscode.ChatSessionChangedFile[] | undefined> {318const worktreeProperties = await this.getWorktreeProperties(sessionId);319if (!worktreeProperties || typeof worktreeProperties === 'string') {320return undefined;321}322323// Return cached changes324if (worktreeProperties.changes) {325return worktreeProperties.changes326.map(change => this._toChatSessionChangedFile2(sessionId, change, worktreeProperties));327}328329try {330// Ensure the initial repository discovery is completed and the repository331// states are initialized in the vscode.git extension. This is needed as these332// will be the repositories that we use to compute the worktree changes. We do333// not have to open each worktree individually since the changes are committed334// so we can get them from the main repository or discovered worktree.335await this.gitService.initialize();336337// Legacy - these changes are staged in the worktree but not yet committed. Since338// the changes are not committed, we need to get them from the worktree repository339// state. To do that we need to open the worktree repository. The source control340// provider will not be shown in the Source Control view since it is being hidden.341if (worktreeProperties.version === 1 && worktreeProperties.autoCommit === false) {342const changes = await this._getWorktreeChangesFromIndex(worktreeProperties) ?? [];343await this.setWorktreeProperties(sessionId, {344...worktreeProperties, changes345});346347return changes.map(change => this._toChatSessionChangedFile2(sessionId, change, worktreeProperties));348}349350// Auto-commit is enabled which means that following each turn the changes are351// committed. We can use the commit history of the worktree branch to compute352// the changes. For the Sessions app, we do want to provide updated changes353// while the session is in progress.354if (worktreeProperties.version === 2 && worktreeProperties.autoCommit === true) {355const properties = vscode.workspace.isAgentSessionsWorkspace356? await this._getWorktreeChanges(sessionId, worktreeProperties)357: await this._getWorktreeChangesFromCommits(worktreeProperties);358359if (properties) {360await this.setWorktreeProperties(sessionId, {361...worktreeProperties, ...properties362});363}364365return properties?.changes.map(change => this._toChatSessionChangedFile2(sessionId, change, worktreeProperties)) ?? [];366}367368// Use checkpoints to compute the changes369const properties = await this._getWorktreeChanges(sessionId, worktreeProperties);370if (properties) {371await this.setWorktreeProperties(sessionId, {372...worktreeProperties, ...properties373});374}375376return properties?.changes.map(change => this._toChatSessionChangedFile2(sessionId, change, worktreeProperties)) ?? [];377} catch (error) {378const errorMessage = error instanceof Error ? error.message : String(error);379this.logService.warn(`[ChatSessionWorktreeCheckpointService][getWorktreeChanges] Session ${sessionId}: error computing diff for committed changes, returning empty. Error: ${errorMessage}`);380await this.setWorktreeProperties(sessionId, {381...worktreeProperties, changes: []382});383384return [];385}386}387388async handleRequestCompleted(sessionId: string): Promise<void> {389const worktreeProperties = await this.getWorktreeProperties(sessionId);390if (!worktreeProperties) {391return;392}393394// Auto-commit is disabled for this worktree395if (worktreeProperties.autoCommit === false) {396this.logService.trace(`[ChatSessionWorktreeService][handleRequestCompleted] Auto-commit is disabled, skipping commit of worktree changes for session ${sessionId}`);397398// Delete worktree changes cache399await this.setWorktreeProperties(sessionId, {400...worktreeProperties,401changes: undefined402});403404return;405}406407const worktreePath = worktreeProperties.worktreePath;408409// Commit all changes in the worktree410const repository = await this.gitCommitMessageService.getRepository(vscode.Uri.file(worktreePath));411if (!repository) {412this.logService.error(`[ChatSessionWorktreeService][handleRequestCompleted] Unable to find repository for working directory ${worktreePath}`);413throw new Error(`Unable to find repository for working directory ${worktreePath}`);414}415416if (repository.state.workingTreeChanges.length === 0 && repository.state.indexChanges.length === 0 && repository.state.untrackedChanges.length === 0) {417this.logService.trace(`[ChatSessionWorktreeService][handleRequestCompleted] No changes to commit in working directory ${worktreePath}`);418419// Delete worktree changes cache420await this.setWorktreeProperties(sessionId, {421...worktreeProperties,422changes: undefined423});424425return;426}427428let message: string | undefined;429try {430this.logService.trace(`[ChatSessionWorktreeService][handleRequestCompleted] Generating commit message for working directory ${worktreePath}. Repository state: ${JSON.stringify(repository.state)}`);431message = await this.gitCommitMessageService.generateCommitMessage(repository, CancellationToken.None);432} catch (error) {433const errorMessage = error instanceof Error ? error.message : String(error);434this.logService.error(`[ChatSessionWorktreeService][handleRequestCompleted] Error generating commit message for working directory ${worktreePath}. Repository state: ${JSON.stringify(repository.state)}. Error: ${errorMessage}`);435}436437if (!message) {438// Fallback commit message439this.logService.warn(`[ChatSessionWorktreeService][handleRequestCompleted] Unable to generate commit message for working directory ${worktreePath}. Repository state: ${JSON.stringify(repository.state)}`);440message = `Copilot CLI session ${sessionId} changes`;441}442443// Commit the changes444await this.gitService.commit(vscode.Uri.file(worktreePath), message, { all: true, noVerify: true, signCommit: false });445this.logService.trace(`[ChatSessionWorktreeService] Committed all changes in working directory ${worktreePath}`);446447// Delete worktree changes cache448await this.setWorktreeProperties(sessionId, {449...worktreeProperties,450changes: undefined451});452}453454async cleanupWorktreeOnArchive(sessionId: string): Promise<{ cleaned: boolean; reason?: string }> {455const worktreeProperties = await this.getWorktreeProperties(sessionId);456if (!worktreeProperties) {457return { cleaned: false, reason: 'no-worktree' };458}459460const worktreePath = worktreeProperties.worktreePath;461462// Check if the worktree directory exists463try {464await fs.access(worktreePath);465} catch {466this.logService.trace(`[ChatSessionWorktreeService][cleanupWorktreeOnArchive] Worktree path does not exist: ${worktreePath}`);467return { cleaned: false, reason: 'worktree-not-found' };468}469470// Get the git repository for the worktree471const repository = await this.gitCommitMessageService.getRepository(vscode.Uri.file(worktreePath));472if (!repository) {473this.logService.warn(`[ChatSessionWorktreeService][cleanupWorktreeOnArchive] Unable to find repository for worktree ${worktreePath}`);474return { cleaned: false, reason: 'no-repository' };475}476477const hasUncommittedChanges = repository.state.workingTreeChanges.length > 0478|| repository.state.indexChanges.length > 0479|| repository.state.untrackedChanges.length > 0;480481if (hasUncommittedChanges) {482// For auto-commit sessions, commit changes before cleanup483if (worktreeProperties.autoCommit !== false) {484this.logService.trace(`[ChatSessionWorktreeService][cleanupWorktreeOnArchive] Auto-committing changes before cleanup for session ${sessionId}`);485try {486await this.handleRequestCompleted(sessionId);487} catch (error) {488const errorMessage = error instanceof Error ? error.message : String(error);489this.logService.error(`[ChatSessionWorktreeService][cleanupWorktreeOnArchive] Failed to auto-commit: ${errorMessage}`);490return { cleaned: false, reason: 'auto-commit-failed' };491}492} else {493// Non-auto-commit sessions with uncommitted changes: skip cleanup494this.logService.trace(`[ChatSessionWorktreeService][cleanupWorktreeOnArchive] Skipping cleanup for session ${sessionId}: has uncommitted changes and auto-commit is disabled`);495return { cleaned: false, reason: 'uncommitted-changes' };496}497}498499// Verify the branch exists before deleting the worktree500try {501const refs = await this.gitService.getRefs(502vscode.Uri.file(worktreeProperties.repositoryPath),503{ pattern: `refs/heads/${worktreeProperties.branchName}` }504);505if (!refs || refs.length === 0) {506this.logService.warn(`[ChatSessionWorktreeService][cleanupWorktreeOnArchive] Branch ${worktreeProperties.branchName} not found, skipping cleanup`);507return { cleaned: false, reason: 'branch-not-found' };508}509} catch (error) {510const errorMessage = error instanceof Error ? error.message : String(error);511this.logService.warn(`[ChatSessionWorktreeService][cleanupWorktreeOnArchive] Failed to verify branch: ${errorMessage}`);512return { cleaned: false, reason: 'branch-check-failed' };513}514515// Delete the worktree516try {517const parentRepository = await this.gitService.getRepository(vscode.Uri.file(worktreeProperties.repositoryPath), true);518if (!parentRepository) {519this.logService.warn(`[ChatSessionWorktreeService][cleanupWorktreeOnArchive] No parent repository found for ${worktreeProperties.repositoryPath}`);520return { cleaned: false, reason: 'no-parent-repository' };521}522await this.gitService.deleteWorktree(parentRepository.rootUri, worktreePath);523this.logService.trace(`[ChatSessionWorktreeService][cleanupWorktreeOnArchive] Deleted worktree ${worktreePath} for session ${sessionId}`);524return { cleaned: true };525} catch (error) {526const errorMessage = error instanceof Error ? error.message : String(error);527this.logService.error(`[ChatSessionWorktreeService][cleanupWorktreeOnArchive] Failed to delete worktree: ${errorMessage}`);528return { cleaned: false, reason: 'delete-failed' };529}530}531532async recreateWorktreeOnUnarchive(sessionId: string): Promise<{ recreated: boolean; reason?: string }> {533const worktreeProperties = await this.getWorktreeProperties(sessionId);534if (!worktreeProperties) {535return { recreated: false, reason: 'no-worktree-properties' };536}537538const worktreePath = worktreeProperties.worktreePath;539540// Check if the worktree already exists on disk541try {542await fs.access(worktreePath);543this.logService.trace(`[ChatSessionWorktreeService][recreateWorktreeOnUnarchive] Worktree already exists at ${worktreePath}`);544return { recreated: false, reason: 'already-exists' };545} catch {546// Expected — worktree was cleaned up on archive547}548549// Verify the branch still exists in the parent repository550try {551const refs = await this.gitService.getRefs(552vscode.Uri.file(worktreeProperties.repositoryPath),553{ pattern: `refs/heads/${worktreeProperties.branchName}` }554);555if (!refs || refs.length === 0) {556this.logService.warn(`[ChatSessionWorktreeService][recreateWorktreeOnUnarchive] Branch ${worktreeProperties.branchName} no longer exists`);557return { recreated: false, reason: 'branch-not-found' };558}559} catch (error) {560const errorMessage = error instanceof Error ? error.message : String(error);561this.logService.warn(`[ChatSessionWorktreeService][recreateWorktreeOnUnarchive] Failed to verify branch: ${errorMessage}`);562return { recreated: false, reason: 'branch-check-failed' };563}564565// Recreate the worktree from the existing branch566try {567const parentRepository = await this.gitService.getRepository(vscode.Uri.file(worktreeProperties.repositoryPath), true);568if (!parentRepository) {569this.logService.warn(`[ChatSessionWorktreeService][recreateWorktreeOnUnarchive] No parent repository found for ${worktreeProperties.repositoryPath}`);570return { recreated: false, reason: 'no-parent-repository' };571}572573// Use commitish (existing branch) without branch (no -b flag) to checkout the existing branch574const createdPath = await this.gitService.createWorktree(parentRepository.rootUri, {575path: worktreePath,576commitish: worktreeProperties.branchName,577});578579if (!createdPath) {580this.logService.error(`[ChatSessionWorktreeService][recreateWorktreeOnUnarchive] createWorktree returned no path`);581return { recreated: false, reason: 'create-failed' };582}583584this.logService.trace(`[ChatSessionWorktreeService][recreateWorktreeOnUnarchive] Recreated worktree at ${createdPath} for session ${sessionId}`);585return { recreated: true };586} catch (error) {587const errorMessage = error instanceof Error ? error.message : String(error);588this.logService.error(`[ChatSessionWorktreeService][recreateWorktreeOnUnarchive] Failed to recreate worktree: ${errorMessage}`);589return { recreated: false, reason: 'create-failed' };590}591}592593private async _getWorktreeChangesFromIndex(worktreeProperties: ChatSessionWorktreeProperties): Promise<readonly ChatSessionWorktreeFile[] | undefined> {594const worktreePath = vscode.Uri.file(worktreeProperties.worktreePath);595const worktreeRepository = await this.gitService.getRepository(worktreePath);596597if (!worktreeRepository?.changes) {598return [];599}600601const changes: ChatSessionWorktreeFile[] = [];602for (const change of [...worktreeRepository.changes.indexChanges, ...worktreeRepository.changes.workingTree]) {603try {604const fileStats = await this.gitService.diffIndexWithHEADShortStats(change.uri);605changes.push({606filePath: change.uri.fsPath,607originalFilePath: change.status !== 1 /* INDEX_ADDED */608? change.originalUri?.fsPath609: undefined,610modifiedFilePath: change.status !== 2 /* INDEX_DELETED */611? change.uri.fsPath612: undefined,613statistics: {614additions: fileStats?.insertions ?? 0,615deletions: fileStats?.deletions ?? 0616}617} satisfies ChatSessionWorktreeFile);618} catch (error) { }619}620621return changes;622}623624private async _getWorktreeChangesFromCommits(worktreeProperties: ChatSessionWorktreePropertiesV2): Promise<{ changes: readonly ChatSessionWorktreeFile[] } | undefined> {625// Open the main repository that contains the worktree. We have to open626// the repository so that we can run do `git diff` against the repository627// to get the committed changes in the worktree branch.628const repository = await this.gitService.getRepository(vscode.Uri.file(worktreeProperties.repositoryPath));629630if (!repository) {631return undefined;632}633634// These changes are committed in the worktree branch but since they are635// committed we can get the changes from the main repository and we do636// not need to open the worktree repository.637const diff = await this.gitService.diffBetweenWithStats(638repository.rootUri,639worktreeProperties.baseCommit,640worktreeProperties.branchName);641642if (!diff) {643return { changes: [] };644}645646const changes = diff.map(change => {647// Since the diff was computed using the main repository, the file paths in the diff are relative to the648// main repository. We need to convert them to absolute paths by joining them with the repository path.649const worktreeFilePath = path.join(worktreeProperties.worktreePath, path.relative(worktreeProperties.repositoryPath, change.uri.fsPath));650const worktreeOriginalFilePath = change.originalUri651? path.join(worktreeProperties.worktreePath, path.relative(worktreeProperties.repositoryPath, change.originalUri.fsPath))652: undefined;653654return {655filePath: worktreeFilePath,656originalFilePath: change.status !== 1 /* INDEX_ADDED */657? worktreeOriginalFilePath658: undefined,659modifiedFilePath: change.status !== 6 /* DELETED */660? worktreeFilePath661: undefined,662statistics: {663additions: change.insertions,664deletions: change.deletions665}666} satisfies ChatSessionWorktreeFile;667});668669return { changes };670}671672private async _getWorktreeChanges(sessionId: string, worktreeProperties: ChatSessionWorktreeProperties): Promise<{673readonly changes: readonly ChatSessionWorktreeFile[];674readonly mergeBaseCommit?: string;675readonly hasGitHubRemote?: boolean;676readonly upstreamBranchName?: string;677readonly incomingChanges?: number;678readonly outgoingChanges?: number;679readonly uncommittedChanges?: number;680} | undefined> {681if (worktreeProperties.version !== 2) {682this.logService.warn(`[ChatSessionWorktreeService][_getWorktreeChanges] Worktree properties for session ${sessionId} is not version 2.`);683return undefined;684}685686// We need to open the worktree repository since we need access to the worktree repository's687// working tree in order to compute the diff statistics. We do this to provide updates while688// the session is in progress, or if auto-commit is disabled689const worktreeRepository = await this.gitService.getRepository(vscode.Uri.file(worktreeProperties.worktreePath));690691if (!worktreeRepository) {692this.logService.warn(`[ChatSessionWorktreeService][_getWorktreeChanges] Unable to open worktree repository for session ${sessionId} at path ${worktreeProperties.worktreePath}`);693return undefined;694}695696// Check for untracked changes697const hasUntrackedChanges = [698...worktreeRepository.changes?.workingTree ?? [],699...worktreeRepository.changes?.untrackedChanges ?? [],700].some(change => change.status === 7 /* UNTRACKED */);701702703// If the repository is using a virtual file system, we need to704// disable rename detection to avoid expensive git operations705const noRenamesArg = worktreeRepository.isUsingVirtualFileSystem706? ['--no-renames']707: [];708709const diffChanges: DiffChange[] = [];710const worktreePath = vscode.Uri.file(worktreeProperties.worktreePath);711712if (hasUntrackedChanges) {713// Tracked + untracked changes714const tmpDirName = `vscode-sessions-${sessionId}-${generateUuid()}`;715const diffIndexFile = path.join(this.extensionContext.globalStorageUri.fsPath, tmpDirName, 'diff.index');716const pathspecFile = path.join(this.extensionContext.globalStorageUri.fsPath, tmpDirName, `pathspec.txt`);717718const env = buildTempIndexEnv(worktreeRepository, diffIndexFile);719720try {721// Create temp index file directory722await fs.mkdir(path.dirname(diffIndexFile), { recursive: true });723724// Populate temp index from HEAD725await this.gitService.exec(worktreePath, ['read-tree', 'HEAD'], env);726727// Stage entire working directory into temp index728const uncommittedFilePaths = getUncommittedFilePaths(worktreeRepository);729await fs.writeFile(pathspecFile, uncommittedFilePaths.join('\n'), 'utf8');730await this.gitService.exec(worktreePath, ['add', '-A', `--pathspec-from-file=${pathspecFile}`], env);731732// Diff the temp index with the base branch733const result = await this.gitService.exec(worktreePath, ['diff', '--cached', '--raw', '--numstat', '--diff-filter=ADMR', ...noRenamesArg, '-z', '--merge-base', worktreeProperties.baseBranchName, '--'], env);734diffChanges.push(...parseGitChangesRaw(worktreeProperties.worktreePath, result));735} catch (error) {736this.logService.error(`[ChatSessionWorktreeService][_getWorktreeChanges] Error while processing worktree changes for session ${sessionId}: ${error}`);737return undefined;738} finally {739try {740await fs.rm(path.dirname(diffIndexFile), { recursive: true, force: true });741} catch (error) {742this.logService.error(`[ChatSessionWorktreeService][_getWorktreeChanges] Error while cleaning up temp index file for session ${sessionId}: ${error}`);743}744}745} else {746// Tracked changes747try {748const result = await this.gitService.exec(worktreePath, ['diff', '--raw', '--numstat', '--diff-filter=ADMR', ...noRenamesArg, '-z', '--merge-base', worktreeProperties.baseBranchName, '--']);749diffChanges.push(...parseGitChangesRaw(worktreeProperties.worktreePath, result));750} catch (error) {751this.logService.error(`[ChatSessionWorktreeService][_getWorktreeChanges] Error while processing worktree changes for session ${sessionId}: ${error}`);752return undefined;753}754}755756// Since the diff is being computed using the merge base commit of the worktree757// branch and the base branch, we need to compute it as well so that we can use758// it as the originalRef (left-hand side) of the diff editor759let mergeBaseCommit: string | undefined;760try {761mergeBaseCommit = await this.gitService.getMergeBase(worktreePath, worktreeProperties.branchName, worktreeProperties.baseBranchName);762} catch (error) {763this.logService.error(`[ChatSessionWorktreeService][_getWorktreeChanges] Error while getting merge base (${worktreeProperties.branchName}, ${worktreeProperties.baseBranchName}) for session ${sessionId}: ${error}`);764}765766const changes = diffChanges.map(change => ({767filePath: change.uri.fsPath,768originalFilePath: change.status !== 1 /* INDEX_ADDED */769? change.originalUri?.fsPath770: undefined,771modifiedFilePath: change.status !== 6 /* DELETED */772? change.uri.fsPath773: undefined,774statistics: {775additions: change.insertions,776deletions: change.deletions777}778} satisfies ChatSessionWorktreeFile));779780const repositoryState = {781mergeBaseCommit,782hasGitHubRemote: getGitHubRepoInfoFromContext(worktreeRepository) !== undefined,783upstreamBranchName: worktreeRepository.upstreamRemote && worktreeRepository.upstreamBranchName784? `${worktreeRepository.upstreamRemote}/${worktreeRepository.upstreamBranchName}`785: undefined,786incomingChanges: worktreeRepository.headIncomingChanges ?? 0,787outgoingChanges: worktreeRepository.headOutgoingChanges ?? 0,788uncommittedChanges:789(worktreeRepository.changes?.mergeChanges.length ?? 0) +790(worktreeRepository.changes?.indexChanges.length ?? 0) +791(worktreeRepository.changes?.workingTree.length ?? 0) +792(worktreeRepository.changes?.untrackedChanges.length ?? 0)793};794795return { changes, ...repositoryState };796}797798private _toChatSessionChangedFile2(sessionId: string, change: ChatSessionWorktreeFile, worktreeProperties: ChatSessionWorktreeProperties): vscode.ChatSessionChangedFile {799let originalFileRef: string, modifiedFileRef: string | undefined;800if (worktreeProperties.version === 2) {801// Commit | Working tree802originalFileRef = vscode.workspace.isAgentSessionsWorkspace803? worktreeProperties.mergeBaseCommit ?? worktreeProperties.baseCommit804: worktreeProperties.baseCommit;805modifiedFileRef = vscode.workspace.isAgentSessionsWorkspace806? undefined807: worktreeProperties.branchName;808} else {809// Legacy810originalFileRef = worktreeProperties.baseCommit;811modifiedFileRef = worktreeProperties.branchName;812}813814return new vscode.ChatSessionChangedFile(815vscode.Uri.file(change.filePath),816change.originalFilePath817? toGitUri(vscode.Uri.file(change.originalFilePath), originalFileRef)818: undefined,819change.modifiedFilePath820? modifiedFileRef821? toGitUri(vscode.Uri.file(change.modifiedFilePath), modifiedFileRef)822: vscode.Uri.file(change.modifiedFilePath)823: undefined,824change.statistics.additions,825change.statistics.deletions);826}827828async getAdditionalWorktreeProperties(sessionId: string): Promise<ChatSessionWorktreeProperties[]> {829const additionalWorkspaces = await this.metadataStore.getAdditionalWorkspaces(sessionId);830return additionalWorkspaces831.map(ws => ws.worktreeProperties)832.filter((props): props is ChatSessionWorktreeProperties => !!props);833}834835async setAdditionalWorktreeProperties(sessionId: string, properties: ChatSessionWorktreeProperties[]): Promise<void> {836const workspaces = properties.map(props => ({837folder: undefined,838repository: vscode.Uri.file(props.repositoryPath),839worktree: vscode.Uri.file(props.worktreePath),840worktreeProperties: props,841}));842await this.metadataStore.setAdditionalWorkspaces(sessionId, workspaces);843}844845async handleRequestCompletedForWorktree(worktreeProperties: ChatSessionWorktreeProperties): Promise<void> {846if (worktreeProperties.autoCommit === false) {847this.logService.trace(`[ChatSessionWorktreeService][handleRequestCompletedForWorktree] Auto-commit is disabled, skipping commit for worktree ${worktreeProperties.worktreePath}`);848return;849}850851const worktreePath = worktreeProperties.worktreePath;852const repository = await this.gitCommitMessageService.getRepository(vscode.Uri.file(worktreePath));853if (!repository) {854this.logService.error(`[ChatSessionWorktreeService][handleRequestCompletedForWorktree] Unable to find repository for working directory ${worktreePath}`);855throw new Error(`Unable to find repository for working directory ${worktreePath}`);856}857858if (repository.state.workingTreeChanges.length === 0 && repository.state.indexChanges.length === 0 && repository.state.untrackedChanges.length === 0) {859this.logService.trace(`[ChatSessionWorktreeService][handleRequestCompletedForWorktree] No changes to commit in working directory ${worktreePath}`);860return;861}862863let message: string | undefined;864try {865message = await this.gitCommitMessageService.generateCommitMessage(repository, CancellationToken.None);866} catch (error) {867const errorMessage = error instanceof Error ? error.message : String(error);868this.logService.error(`[ChatSessionWorktreeService][handleRequestCompletedForWorktree] Error generating commit message for ${worktreePath}: ${errorMessage}`);869}870871if (!message) {872message = `Copilot CLI session changes`;873}874875await this.gitService.commit(vscode.Uri.file(worktreePath), message, { all: true, noVerify: true, signCommit: false });876this.logService.trace(`[ChatSessionWorktreeService][handleRequestCompletedForWorktree] Committed all changes in working directory ${worktreePath}`);877}878}879880881