Git - refactor create/delete worktree and expose extension API (#278107)

* Git - refactor create/delete worktree and expose extension API

* Pull request feedback

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Ladislau Szomoru
2025-11-18 15:37:56 +00:00
committed by GitHub
parent d5f4606ce7
commit 5a1c4e6ca5
4 changed files with 161 additions and 156 deletions

View File

@@ -318,6 +318,14 @@ export class ApiRepository implements Repository {
dropStash(index?: number): Promise<void> {
return this.#repository.dropStash(index);
}
createWorktree(options?: { path?: string; commitish?: string; branch?: string }): Promise<string> {
return this.#repository.createWorktree(options);
}
deleteWorktree(path: string, options?: { force?: boolean }): Promise<void> {
return this.#repository.deleteWorktree(path, options);
}
}
export class ApiGit implements Git {

View File

@@ -289,6 +289,9 @@ export interface Repository {
applyStash(index?: number): Promise<void>;
popStash(index?: number): Promise<void>;
dropStash(index?: number): Promise<void>;
createWorktree(options?: { path?: string; commitish?: string; branch?: string }): Promise<string>;
deleteWorktree(path: string, options?: { force?: boolean }): Promise<void>;
}
export interface RemoteSource {

View File

@@ -762,8 +762,6 @@ export class CommandCenter {
private disposables: Disposable[];
private commandErrors = new CommandErrorOutputTextDocumentContentProvider();
private static readonly WORKTREE_ROOT_KEY = 'worktreeRoot';
constructor(
private git: Git,
private model: Model,
@@ -3500,119 +3498,47 @@ export class CommandCenter {
});
}
@command('git.createWorktreeWithDefaults', { repository: true, repositoryFilter: ['repository'] })
async createWorktreeWithDefaults(
repository: Repository,
commitish: string = 'HEAD'
): Promise<string | undefined> {
const config = workspace.getConfiguration('git');
const branchPrefix = config.get<string>('branchPrefix', '');
// Generate branch name if not provided
let branch = await this.generateRandomBranchName(repository, '-');
if (!branch) {
// Fallback to timestamp-based name if random generation fails
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
branch = `${branchPrefix}worktree-${timestamp}`;
}
// Ensure branch name starts with prefix if configured
if (branchPrefix && !branch.startsWith(branchPrefix)) {
branch = branchPrefix + branch;
}
// Create worktree name from branch name
const worktreeName = branch.startsWith(branchPrefix)
? branch.substring(branchPrefix.length).replace(/\//g, '-')
: branch.replace(/\//g, '-');
// Determine default worktree path
const defaultWorktreeRoot = this.globalState.get<string>(`${CommandCenter.WORKTREE_ROOT_KEY}:${repository.root}`);
const defaultWorktreePath = defaultWorktreeRoot
? path.join(defaultWorktreeRoot, worktreeName)
: path.join(path.dirname(repository.root), `${path.basename(repository.root)}.worktrees`, worktreeName);
// Check if worktree already exists at this path
const existingWorktree = repository.worktrees.find(worktree =>
pathEquals(path.normalize(worktree.path), path.normalize(defaultWorktreePath))
);
if (existingWorktree) {
// Generate unique path by appending a number
let counter = 1;
let uniquePath = `${defaultWorktreePath}-${counter}`;
while (repository.worktrees.some(wt => pathEquals(path.normalize(wt.path), path.normalize(uniquePath)))) {
counter++;
uniquePath = `${defaultWorktreePath}-${counter}`;
}
const finalWorktreePath = uniquePath;
try {
await repository.addWorktree({ path: finalWorktreePath, branch, commitish });
// Update worktree root in global state
const worktreeRoot = path.dirname(finalWorktreePath);
if (worktreeRoot !== defaultWorktreeRoot) {
this.globalState.update(`${CommandCenter.WORKTREE_ROOT_KEY}:${repository.root}`, worktreeRoot);
}
return finalWorktreePath;
} catch (err) {
// Return undefined on failure
return undefined;
}
}
try {
await repository.addWorktree({ path: defaultWorktreePath, branch, commitish });
// Update worktree root in global state
const worktreeRoot = path.dirname(defaultWorktreePath);
if (worktreeRoot !== defaultWorktreeRoot) {
this.globalState.update(`${CommandCenter.WORKTREE_ROOT_KEY}:${repository.root}`, worktreeRoot);
}
return defaultWorktreePath;
} catch (err) {
// Return undefined on failure
return undefined;
}
}
@command('git.createWorktree', { repository: true })
@command('git.createWorktree', { repository: true, repositoryFilter: ['repository', 'submodule'] })
async createWorktree(repository?: Repository): Promise<void> {
if (!repository) {
// Single repository/submodule/worktree
if (this.model.repositories.length === 1) {
repository = this.model.repositories[0];
}
}
if (!repository) {
// Single repository/submodule
const repositories = this.model.repositories
.filter(r => r.kind === 'repository' || r.kind === 'submodule');
if (repositories.length === 1) {
repository = repositories[0];
}
}
if (!repository) {
// Multiple repositories/submodules
repository = await this.model.pickRepository(['repository', 'submodule']);
}
if (!repository) {
return;
}
await this._createWorktree(repository);
}
private async _createWorktree(repository: Repository): Promise<void> {
const config = workspace.getConfiguration('git');
const branchPrefix = config.get<string>('branchPrefix')!;
// Get commitish and branch for the new worktree
const worktreeDetails = await this.getWorktreeCommitishAndBranch(repository);
if (!worktreeDetails) {
return;
}
const { commitish, branch } = worktreeDetails;
const worktreeName = ((branch ?? commitish).startsWith(branchPrefix)
? (branch ?? commitish).substring(branchPrefix.length).replace(/\//g, '-')
: (branch ?? commitish).replace(/\//g, '-'));
// Get path for the new worktree
const worktreePath = await this.getWorktreePath(repository, worktreeName);
if (!worktreePath) {
return;
}
try {
await repository.createWorktree({ path: worktreePath, branch, commitish: commitish });
} catch (err) {
if (err instanceof GitError && err.gitErrorCode === GitErrorCodes.WorktreeAlreadyExists) {
await this.handleWorktreeAlreadyExists(err);
} else if (err instanceof GitError && err.gitErrorCode === GitErrorCodes.WorktreeBranchAlreadyUsed) {
await this.handleWorktreeBranchAlreadyUsed(err);
} else {
throw err;
}
}
}
private async getWorktreeCommitishAndBranch(repository: Repository): Promise<{ commitish: string; branch: string | undefined } | undefined> {
const config = workspace.getConfiguration('git', Uri.file(repository.root));
const showRefDetails = config.get<boolean>('showReferenceDetails') === true;
const createBranch = new CreateBranchItem();
@@ -3631,23 +3557,21 @@ export class CommandCenter {
const choice = await this.pickRef(getBranchPicks(), placeHolder);
if (!choice) {
return;
return undefined;
}
let branch: string | undefined = undefined;
let commitish: string;
if (choice === createBranch) {
branch = await this.promptForBranchName(repository);
// Create new branch
const branch = await this.promptForBranchName(repository);
if (!branch) {
return;
return undefined;
}
commitish = 'HEAD';
return { commitish: 'HEAD', branch };
} else {
// Existing reference
if (!(choice instanceof RefItem) || !choice.refName) {
return;
return undefined;
}
if (choice.refName === repository.HEAD?.name) {
@@ -3656,15 +3580,14 @@ export class CommandCenter {
const pick = await window.showWarningMessage(message, { modal: true }, createBranch);
if (pick === createBranch) {
branch = await this.promptForBranchName(repository);
const branch = await this.promptForBranchName(repository);
if (!branch) {
return;
return undefined;
}
commitish = 'HEAD';
return { commitish: 'HEAD', branch };
} else {
return;
return undefined;
}
} else {
// Check whether the selected branch is checked out in an existing worktree
@@ -3674,17 +3597,14 @@ export class CommandCenter {
await this.handleWorktreeConflict(worktree.path, message);
return;
}
commitish = choice.refName;
return { commitish: choice.refName, branch: undefined };
}
}
}
const worktreeName = ((branch ?? commitish).startsWith(branchPrefix)
? (branch ?? commitish).substring(branchPrefix.length).replace(/\//g, '-')
: (branch ?? commitish).replace(/\//g, '-'));
// If user selects folder button, they manually select the worktree path through folder picker
private async getWorktreePath(repository: Repository, worktreeName: string): Promise<string | undefined> {
const getWorktreePath = async (): Promise<string | undefined> => {
const worktreeRoot = this.globalState.get<string>(`${CommandCenter.WORKTREE_ROOT_KEY}:${repository.root}`);
const worktreeRoot = this.globalState.get<string>(`${Repository.WORKTREE_ROOT_STORAGE_KEY}:${repository.root}`);
const defaultUri = worktreeRoot ? Uri.file(worktreeRoot) : Uri.file(path.dirname(repository.root));
const uris = await window.showOpenDialog({
@@ -3720,7 +3640,7 @@ export class CommandCenter {
};
// Default worktree path is based on the last worktree location or a worktree folder for the repository
const defaultWorktreeRoot = this.globalState.get<string>(`${CommandCenter.WORKTREE_ROOT_KEY}:${repository.root}`);
const defaultWorktreeRoot = this.globalState.get<string>(`${Repository.WORKTREE_ROOT_STORAGE_KEY}:${repository.root}`);
const defaultWorktreePath = defaultWorktreeRoot
? path.join(defaultWorktreeRoot, worktreeName)
: path.join(path.dirname(repository.root), `${path.basename(repository.root)}.worktrees`, worktreeName);
@@ -3759,29 +3679,7 @@ export class CommandCenter {
dispose(disposables);
if (!worktreePath) {
return;
}
try {
await repository.addWorktree({ path: worktreePath, branch, commitish: commitish });
// Update worktree root in global state
const worktreeRoot = path.dirname(worktreePath);
if (worktreeRoot !== defaultWorktreeRoot) {
this.globalState.update(`${CommandCenter.WORKTREE_ROOT_KEY}:${repository.root}`, worktreeRoot);
}
} catch (err) {
if (err instanceof GitError && err.gitErrorCode === GitErrorCodes.WorktreeAlreadyExists) {
await this.handleWorktreeAlreadyExists(err);
} else if (err instanceof GitError && err.gitErrorCode === GitErrorCodes.WorktreeBranchAlreadyUsed) {
await this.handleWorktreeBranchAlreadyUsed(err);
} else {
throw err;
}
return;
}
return worktreePath;
}
private async handleWorktreeBranchAlreadyUsed(err: GitError): Promise<void> {

View File

@@ -7,6 +7,7 @@ import TelemetryReporter from '@vscode/extension-telemetry';
import * as fs from 'fs';
import * as path from 'path';
import picomatch from 'picomatch';
import { uniqueNamesGenerator, adjectives, animals, colors, NumberDictionary } from '@joaomoreno/unique-names-generator';
import { CancellationError, CancellationToken, CancellationTokenSource, Command, commands, Disposable, Event, EventEmitter, FileDecoration, FileType, l10n, LogLevel, LogOutputChannel, Memento, ProgressLocation, ProgressOptions, QuickDiffProvider, RelativePattern, scm, SourceControl, SourceControlInputBox, SourceControlInputBoxValidation, SourceControlInputBoxValidationType, SourceControlResourceDecorations, SourceControlResourceGroup, SourceControlResourceState, TabInputNotebookDiff, TabInputTextDiff, TabInputTextMultiDiff, ThemeColor, ThemeIcon, Uri, window, workspace, WorkspaceEdit } from 'vscode';
import { ActionButton } from './actionButton';
import { ApiRepository } from './api/api1';
@@ -696,6 +697,7 @@ export interface IRepositoryResolver {
}
export class Repository implements Disposable {
static readonly WORKTREE_ROOT_STORAGE_KEY = 'worktreeRoot';
private _onDidChangeRepository = new EventEmitter<Uri>();
readonly onDidChangeRepository: Event<Uri> = this._onDidChangeRepository.event;
@@ -896,7 +898,7 @@ export class Repository implements Disposable {
postCommitCommandsProviderRegistry: IPostCommitCommandsProviderRegistry,
private readonly branchProtectionProviderRegistry: IBranchProtectionProviderRegistry,
historyItemDetailProviderRegistry: ISourceControlHistoryItemDetailsProviderRegistry,
globalState: Memento,
private readonly globalState: Memento,
private readonly logger: LogOutputChannel,
private telemetryReporter: TelemetryReporter,
private readonly repositoryCache: RepositoryCache
@@ -1797,8 +1799,57 @@ export class Repository implements Disposable {
await this.run(Operation.DeleteTag, () => this.repository.deleteTag(name));
}
async addWorktree(options: { path: string; commitish: string; branch?: string }): Promise<void> {
await this.run(Operation.Worktree, () => this.repository.addWorktree(options));
async createWorktree(options?: { path?: string; commitish?: string; branch?: string }): Promise<string> {
const defaultWorktreeRoot = this.globalState.get<string>(`${Repository.WORKTREE_ROOT_STORAGE_KEY}:${this.root}`);
let { path: worktreePath, commitish, branch } = options || {};
let worktreeName: string | undefined;
return await this.run(Operation.Worktree, async () => {
// Generate branch name if not provided
if (branch === undefined) {
const config = workspace.getConfiguration('git', Uri.file(this.root));
const branchPrefix = config.get<string>('branchPrefix', '');
let worktreeName = await this.getRandomBranchName();
if (!worktreeName) {
// Fallback to timestamp-based name if random generation fails
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
worktreeName = `worktree-${timestamp}`;
}
branch = `${branchPrefix}${worktreeName}`;
}
// Generate path if not provided
if (worktreePath === undefined) {
worktreePath = defaultWorktreeRoot
? path.join(defaultWorktreeRoot, worktreeName!)
: path.join(path.dirname(this.root), `${path.basename(this.root)}.worktrees`, worktreeName!);
// Ensure that the worktree path is unique
if (this.worktrees.some(worktree => pathEquals(path.normalize(worktree.path), path.normalize(worktreePath!)))) {
let counter = 1;
let uniqueWorktreePath = `${worktreePath}-${counter}`;
while (this.worktrees.some(wt => pathEquals(path.normalize(wt.path), path.normalize(uniqueWorktreePath)))) {
counter++;
uniqueWorktreePath = `${worktreePath}-${counter}`;
}
worktreePath = uniqueWorktreePath;
}
}
// Create the worktree
await this.repository.addWorktree({ path: worktreePath!, commitish: commitish ?? 'HEAD', branch });
// Update worktree root in global state
const newWorktreeRoot = path.dirname(worktreePath!);
if (defaultWorktreeRoot && !pathEquals(newWorktreeRoot, defaultWorktreeRoot)) {
this.globalState.update(`${Repository.WORKTREE_ROOT_STORAGE_KEY}:${this.root}`, newWorktreeRoot);
}
return worktreePath!;
});
}
async deleteWorktree(path: string, options?: { force?: boolean }): Promise<void> {
@@ -2988,6 +3039,51 @@ export class Repository implements Disposable {
return this.unpublishedCommits;
}
private async getRandomBranchName(): Promise<string | undefined> {
const config = workspace.getConfiguration('git', Uri.file(this.root));
const branchRandomNameEnabled = config.get<boolean>('branchRandomName.enable', false);
if (!branchRandomNameEnabled) {
return undefined;
}
const dictionaries: string[][] = [];
const branchWhitespaceChar = config.get<string>('branchWhitespaceChar', '-');
const branchRandomNameDictionary = config.get<string[]>('branchRandomName.dictionary', ['adjectives', 'animals']);
for (const dictionary of branchRandomNameDictionary) {
if (dictionary.toLowerCase() === 'adjectives') {
dictionaries.push(adjectives);
} else if (dictionary.toLowerCase() === 'animals') {
dictionaries.push(animals);
} else if (dictionary.toLowerCase() === 'colors') {
dictionaries.push(colors);
} else if (dictionary.toLowerCase() === 'numbers') {
dictionaries.push(NumberDictionary.generate({ length: 3 }));
}
}
if (dictionaries.length === 0) {
return undefined;
}
// 5 attempts to generate a random branch name
for (let index = 0; index < 5; index++) {
const randomName = uniqueNamesGenerator({
dictionaries,
length: dictionaries.length,
separator: branchWhitespaceChar
});
// Check for local ref conflict
const refs = await this.getRefs({ pattern: `refs/heads/${randomName}` });
if (refs.length === 0) {
return randomName;
}
}
return undefined;
}
dispose(): void {
this.disposables = dispose(this.disposables);
}