mirror of
https://github.com/microsoft/vscode.git
synced 2025-12-24 12:19:20 +00:00
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:
@@ -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 {
|
||||
|
||||
3
extensions/git/src/api/git.d.ts
vendored
3
extensions/git/src/api/git.d.ts
vendored
@@ -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 {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user