/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import * as fs from 'fs/promises'; import { RunOnceScheduler, SequencerByKey } from '../../../../base/common/async.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { appendEscapedMarkdownInlineCode } from '../../../../base/common/htmlContent.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../base/common/network.js'; import { basename } from '../../../../base/common/path.js'; import { isEqual } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { generateUuid } from '../../../../base/common/uuid.js'; import { localize } from '../../../../nls.js'; import { createDecorator } from '../../../instantiation/common/instantiation.js'; import { ILogService } from '../../../log/common/log.js'; import { AgentSession, IAgentSessionProjectInfo } from '../../common/agent.js'; import { getBranchCompletions, IAgentHostGitService, IDefaultBranch, IWorktreeFileProgress, META_DIFF_BASE_BRANCH, tryResolvePrimaryWorktreeRoot } from '../../common/agentHostGitService.js'; import { AgentSystemNotificationKind, AgentSystemNotificationSeverity, toAgentSystemNotificationMeta } from '../../common/meta/agentSystemNotificationMeta.js'; import { ISchemaProperty, schemaProperty } from '../../common/agentHostSchema.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, ResponsePart, ResponsePartKind, Turn } from '../../common/state/sessionState.js'; import { AGENT_BRANCH_PREFIX, AgentBranchNameGenerator, IAgentBranchNameGenerator } from './agentBranchNameGenerator.js'; import { ICopilotApiService } from './copilotApiService.js'; export const IAgentHostWorktreeIsolation = createDecorator('agentHostWorktreeIsolation'); export interface IAgentHostWorktreeIsolation { readonly _serviceBrand: undefined; readonly onDidChangeWorkingDirectoryPending: Event; isWorkingDirectoryPending(sessionId: string): boolean; } /** * Per-session-database metadata keys under which the worktree an agent * created for an isolated session is recorded. The string values keep the * historical `copilot.worktree.*` prefix so sessions materialized by earlier * Copilot builds keep resolving their worktree on archive / unarchive / * restore after this logic was unified across agents. All agents (Copilot, * Codex, Claude) now write and read these same keys; the per-session database * is already scoped by session, so there is no cross-agent collision. */ const WORKTREE_META_BRANCH = 'copilot.worktree.branchName'; const WORKTREE_META_PATH = 'copilot.worktree.path'; export const WORKTREE_META_REPOSITORY_ROOT = 'copilot.worktree.repositoryRoot'; const WORKTREE_META_CREATION_FAILURE = 'copilot.worktree.creationFailure'; // TODO@roblourens: Remove after ~November 2026, when pre-July 2026 sessions no longer need their worktree path/root reconstructed from this legacy key. const LEGACY_WORKTREE_META_WORKING_DIRECTORY = 'copilot.workingDirectory'; const MAX_WORKTREE_FAILURE_DIAGNOSTIC_LENGTH = 200; /** Thrown when a persisted session working directory is missing and cannot be repaired. */ export class SessionWorkingDirectoryMissingError extends Error { constructor(readonly workingDirectory: URI, readonly reason?: string) { super(reason ? localize('sessionWorkingDirectoryMissingWithReason', "This session couldn't be loaded because its worktree is missing and could not be recreated: {0}", reason) : localize('sessionWorkingDirectoryMissing', "This session couldn't be loaded because its working directory no longer exists: {0}", workingDirectory.fsPath)); this.name = 'SessionWorkingDirectoryMissingError'; } } /** Default upper bound on branch names returned for the branch picker. */ const BRANCH_COMPLETION_LIMIT = 25; const WORKTREE_PROGRESS_DEBOUNCE_MS = 40; interface ISessionWorktree { readonly repositoryRoot: URI; readonly worktree: URI; } interface IWorktreeMetadata { readonly branchName: string; readonly worktreePath?: URI; readonly repositoryRoot?: URI; } /** * The `.worktrees` sibling directory where per-session isolated * worktrees are created, e.g. `/src/vscode` → `/src/vscode.worktrees`. */ export function getWorktreesRoot(repositoryRoot: URI): URI { return URI.joinPath(repositoryRoot, '..', `${basename(repositoryRoot.fsPath)}.worktrees`); } /** * Derives the on-disk worktree directory name from a branch name: strips the * caller-supplied prefix (e.g. the user's `git.branchPrefix`) and the built-in * `agents/` prefix so the directory stays concise, then flattens any remaining * path separators. */ export function getWorktreeName(branchName: string, branchPrefix: string = ''): string { let name = branchName; if (branchPrefix && name.startsWith(branchPrefix)) { name = name.substring(branchPrefix.length); } if (name.startsWith(AGENT_BRANCH_PREFIX)) { name = name.substring(AGENT_BRANCH_PREFIX.length); } return name.replace(/\//g, '-'); } /** * Builds the localized "Created isolated worktree for branch X" markdown shown * at the top of the first response in worktree-isolated sessions. The branch * name is wrapped as inline code so the localized template doesn't have to * embed markdown punctuation. The trailing blank line keeps the announcement * visually separated when it gets merged into the same markdown part as the * model's reply. */ export function buildWorktreeAnnouncementText(branchName: string): string { return localize( 'agentHost.worktreeCreated', "Created isolated worktree for branch {0}", appendEscapedMarkdownInlineCode(branchName) ) + '\n\n'; } /** Builds the warning shown when worktree isolation falls back to the original folder. */ export function buildWorktreeFailureNotification(diagnostic?: string): Extract { const normalizedDiagnostic = normalizeWorktreeFailureDiagnostic(diagnostic); const content = normalizedDiagnostic ? localize( 'agentHost.worktreeCreationFailedWithDiagnostic', "Couldn't create the isolated worktree. This session is continuing in the original folder.\n\n{0}", appendEscapedMarkdownInlineCode(normalizedDiagnostic) ) : localize( 'agentHost.worktreeCreationFailed', "Couldn't create the isolated worktree. This session is continuing in the original folder." ); return { kind: ResponsePartKind.SystemNotification, content, _meta: toAgentSystemNotificationMeta({ kind: AgentSystemNotificationKind.WorktreeCreationFailure, severity: AgentSystemNotificationSeverity.Warning, }), }; } /** Normalizes an arbitrary worktree failure into a bounded single-line diagnostic. */ export function normalizeWorktreeFailureDiagnostic(diagnostic: string | undefined): string | undefined { const normalized = diagnostic?.replace(/\s+/g, ' ').trim(); if (!normalized) { return undefined; } return normalized.length > MAX_WORKTREE_FAILURE_DIAGNOSTIC_LENGTH ? `${normalized.slice(0, MAX_WORKTREE_FAILURE_DIAGNOSTIC_LENGTH - 3)}...` : normalized; } /** * The steps of worktree creation that are slow enough to be worth naming while * a session materializes. Ordered as they run. */ export const enum WorktreeCreationPhase { /** Queued behind another worktree being created in the same repository. */ Starting, /** Asking the model for a branch name, then probing candidates for collisions. */ NamingBranch, /** `git worktree add` — the phase that reports file-level progress. */ CheckingOut, /** Copying the git-ignored files the client asked to carry over. */ CopyingIncludeFiles, } /** * Builds the localized activity label for a worktree-creation phase. `percent` * only applies to the phases that report file-level progress * ({@link WorktreeCreationPhase.CheckingOut} and * {@link WorktreeCreationPhase.CopyingIncludeFiles}), where it is absent until * the first sample arrives. */ export function buildWorktreeProgressText(phase: WorktreeCreationPhase, percent?: number): string { switch (phase) { case WorktreeCreationPhase.NamingBranch: return localize('agentHost.worktreeNamingBranch', "Creating isolated worktree (naming branch)"); case WorktreeCreationPhase.CheckingOut: return percent === undefined ? localize('agentHost.worktreeCheckingOut', "Creating isolated worktree (checking out files)") : localize('agentHost.worktreeCheckingOutPercent', "Creating isolated worktree (checking out files, {0}%)", percent); case WorktreeCreationPhase.CopyingIncludeFiles: return percent === undefined ? localize('agentHost.worktreeCopyingIncludeFiles', "Creating isolated worktree (copying additional files)") : localize('agentHost.worktreeCopyingIncludeFilesPercent', "Creating isolated worktree (copying additional files, {0}%)", percent); default: return localize('agentHost.worktreeCreating', "Creating isolated worktree"); } } /** * Adapts the raw file counts the git service reports into progress labels for * a phase. Rounds down to whole percentages, drops non-advancing samples, and * debounces updates to avoid overwhelming consumers, flushing the latest * percentage when the operation completes. */ async function withPercentProgress( phase: WorktreeCreationPhase, onProgress: ((activity: string) => void) | undefined, operation: (onProgress: ((progress: IWorktreeFileProgress) => void) | undefined) => Promise, ): Promise { if (!onProgress) { return operation(undefined); } let lastPercent = -1; const scheduler = new RunOnceScheduler(() => onProgress(buildWorktreeProgressText(phase, lastPercent)), WORKTREE_PROGRESS_DEBOUNCE_MS); try { return await operation(({ filesDone, filesTotal }) => { const percent = Math.min(100, Math.floor(filesDone * 100 / filesTotal)); if (percent <= lastPercent) { return; } lastPercent = percent; scheduler.schedule(); }); } finally { const shouldFlush = scheduler.isScheduled(); scheduler.dispose(); if (shouldFlush) { onProgress(buildWorktreeProgressText(phase, lastPercent)); } } } /** * Returns a copy of `turns` where `announcement` has been prepended to the * first top-level assistant turn's first markdown response part. Used on * session restore so the worktree announcement remains visible after the * session is reopened. If no assistant content exists yet, a fresh markdown * part is inserted at the top of the first turn. */ export function prependAnnouncementToFirstTurn(turns: readonly Turn[], announcement: string): readonly Turn[] { if (turns.length === 0) { return turns; } const result = turns.slice(); const first = result[0]; const part = first.responseParts[0]; if (part?.kind === ResponsePartKind.Markdown) { const responseParts = first.responseParts.slice(); responseParts[0] = { ...part, content: announcement + part.content }; result[0] = { ...first, responseParts }; } else { const responseParts: ResponsePart[] = [ { kind: ResponsePartKind.Markdown, id: generateUuid(), content: announcement }, ...first.responseParts, ]; result[0] = { ...first, responseParts }; } return result; } function prependWorktreeFailureToFirstTurn(turns: readonly Turn[], diagnostic: string | undefined): readonly Turn[] { if (turns.length === 0) { return turns; } const result = turns.slice(); const first = result[0]; result[0] = { ...first, responseParts: [buildWorktreeFailureNotification(diagnostic), ...first.responseParts], }; return result; } /** Parameters for {@link WorktreeIsolation.resolveIsolationConfig}. */ export interface IResolveIsolationConfigRequest { readonly workingDirectory: URI | undefined; readonly config: Record | undefined; } /** * The isolation + branch schema contribution for an agent's * `resolveSessionConfig`. Callers merge {@link isolationProperty} (and * {@link branchProperty} / {@link worktreeBranchPrefixProperty} when present) * into their own schema and merge the default values ({@link isolationValue} / * {@link branchDefault}) into the defaults bag they pass to `validateOrDefault`. */ export interface IIsolationConfigContribution { readonly isolationProperty: ISchemaProperty<'folder' | 'worktree'>; readonly branchProperty: ISchemaProperty | undefined; /** * Read-only carrier for the client's `git.branchPrefix`. Declared for both * isolations (like `branch`) so the value rides `_config.values` and * survives isolation toggles; the host only consumes it for worktree * isolation (see {@link WorktreeIsolation.resolveWorkingDirectory}). */ readonly worktreeBranchPrefixProperty: ISchemaProperty | undefined; /** Read-only carrier for the client's `git.worktreeIncludeFiles`. */ readonly worktreeIncludeFilesProperty: ISchemaProperty | undefined; /** Read-only carrier for the programmatic worktree branch tracking preference. */ readonly worktreeBranchTrackProperty: ISchemaProperty | undefined; /** Read-only carrier for checking out the selected branch directly. */ readonly worktreeCreateNewBranchProperty: ISchemaProperty | undefined; readonly isolationValue: 'folder' | 'worktree'; readonly branchDefault: string | undefined; readonly branchValue: string | undefined; } /** Parameters for {@link WorktreeIsolation.resolveWorkingDirectory}. */ export interface IResolveWorkingDirectoryRequest { readonly sessionUri: URI; readonly sessionId: string; readonly workingDirectory: URI | undefined; readonly config: Record | undefined; readonly prompt?: string; readonly githubToken?: string; /** * Receives localized activity labels while the worktree is being created, * so callers can surface live progress. Only called for sessions that * selected worktree isolation — though such a session can still fall back * to its folder after the first label (e.g. the directory turns out not to * be a git repository) — and the caller is responsible for clearing the * activity once resolution settles. */ readonly onProgress?: (activity: string) => void; } /** * Shared, per-agent controller for git-worktree session isolation. Owns the * full machinery Copilot pioneered so Codex and Claude get identical behavior: * * - advertising the `isolation` (`folder` / `worktree`) and `branch` session * config properties from `resolveSessionConfig` ({@link resolveIsolationConfig}); * - completing branch names for the branch picker ({@link branchCompletions}); * - creating the worktree on materialization and persisting its metadata * ({@link resolveWorkingDirectory}); * - surfacing worktree creation success/failure notices live and on restore; * - cleaning up / recreating the worktree on session deletion, archive, and unarchive. * * A single host-owned instance serves every agent: the orchestrator * ({@link AgentService}) creates it and drives the lifecycle so individual * agents stay unaware of the folder-vs-worktree distinction. Session state * (`_materializedWorktrees`, pending markers, pending announcements) is keyed by the * globally-unique sessionId, so sharing one instance across agents is safe. */ export class WorktreeIsolation extends Disposable implements IAgentHostWorktreeIsolation { declare readonly _serviceBrand: undefined; /** Worktrees materialized during this host process, keyed by sessionId. */ private readonly _materializedWorktrees = new Map(); private readonly _worktreeDeletionRetries = new Map(); /** * Per-session announcement (markdown) emitted as a synthetic streaming * markdown part the first time the session sends a message. Surfaces the * "Created isolated worktree for branch X" message live during the first * turn; the same announcement is re-injected on restore via * {@link applyRestoreAnnouncement}. */ private readonly _pendingFirstTurnAnnouncements = new Map(); /** * SessionIds of freshly-created worktree-isolation sessions whose worktree * has not yet been created (creation is deferred to the first send so the * user's prompt can drive branch naming). While a session is in this set the * host reports its working directory as "pending" ({@link isWorkingDirectoryPending}) * so agents defer prewarming / materializing until {@link resolveOnFirstSend} * runs. Never populated for restored sessions — their worktree already exists * on disk and their persisted working directory already points at it. */ private readonly _pending = new Set(); private readonly _onDidChangeWorkingDirectoryPending = this._register(new Emitter()); readonly onDidChangeWorkingDirectoryPending: Event = this._onDidChangeWorkingDirectoryPending.event; /** Fixed log label; one host-owned instance serves every agent. */ private readonly _logLabel = 'AgentHost'; /** * Serializes the worktree lifecycle per session so a first-send creation * ({@link resolveOnFirstSend}) never interleaves with archive/unarchive * cleanup ({@link cleanupWorktreeOnArchive} / {@link recreateWorktreeOnUnarchive}) * or deletion ({@link removeSessionWorktree}) for the same session — the * guarantee each agent previously enforced with its own sequencer. */ private readonly _sequencer = new SequencerByKey(); private readonly _worktreeCreationSequencer = new SequencerByKey(); /** Branch-name generator for worktree sessions; created from {@link ICopilotApiService} unless a test supplies an override. */ private readonly _branchNameGenerator: IAgentBranchNameGenerator; constructor( branchNameGenerator: IAgentBranchNameGenerator | undefined, @IAgentHostGitService private readonly _gitService: IAgentHostGitService, @ICopilotApiService copilotApiService: ICopilotApiService, @ISessionDataService private readonly _sessionDataService: ISessionDataService, @ILogService private readonly _logService: ILogService, ) { super(); this._branchNameGenerator = branchNameGenerator ?? new AgentBranchNameGenerator(copilotApiService, this._logService); } /** * Marks a fresh worktree-isolation session as pending — its worktree is * deferred to the first send. Called by the host while a creating session's * resolved config selects `worktree` isolation. */ notePending(sessionId: string): void { if (!this._pending.has(sessionId)) { this._pending.add(sessionId); this._onDidChangeWorkingDirectoryPending.fire(sessionId); } } /** Clears a pending marker when a session will not materialize a worktree. */ clearPending(sessionId: string): void { if (this._pending.delete(sessionId)) { this._onDidChangeWorkingDirectoryPending.fire(sessionId); } } /** * Whether a session's worktree is still pending creation. The host exposes * this through {@link IAgentConfigurationService.isWorkingDirectoryPending} so * agents defer materialization until the host has resolved the worktree. */ isWorkingDirectoryPending(sessionId: string): boolean { return this._pending.has(sessionId); } /** The worktree created for a session in this process, if any. */ getResolvedWorktree(sessionId: string): URI | undefined { return this._materializedWorktrees.get(sessionId)?.worktree; } /** * First-send worktree resolution: creates the worktree (when the session * selected `worktree` isolation on a git repo) and clears the pending marker * regardless of outcome, so a failed creation falls back to folder isolation * instead of leaving the session permanently "pending". Delegates to * {@link resolveWorkingDirectory}, which is idempotent per session. */ async resolveOnFirstSend(request: IResolveWorkingDirectoryRequest): Promise { return this._sequencer.queue(request.sessionId, async () => { try { return await this.resolveWorkingDirectory(request); } finally { this.clearPending(request.sessionId); } }); } /** * Builds the `isolation` / `branch` schema contribution for * `resolveSessionConfig`. When {@link IResolveIsolationConfigRequest.workingDirectory} * is not a git repository (or has no commits yet) isolation is forced to * `folder` and no branch property is offered. */ async resolveIsolationConfig(request: IResolveIsolationConfigRequest): Promise { const gitInfo = request.workingDirectory ? await this._getGitInfo(request.workingDirectory) : undefined; const isolationProperty = schemaProperty<'folder' | 'worktree'>({ type: 'string', title: localize('agentHost.sessionConfig.isolation', "Isolation"), description: localize('agentHost.sessionConfig.isolationDescription', "Where the agent should make changes"), enum: gitInfo ? ['folder', 'worktree'] : ['folder'], enumLabels: gitInfo ? [localize('agentHost.sessionConfig.isolation.folder', "Folder"), localize('agentHost.sessionConfig.isolation.worktree', "Worktree")] : [localize('agentHost.sessionConfig.isolation.folder', "Folder")], enumDescriptions: gitInfo ? [localize('agentHost.sessionConfig.isolation.folderDescription', "Work directly in the folder"), localize('agentHost.sessionConfig.isolation.worktreeDescription', "Create a Git worktree for isolation")] : [localize('agentHost.sessionConfig.isolation.folderDescription', "Work directly in the folder")], default: gitInfo ? 'worktree' : 'folder', readOnly: !gitInfo, sessionMutable: false, }); // Resolve isolation first — downstream schema shapes (branch's // read-only mode + enum restriction) depend on the effective value. const isolationDefault: 'folder' | 'worktree' = gitInfo ? 'worktree' : 'folder'; const isolationValue = isolationProperty.validate(request.config?.[SessionConfigKey.Isolation]) ? request.config![SessionConfigKey.Isolation] as 'folder' | 'worktree' : isolationDefault; let branchProperty: ISchemaProperty | undefined; let branchDefault: string | undefined; let branchValue: string | undefined; let worktreeBranchPrefixProperty: ISchemaProperty | undefined; let worktreeIncludeFilesProperty: ISchemaProperty | undefined; let worktreeBranchTrackProperty: ISchemaProperty | undefined; let worktreeCreateNewBranchProperty: ISchemaProperty | undefined; if (gitInfo) { const branchReadOnly = isolationValue === 'folder'; branchDefault = isolationValue === 'worktree' ? gitInfo.defaultBranch.name : gitInfo.currentBranch; branchValue = isolationValue === 'worktree' && typeof request.config?.[SessionConfigKey.Branch] === 'string' ? request.config[SessionConfigKey.Branch] as string : branchDefault; branchProperty = schemaProperty({ type: 'string', title: localize('agentHost.sessionConfig.branch', "Branch"), description: localize('agentHost.sessionConfig.branchDescription', "Base branch to work from"), enum: [branchDefault], enumLabels: [branchDefault], default: branchDefault, enumDynamic: !branchReadOnly, readOnly: branchReadOnly, sessionMutable: false, }); // Carrier for the client's `git.branchPrefix`: the host prepends it // to the branch it creates for an isolated worktree. Declared for // both isolations (like `branch`), so the value rides // `_config.values` and survives isolation toggles — a user who flips // worktree → folder → worktree keeps the prefix. It has no // `enum`/`enumDynamic`, so the config picker treats it as // non-pickable and never surfaces it as a chip: the client seeds it // (from `git.branchPrefix`), the user never edits it, and the host // only *consumes* it for worktree isolation (see // {@link resolveWorkingDirectory}). worktreeBranchPrefixProperty = schemaProperty({ type: 'string', title: localize('agentHost.sessionConfig.worktreeBranchPrefix', "Worktree Branch Prefix"), description: localize('agentHost.sessionConfig.worktreeBranchPrefixDescription', "Prefix applied to the branch created for an isolated worktree."), readOnly: true, sessionMutable: false, }); worktreeBranchTrackProperty = schemaProperty({ type: 'boolean', title: localize('agentHost.sessionConfig.worktreeBranchTrack', "Worktree Branch Tracking"), description: localize('agentHost.sessionConfig.worktreeBranchTrackDescription', "Whether the branch created for an isolated worktree tracks its upstream."), default: false, readOnly: true, sessionMutable: false, }); worktreeCreateNewBranchProperty = schemaProperty({ type: 'boolean', title: localize('agentHost.sessionConfig.worktreeCreateNewBranch', "Create New Worktree Branch"), description: localize('agentHost.sessionConfig.worktreeCreateNewBranchDescription', "Whether to create a new branch for the isolated worktree."), default: true, readOnly: true, sessionMutable: false, }); worktreeIncludeFilesProperty = schemaProperty({ type: 'array', title: localize('agentHost.sessionConfig.worktreeIncludeFiles', "Worktree Include Files"), description: localize('agentHost.sessionConfig.worktreeIncludeFilesDescription', "Glob patterns for git-ignored files to copy into the isolated worktree."), items: { type: 'string', title: localize('agentHost.sessionConfig.worktreeIncludeFilesItem', "Pattern"), }, readOnly: true, sessionMutable: false, }); } return { isolationProperty, branchProperty, worktreeBranchPrefixProperty, worktreeBranchTrackProperty, worktreeCreateNewBranchProperty, worktreeIncludeFilesProperty, isolationValue, branchDefault, branchValue }; } /** * Branch-name completions for the branch picker. Callers forward this from * their `sessionConfigCompletions` when the requested property is * {@link SessionConfigKey.Branch}. */ async branchCompletions(workingDirectory: URI | undefined, query?: string): Promise<{ items: { value: string; label: string }[] }> { if (!workingDirectory) { return { items: [] }; } const [branches, currentBranch, defaultBranch] = await Promise.all([ this._gitService.getBranches(workingDirectory, { pattern: ['refs/heads'], sort: 'committerdate' }), this._gitService.getCurrentBranch(workingDirectory), this._gitService.getDefaultBranch(workingDirectory), ]); const branchCompletions = getBranchCompletions(branches.map(branch => branch.name), { currentBranch, defaultBranch: defaultBranch?.name, query, limit: BRANCH_COMPLETION_LIMIT, }); return { items: branchCompletions.map(branch => ({ value: branch, label: branch })) }; } /** * Resolves the effective working directory for a session that is about to * be materialized. When the session config selects `worktree` isolation on * a git repository, creates or checks out a branch in a worktree, records it for * cleanup, queues the first-turn announcement, persists the worktree * metadata, and returns the worktree URI. Otherwise returns the requested * working directory unchanged. */ async resolveWorkingDirectory(request: IResolveWorkingDirectoryRequest): Promise { const { config, workingDirectory, sessionId, sessionUri, prompt, githubToken, onProgress } = request; if (config?.[SessionConfigKey.Isolation] !== 'worktree' || !workingDirectory || typeof config[SessionConfigKey.Branch] !== 'string') { return workingDirectory; } // Idempotent: if a worktree was already created for this session in this // process (e.g. the caller re-enters materialization after a thread // restart or a post-creation failure) reuse it rather than creating a // second branch + worktree. const already = this._materializedWorktrees.get(sessionId); if (already) { return already.worktree; } onProgress?.(buildWorktreeProgressText(WorktreeCreationPhase.Starting)); const checkoutRoot = await this._gitService.getRepositoryRoot(workingDirectory); if (!checkoutRoot) { return workingDirectory; } const repositoryRoot = await this._resolvePrimaryWorktreeRoot(checkoutRoot, checkoutRoot); const selectedBranch = config[SessionConfigKey.Branch] as string; const worktreeBranchTrack = config[SessionConfigKey.WorktreeBranchTrack] === true; const worktreeCreateNewBranch = config[SessionConfigKey.WorktreeCreateNewBranch] !== false; // Prefix (e.g. the user's `git.branchPrefix`) the client forwards for // worktree-isolated sessions. Prepended ahead of the built-in `agents/` // prefix when naming the branch and stripped from the worktree dir name. const worktreeBranchPrefix = worktreeCreateNewBranch && typeof config[SessionConfigKey.WorktreeBranchPrefix] === 'string' ? config[SessionConfigKey.WorktreeBranchPrefix] as string : undefined; const { worktreePath, branchName, baseBranch } = await this._worktreeCreationSequencer.queue(repositoryRoot.toString(), async () => { const worktreesRoot = getWorktreesRoot(repositoryRoot); if (worktreeCreateNewBranch) { onProgress?.(buildWorktreeProgressText(WorktreeCreationPhase.NamingBranch)); } const newBranchName = worktreeCreateNewBranch ? await this._branchNameGenerator.generateBranchName({ sessionId, message: prompt, githubToken, branchPrefix: worktreeBranchPrefix, branchNameCollides: async candidate => { if (await this._gitService.branchExists(repositoryRoot, candidate).catch(() => true)) { return true; } const candidateWorktree = URI.joinPath(worktreesRoot, getWorktreeName(candidate, worktreeBranchPrefix)); return fileExists(candidateWorktree.fsPath); }, }) : undefined; const branchStartPoint = await this._resolveBranchStartPoint(repositoryRoot, selectedBranch); const baseBranch = worktreeCreateNewBranch ? branchStartPoint : (await this._gitService.getDefaultBranch(repositoryRoot))?.startPoint; // Git suppresses progress for the first couple of seconds, so name // the phase up front rather than leaving the label stale until the // first percentage arrives. onProgress?.(buildWorktreeProgressText(WorktreeCreationPhase.CheckingOut)); await fs.mkdir(worktreesRoot.fsPath, { recursive: true }); const worktreePath = URI.joinPath(worktreesRoot, getWorktreeName(newBranchName ?? selectedBranch, worktreeBranchPrefix)); await withPercentProgress(WorktreeCreationPhase.CheckingOut, onProgress, progress => this._gitService.addWorktree(repositoryRoot, { path: worktreePath, commitish: worktreeCreateNewBranch ? branchStartPoint : selectedBranch, newBranchName, preferRemoteBranch: worktreeCreateNewBranch, track: worktreeBranchTrack, onProgress: progress, })); return { branchName: newBranchName ?? selectedBranch, worktreePath, baseBranch }; }); const worktreeIncludeFiles = Array.isArray(config[SessionConfigKey.WorktreeIncludeFiles]) && config[SessionConfigKey.WorktreeIncludeFiles].every(pattern => typeof pattern === 'string') ? config[SessionConfigKey.WorktreeIncludeFiles] as readonly string[] : undefined; if (worktreeIncludeFiles?.length) { try { onProgress?.(buildWorktreeProgressText(WorktreeCreationPhase.CopyingIncludeFiles)); await withPercentProgress(WorktreeCreationPhase.CopyingIncludeFiles, onProgress, progress => this._gitService.copyWorktreeIncludeFiles(checkoutRoot, worktreePath, worktreeIncludeFiles, progress)); } catch (error) { this._logService.warn(`[${this._logLabel}:${sessionId}] Failed to copy worktree include files: ${errorMessage(error)}`); } } this._materializedWorktrees.set(sessionId, { repositoryRoot, worktree: worktreePath }); // Queue the worktree announcement so the first turn (live) and any // subsequent restore (history) both surface the message in the chat. this._pendingFirstTurnAnnouncements.set(sessionId, buildWorktreeAnnouncementText(branchName)); try { await this._writeWorktreeMetadata(sessionUri, { repositoryRoot, worktreePath, baseBranch, branchName }); } catch (error) { this._logService.warn(`[${this._logLabel}:${sessionId}] Failed to persist worktree branch metadata: ${errorMessage(error)}`); } return worktreePath; } /** Resolves a persisted working directory, repairing a removed worktree when possible. */ async resolveWorkingDirectoryForResume(sessionUri: URI, sessionId: string, workingDirectory: URI): Promise { return this._sequencer.queue(sessionId, () => this._resolveWorkingDirectoryForResume(sessionUri, sessionId, workingDirectory)); } private async _resolveWorkingDirectoryForResume(sessionUri: URI, sessionId: string, workingDirectory: URI): Promise { if (workingDirectory.scheme !== Schemas.file) { return workingDirectory; } try { await fs.access(workingDirectory.fsPath); return workingDirectory; } catch { // Repair or fall back below. } const meta = await this._readWorktreeMetadata(sessionUri).catch(() => undefined); const archived = await this._isSessionArchived(sessionUri); if (archived) { if (meta?.repositoryRoot) { try { await fs.access(meta.repositoryRoot.fsPath); this._logService.info(`[${this._logLabel}:${sessionId}] Archived session working directory '${workingDirectory.fsPath}' is missing; resuming against repository root '${meta.repositoryRoot.fsPath}' for history`); return meta.repositoryRoot; } catch { // Fall through when the repository root is also gone. } } this._logService.warn(`[${this._logLabel}:${sessionId}] Cannot resume archived session: working directory '${workingDirectory.fsPath}' is missing and no usable repository-root fallback was found`); throw new SessionWorkingDirectoryMissingError(workingDirectory); } let recreateFailureReason: string | undefined; if (meta?.worktreePath && meta.repositoryRoot) { const { branchName, worktreePath, repositoryRoot } = meta; const recreated = await this._recreateWorktree(sessionId, { branchName, worktreePath, repositoryRoot }); if (recreated.ok) { this._logService.info(`[${this._logLabel}:${sessionId}] Recreated missing worktree '${worktreePath.fsPath}' for a live session on resume`); return worktreePath; } recreateFailureReason = recreated.reason; } this._logService.warn(`[${this._logLabel}:${sessionId}] Cannot resume: working directory '${workingDirectory.fsPath}' is missing and its worktree could not be recreated${recreateFailureReason ? `: ${recreateFailureReason}` : ''}`); throw new SessionWorkingDirectoryMissingError(workingDirectory, recreateFailureReason); } /** * Takes (and clears) the pending "worktree created" announcement for a * session so callers can emit it live as the first response part on the * first turn. Returns `undefined` when the session has no pending * announcement. */ takePendingAnnouncement(sessionId: string): string | undefined { const announcement = this._pendingFirstTurnAnnouncements.get(sessionId); if (announcement !== undefined) { this._pendingFirstTurnAnnouncements.delete(sessionId); } return announcement; } async persistCreationFailure(sessionUri: URI, sessionId: string, diagnostic: string | undefined): Promise { const dbRef = this._sessionDataService.openDatabase(sessionUri); try { await dbRef.object.setMetadata(WORKTREE_META_CREATION_FAILURE, JSON.stringify({ sessionId, diagnostic: normalizeWorktreeFailureDiagnostic(diagnostic), })); } finally { dbRef.dispose(); } } /** * Re-injects the applicable worktree notice into the first restored turn. * * The live path ({@link takePendingAnnouncement}) handles the very first * turn while the session is fresh; this path takes over on subsequent loads * (where the synthetic announcement is not part of the agent transcript). */ async applyRestoreAnnouncement(sessionUri: URI, turns: readonly Turn[]): Promise { const notice = await this._readWorktreeNotice(sessionUri).catch(() => undefined); if (notice?.kind === 'failure') { return prependWorktreeFailureToFirstTurn(turns, notice.diagnostic); } if (notice?.kind !== 'success') { return turns; } return prependAnnouncementToFirstTurn(turns, buildWorktreeAnnouncementText(notice.branchName)); } /** Resolves the worktree to remove before the session database is deleted. */ async prepareSessionDeletion(sessionUri: URI, sessionId: string): Promise { return this._sequencer.queue(sessionId, async () => { const deletionRetry = this._worktreeDeletionRetries.get(sessionId); if (deletionRetry) { return deletionRetry; } const materializedWorktree = this._materializedWorktrees.get(sessionId); if (materializedWorktree) { return materializedWorktree; } try { const meta = await this._readWorktreeMetadata(sessionUri); return meta?.worktreePath && meta.repositoryRoot ? { repositoryRoot: meta.repositoryRoot, worktree: meta.worktreePath } : undefined; } catch (error) { this._logService.warn(`[${this._logLabel}:${sessionId}] Failed to read worktree metadata before session deletion: ${errorMessage(error)}`); throw error; } }); } /** Force-removes the resolved worktree after the user confirms session deletion. */ async removeSessionWorktree(sessionId: string, worktree: ISessionWorktree | undefined): Promise { return this._sequencer.queue(sessionId, () => this._removeSessionWorktree(sessionId, worktree)); } private async _removeSessionWorktree(sessionId: string, worktree: ISessionWorktree | undefined): Promise { this.clearPending(sessionId); if (!worktree) { return; } try { await this._gitService.removeWorktree(worktree.repositoryRoot, worktree.worktree, { force: true }); this._materializedWorktrees.delete(sessionId); this._worktreeDeletionRetries.delete(sessionId); } catch (error) { this._worktreeDeletionRetries.set(sessionId, worktree); this._logService.warn(`[${this._logLabel}:${sessionId}] Failed to remove worktree '${worktree.worktree.fsPath}': ${errorMessage(error)}`); throw error; } } /** * On archive, removes the worktree directory when its branch is preserved * and the working tree is clean, so the worktree can be recreated on * unarchive without losing work. Skips the removal when the branch is * missing or the tree is dirty. */ async cleanupWorktreeOnArchive(sessionUri: URI, sessionId: string): Promise { return this._sequencer.queue(sessionId, () => this._cleanupWorktreeOnArchive(sessionUri, sessionId)); } private async _cleanupWorktreeOnArchive(sessionUri: URI, sessionId: string): Promise { const meta = await this._readWorktreeMetadata(sessionUri).catch(() => undefined); if (!meta?.worktreePath || !meta.repositoryRoot) { return; } const { branchName, worktreePath, repositoryRoot } = meta; // Skip if the worktree directory is already gone — nothing to clean. try { await fs.access(worktreePath.fsPath); } catch { this._materializedWorktrees.delete(sessionId); return; } // Skip if the branch is missing — without it we can't safely recreate // the worktree on unarchive, so leave the working tree intact. const branchPresent = await this._gitService.branchExists(repositoryRoot, branchName).catch(() => false); if (!branchPresent) { this._logService.info(`[${this._logLabel}:${sessionId}] Skipping worktree cleanup: branch '${branchName}' is missing`); return; } // Commit any uncommitted changes before archiving the session const hasUncommittedChanges = await this._gitService.hasUncommittedChanges(worktreePath).catch(() => true); if (hasUncommittedChanges) { try { await this._gitService.commitAll(worktreePath, localize('worktreeIsolation.commitMessage', 'Saving uncommitted changes before archiving session')); } catch (error) { this._logService.warn(`[${this._logLabel}:${sessionId}] Failed to commit uncommitted changes in '${worktreePath.fsPath}': ${errorMessage(error)}`); return; } } try { await this._gitService.removeWorktree(repositoryRoot, worktreePath); this._logService.info(`[${this._logLabel}:${sessionId}] Removed worktree '${worktreePath.fsPath}' on archive`); this._materializedWorktrees.delete(sessionId); } catch (error) { this._logService.warn(`[${this._logLabel}:${sessionId}] Failed to remove worktree '${worktreePath.fsPath}' on archive: ${errorMessage(error)}`); } } /** * On unarchive, recreates a previously cleaned-up worktree against its * preserved branch. No-op when the directory still exists or the branch is * missing. */ async recreateWorktreeOnUnarchive(sessionUri: URI, sessionId: string): Promise { return this._sequencer.queue(sessionId, () => this._recreateWorktreeOnUnarchive(sessionUri, sessionId)); } private async _recreateWorktreeOnUnarchive(sessionUri: URI, sessionId: string): Promise { const meta = await this._readWorktreeMetadata(sessionUri).catch(() => undefined); if (!meta?.worktreePath || !meta.repositoryRoot) { return; } // Skip if the worktree directory already exists — nothing to do. try { await fs.access(meta.worktreePath.fsPath); return; } catch { // expected when the worktree was cleaned up on archive } const { branchName, worktreePath, repositoryRoot } = meta; await this._recreateWorktree(sessionId, { branchName, worktreePath, repositoryRoot }); } private async _recreateWorktree(sessionId: string, meta: { readonly branchName: string; readonly worktreePath: URI; readonly repositoryRoot: URI }): Promise<{ readonly ok: true } | { readonly ok: false; readonly reason: string }> { const { branchName, worktreePath, repositoryRoot } = meta; const branchPresent = await this._gitService.branchExists(repositoryRoot, branchName).catch(() => false); if (!branchPresent) { const reason = localize('worktreeRecreateBranchMissing', "the branch '{0}' no longer exists", branchName); this._logService.info(`[${this._logLabel}:${sessionId}] Cannot recreate worktree: branch '${branchName}' is missing`); return { ok: false, reason }; } try { await fs.mkdir(URI.joinPath(worktreePath, '..').fsPath, { recursive: true }); await this._gitService.addExistingWorktree(repositoryRoot, worktreePath, branchName); this._materializedWorktrees.set(sessionId, { repositoryRoot, worktree: worktreePath }); this._logService.info(`[${this._logLabel}:${sessionId}] Recreated worktree '${worktreePath.fsPath}'`); return { ok: true }; } catch (error) { const reason = errorMessage(error); this._logService.warn(`[${this._logLabel}:${sessionId}] Failed to recreate worktree '${worktreePath.fsPath}': ${reason}`); return { ok: false, reason }; } } /** Reads the persisted worktree metadata for a session, if any. */ async readWorktreeMetadata(sessionUri: URI): Promise { return this._readWorktreeMetadata(sessionUri); } /** * Bridges worktree metadata for a legacy session adopted in place, whose * working directory is a pre-existing git worktree the agent host did not * create. When `workingDirectory` is a linked worktree (its checkout root * differs from the repository's primary worktree root), persists the worktree * branch / path / repository-root (and diff base branch) so the adopted * session groups under its repository and computes diffs against the right * base — parity with natively worktree-isolated sessions. Deliberately does * NOT register the worktree as host-created, so disposing the session never * deletes the user-owned worktree. Returns `true` when metadata was recorded. */ async adoptExistingWorktreeMetadata(sessionUri: URI, workingDirectory: URI): Promise { const linkedWorktree = await this._resolveLinkedWorktree(workingDirectory); if (!linkedWorktree) { return false; } const { worktreeRoot, primaryRoot, baseBranch } = linkedWorktree; const branchName = await this._gitService.getCurrentBranch(worktreeRoot).catch(() => undefined) ?? 'HEAD'; await this._writeWorktreeMetadata(sessionUri, { branchName, baseBranch, worktreePath: worktreeRoot, repositoryRoot: primaryRoot }); return true; } /** * Records repository identity for an externally-owned linked worktree without taking ownership of its lifecycle. */ async recordExternalWorktreeProject(sessionUri: URI, workingDirectory: URI): Promise { const linkedWorktree = await this._resolveLinkedWorktree(workingDirectory); if (!linkedWorktree) { return undefined; } const { primaryRoot, baseBranch } = linkedWorktree; const dbRef = this._sessionDataService.openDatabase(sessionUri); try { const work: Promise[] = [ dbRef.object.setMetadata(WORKTREE_META_REPOSITORY_ROOT, primaryRoot.toString()), ]; if (baseBranch) { work.push(dbRef.object.setMetadata(META_DIFF_BASE_BRANCH, baseBranch)); } await Promise.all(work); } finally { dbRef.dispose(); } return projectFromRepositoryRoot(primaryRoot); } private async _resolveLinkedWorktree(workingDirectory: URI): Promise<{ worktreeRoot: URI; primaryRoot: URI; baseBranch: string | undefined } | undefined> { const worktreeRoot = await this._gitService.getRepositoryRoot(workingDirectory).catch(() => undefined); if (!worktreeRoot) { return undefined; } const primaryRoot = await tryResolvePrimaryWorktreeRoot(this._gitService, worktreeRoot).catch(() => undefined); if (!primaryRoot || isEqual(primaryRoot, worktreeRoot)) { return undefined; } const baseBranch = (await this._gitService.getDefaultBranch(primaryRoot).catch(() => undefined))?.name; return { worktreeRoot, primaryRoot, baseBranch }; } /** * Resolves the repository "project" for a worktree-isolated session from its * persisted worktree metadata. Worktree sessions run out of a * `.worktrees/` directory, but in the sessions UI they must group * under the *repository* (e.g. `vscode`) — not the worktree folder — exactly * like Copilot. Returns the repository root as the project so agents can merge * it into the `project` field of the `IAgentSessionMetadata` reported from * `listSessions` / `getSessionMetadata`; without it a list refresh clears the * transient project set by the materialize event and the workspace reverts to * the worktree directory name. Returns `undefined` for sessions that were never * worktree-isolated, leaving the caller's own folder-based project untouched. */ async resolveWorktreeProject(sessionUri: URI): Promise { const meta = await this._readWorktreeMetadata(sessionUri).catch(() => undefined); return meta?.repositoryRoot ? projectFromRepositoryRoot(meta.repositoryRoot) : undefined; } private async _resolvePrimaryWorktreeRoot(checkoutRoot: URI, fallbackRoot: URI): Promise { try { return await tryResolvePrimaryWorktreeRoot(this._gitService, checkoutRoot) ?? fallbackRoot; } catch (error) { this._logService.warn(`[${this._logLabel}] Failed to resolve primary worktree for '${checkoutRoot.fsPath}': ${errorMessage(error)}`); return fallbackRoot; } } /** * Synchronous companion to {@link resolveWorktreeProject} for the * materialize-event path: the repository project for a worktree this agent * created in the current process, or `undefined` when the session has none. * Lets an agent supply the materialize event's `project` without an async * metadata read so a fresh worktree groups under the repository the moment it * materializes. */ sessionWorktreeProject(sessionId: string): IAgentSessionProjectInfo | undefined { const worktree = this._materializedWorktrees.get(sessionId); return worktree ? projectFromRepositoryRoot(worktree.repositoryRoot) : undefined; } private async _getGitInfo(workingDirectory: URI): Promise<{ currentBranch: string; defaultBranch: IDefaultBranch } | undefined> { const repositoryRoot = await this._gitService.getRepositoryRoot(workingDirectory); if (!repositoryRoot) { return undefined; } // Skip worktree isolation for a repo with no commits yet (unborn HEAD); `git worktree add` would fail. const headCommit = await this._gitService.revParse(repositoryRoot, 'HEAD').catch(() => undefined); if (!headCommit) { return undefined; } const currentBranch = await this._gitService.getCurrentBranch(repositoryRoot) ?? 'HEAD'; const defaultBranch = await this._gitService.getDefaultBranch(repositoryRoot) ?? { name: currentBranch, startPoint: currentBranch }; return { currentBranch, defaultBranch }; } private async _resolveBranchStartPoint(repositoryRoot: URI, selectedBranch: string): Promise { const defaultBranch = await this._gitService.getDefaultBranch(repositoryRoot); return defaultBranch?.name === selectedBranch ? defaultBranch.startPoint : selectedBranch; } private async _writeWorktreeMetadata(sessionUri: URI, metadata: { branchName: string; baseBranch: string | undefined; worktreePath: URI; repositoryRoot: URI }): Promise { const dbRef = this._sessionDataService.openDatabase(sessionUri); try { const work: Promise[] = [ dbRef.object.setMetadata(WORKTREE_META_BRANCH, metadata.branchName), dbRef.object.setMetadata(WORKTREE_META_PATH, metadata.worktreePath.toString()), dbRef.object.setMetadata(WORKTREE_META_REPOSITORY_ROOT, metadata.repositoryRoot.toString()), ]; if (metadata.baseBranch) { work.push(dbRef.object.setMetadata(META_DIFF_BASE_BRANCH, metadata.baseBranch)); } await Promise.all(work); } finally { dbRef.dispose(); } } /** * Reads persisted worktree metadata, canonicalizing, repairing, and persisting the repository root when needed. * It probes an existing worktree when available and otherwise falls back to the persisted root for archived sessions. * The repair is only reachable when {@link WORKTREE_META_BRANCH} is present, so a root * persisted without its branch will never heal. */ private async _readWorktreeMetadata(sessionUri: URI): Promise { const ref = await this._sessionDataService.tryOpenDatabase(sessionUri); if (!ref) { return undefined; } try { const [branchName, worktreePathRaw, repositoryRootRaw, legacyWorkingDirectoryRaw] = await Promise.all([ ref.object.getMetadata(WORKTREE_META_BRANCH), ref.object.getMetadata(WORKTREE_META_PATH), ref.object.getMetadata(WORKTREE_META_REPOSITORY_ROOT), ref.object.getMetadata(LEGACY_WORKTREE_META_WORKING_DIRECTORY), ]); if (!branchName) { return undefined; } const worktreePath = worktreePathRaw ? URI.parse(worktreePathRaw) : legacyWorkingDirectoryRaw ? URI.parse(legacyWorkingDirectoryRaw) : undefined; let repositoryRoot = repositoryRootRaw ? URI.parse(repositoryRootRaw) : worktreePath ? deriveRepositoryRootFromWorktree(worktreePath) : undefined; if (repositoryRoot) { const checkoutRoot = worktreePath && await fileExists(worktreePath.fsPath) ? worktreePath : repositoryRoot; const primaryRoot = await this._resolvePrimaryWorktreeRoot(checkoutRoot, repositoryRoot); if (primaryRoot.toString() !== repositoryRoot.toString()) { repositoryRoot = primaryRoot; try { await ref.object.setMetadata(WORKTREE_META_REPOSITORY_ROOT, primaryRoot.toString()); } catch (error) { this._logService.warn(`[${this._logLabel}] Failed to normalize worktree repository metadata for '${sessionUri.toString()}': ${errorMessage(error)}`); } } } return { branchName, worktreePath, repositoryRoot }; } finally { ref.dispose(); } } private async _readWorktreeNotice(sessionUri: URI): Promise<{ kind: 'success'; branchName: string } | { kind: 'failure'; diagnostic?: string } | undefined> { const ref = await this._sessionDataService.tryOpenDatabase(sessionUri); if (!ref) { return undefined; } try { const [branchName, failureRaw] = await Promise.all([ ref.object.getMetadata(WORKTREE_META_BRANCH), ref.object.getMetadata(WORKTREE_META_CREATION_FAILURE), ]); if (branchName) { return { kind: 'success', branchName }; } if (!failureRaw) { return undefined; } const failure = JSON.parse(failureRaw); if (!failure || typeof failure !== 'object' || Array.isArray(failure)) { return undefined; } const raw = failure as Record; if (raw['sessionId'] !== AgentSession.id(sessionUri)) { return undefined; } return { kind: 'failure', diagnostic: typeof raw['diagnostic'] === 'string' ? normalizeWorktreeFailureDiagnostic(raw['diagnostic']) : undefined, }; } finally { ref.dispose(); } } private async _isSessionArchived(sessionUri: URI): Promise { const ref = await this._sessionDataService.tryOpenDatabase(sessionUri); if (!ref) { return false; } try { const [isArchived, isDone] = await Promise.all([ ref.object.getMetadata(AH_META_IS_ARCHIVED_DB_KEY), ref.object.getMetadata(AH_META_IS_DONE_DB_KEY), ]); return isArchived !== undefined ? isArchived === 'true' : isDone === 'true'; } finally { ref.dispose(); } } } /** * Derives the repository {@link IAgentSessionProjectInfo} from a repository * root URI. The display name is the repo directory's basename (falling back to * the URI string for pathological roots), matching how Copilot names the * project via `resolveGitProject`. */ function projectFromRepositoryRoot(repositoryRoot: URI): IAgentSessionProjectInfo { return { uri: repositoryRoot, displayName: basename(repositoryRoot.fsPath) || repositoryRoot.toString() }; } function deriveRepositoryRootFromWorktree(worktree: URI): URI | undefined { if (worktree.scheme !== Schemas.file) { return undefined; } const worktreesRoot = URI.joinPath(worktree, '..'); const worktreesRootName = basename(worktreesRoot.fsPath); const suffix = '.worktrees'; if (!worktreesRootName.endsWith(suffix)) { return undefined; } const repositoryName = worktreesRootName.slice(0, -suffix.length); return repositoryName ? URI.joinPath(worktreesRoot, '..', repositoryName) : undefined; } /** * Builds the repository {@link IAgentSessionProjectInfo} from a persisted * {@link WORKTREE_META_REPOSITORY_ROOT} value (a URI string), or `undefined` * when absent. Lets the host merge the repository project into a session's * catalog entry directly from a metadata batch it already read, without a * second database open. */ export function worktreeProjectFromRepositoryRoot(repositoryRootRaw: string | undefined): IAgentSessionProjectInfo | undefined { return repositoryRootRaw ? projectFromRepositoryRoot(URI.parse(repositoryRootRaw)) : undefined; } function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } async function fileExists(path: string): Promise { try { await fs.access(path); return true; } catch { return false; } }