From eb89318d3a82da92992329390d148f7a95f26aae Mon Sep 17 00:00:00 2001 From: Ignat Remizov Date: Thu, 26 Mar 2026 18:01:06 +0200 Subject: [PATCH 1/5] feat(sidebar): show worktrees under their repository Add an optional sidebar mode that shows linked worktrees nested under their repository in the main repository list so repository switching can stay in the main sidebar instead of requiring the worktree dropdown. Changes: - add a secondary Appearance setting to show worktrees in the repository sidebar when worktree support is enabled - group linked worktrees under their main repository in the sidebar - synthesize child rows for linked worktrees discovered from `git worktree list` even when those worktrees were never added as repositories - support linked-only setups by synthesizing sibling worktree rows even when the stored entry is itself a linked worktree instead of the main worktree - use worktree folder names for child row labels while preserving existing alias styling for saved repository entries - use the same displayed-title logic for sorting and disambiguation so nested rows sort and label consistently with what the user sees - preload main-repository worktree state for the sidebar so nested rows and stored linked-worktree branch pills are available on initial render instead of only after opening the worktree dropdown or forcing another sidebar refresh - refresh parent sidebar rows when linked worktrees are selected so nested rows stay in sync with the active repository view - surface nested worktree rows from both saved worktree repositories and synthetic virtual rows without duplicating entries already stored in Desktop - avoid duplicate `Pull all` work for linked worktrees while still including orphan linked worktrees when the main repo is absent from the stored repository list - route virtual worktree open failures through the normal app error path instead of silently failing - open synthetic worktree rows transiently instead of persisting them as top-level repositories under `Other` - keep stored linked-worktree rows on the repository-management context menu while giving synthetic rows a worktree-specific context menu that uses `PopupType.DeleteWorktree` - persist sidebar worktree metadata in repository state and extract the sidebar-specific state shaping into a dedicated helper to keep `app-store.ts` smaller - throttle sidebar worktree refreshes during repository indicator updates to reduce repeated `git worktree list` churn - prune sidebar worktree refresh timestamps when repository lists change so stale cache entries do not accumulate - tighten TypeScript null/undefined handling in repository and sidebar list code so production webpack builds pass across the full CI matrix - add and extend unit coverage for grouped rows, synthetic rows, linked-only setups, stored linked-worktree branch labels, and repository-list context menu behavior Behavioral effect: Users can opt into seeing and switching linked worktrees directly from the main repository sidebar, including unstored Git worktrees, with branch labels, parent-child grouping, and worktree-aware context menu behavior available without forcing those virtual rows into the saved repository list. Testing: - yarn test:unit app/test/unit/repositories-list-grouping-test.ts - yarn test:unit app/test/unit/repository-list-item-context-menu-test.ts - yarn lint - yarn compile:dev - yarn compile:prod --- app/src/lib/app-state.ts | 3 + app/src/lib/git/worktree.ts | 60 +++- app/src/lib/stores/app-store.ts | 193 ++++++++++- .../lib/stores/helpers/sidebar-worktrees.ts | 75 ++++ app/src/models/repository.ts | 31 +- app/src/ui/app.tsx | 15 +- app/src/ui/dispatcher/dispatcher.ts | 4 + app/src/ui/preferences/appearance.tsx | 44 ++- app/src/ui/preferences/preferences.tsx | 26 +- .../repositories-list/group-repositories.ts | 92 ++--- .../repositories-list/repositories-list.tsx | 196 ++++++++++- .../repository-list-item-context-menu.ts | 56 ++- .../repository-list-item.tsx | 57 ++- .../repositories-list/worktree-list-items.ts | 325 ++++++++++++++++++ app/styles/ui/_repository-list.scss | 24 ++ .../unit/repositories-list-grouping-test.ts | 237 +++++++++++++ .../repository-list-item-context-menu-test.ts | 157 +++++++++ 17 files changed, 1494 insertions(+), 101 deletions(-) create mode 100644 app/src/lib/stores/helpers/sidebar-worktrees.ts create mode 100644 app/src/ui/repositories-list/worktree-list-items.ts create mode 100644 app/test/unit/repository-list-item-context-menu-test.ts diff --git a/app/src/lib/app-state.ts b/app/src/lib/app-state.ts index 36501cbe2ac..cf6aef5580b 100644 --- a/app/src/lib/app-state.ts +++ b/app/src/lib/app-state.ts @@ -322,6 +322,9 @@ export interface IAppState { /** Whether or not the worktrees dropdown should be shown in the toolbar */ readonly showWorktrees: boolean + /** Whether linked worktrees should be shown under their repository in the sidebar */ + readonly showWorktreesInSidebar: boolean + /** Whether or not the Compare tab should be shown in the repository view */ readonly showCompareTab: boolean diff --git a/app/src/lib/git/worktree.ts b/app/src/lib/git/worktree.ts index 158acbd4f96..2722412fd56 100644 --- a/app/src/lib/git/worktree.ts +++ b/app/src/lib/git/worktree.ts @@ -5,6 +5,15 @@ import type { WorktreeEntry, WorktreeType } from '../../models/worktree' import { git } from './core' import { normalizePath } from '../helpers/path' +function getDotGitPath(repositoryPath: string): string { + return Path.join(repositoryPath, '.git') +} + +export interface IWorktreePathInfo { + readonly isLinkedWorktree: boolean + readonly mainWorktreePath: string | null +} + export function parseWorktreePorcelainOutput( stdout: string ): ReadonlyArray { @@ -104,6 +113,10 @@ export async function removeWorktree( await git(args, repository.path, 'removeWorktree') } +export async function pruneWorktrees(repository: Repository): Promise { + await git(['worktree', 'prune'], repository.path, 'pruneWorktrees') +} + export async function moveWorktree( repository: Repository, oldPath: string, @@ -135,17 +148,48 @@ export async function getMainWorktreePath( return main?.path ?? null } -/** - * Synchronously checks if a repository path is a linked worktree by examining - * whether `.git` is a file (linked worktree) or directory (main worktree). - */ -export function isLinkedWorktreeSync(repositoryPath: string): boolean { +export function getWorktreePathInfoSync( + repositoryPath: string +): IWorktreePathInfo | null { try { - const dotGit = Path.join(repositoryPath, '.git') + const dotGit = getDotGitPath(repositoryPath) // eslint-disable-next-line no-sync const stats = Fs.statSync(dotGit) - return stats.isFile() + + if (stats.isDirectory()) { + return { isLinkedWorktree: false, mainWorktreePath: repositoryPath } + } + + if (!stats.isFile()) { + return null + } + + // eslint-disable-next-line no-sync + const contents = Fs.readFileSync(dotGit, 'utf8').trim() + if (!contents.startsWith('gitdir: ')) { + return null + } + + const gitDirPath = Path.resolve( + repositoryPath, + contents.substring('gitdir: '.length) + ) + + // eslint-disable-next-line no-sync + const commondir = Fs.readFileSync( + Path.join(gitDirPath, 'commondir'), + 'utf8' + ).trim() + if (commondir.length === 0) { + return null + } + + const commonGitDir = Path.resolve(gitDirPath, commondir) + return { + isLinkedWorktree: true, + mainWorktreePath: Path.dirname(commonGitDir), + } } catch { - return false + return null } } diff --git a/app/src/lib/stores/app-store.ts b/app/src/lib/stores/app-store.ts index ab948e1f95d..02b59e362b2 100644 --- a/app/src/lib/stores/app-store.ts +++ b/app/src/lib/stores/app-store.ts @@ -383,6 +383,13 @@ import { BranchPruner } from './helpers/branch-pruner' import { createTutorialRepository } from './helpers/create-tutorial-repository' import { findRemoteBranchName } from './helpers/find-branch-name' import { RepositoryIndicatorUpdater } from './helpers/repository-indicator-updater' +import { + createSidebarStateFromStatus, + findSidebarWorktreeStateRepository, + getCurrentWorktreeEntryForRepository, + shouldRefreshSidebarWorktrees, + withSidebarWorktrees, +} from './helpers/sidebar-worktrees' import { OnboardingTutorialAssessor } from './helpers/tutorial-assessor' import { getNotificationsEnabled, @@ -396,6 +403,7 @@ import { } from './updates/changes-state' import { updateRemoteUrl } from './updates/update-remote-url' import { getRepoHooks } from '../hooks/get-repo-hooks' +import pLimit from 'p-limit' const LastSelectedRepositoryIDKey = 'last-selected-repository-id' @@ -423,6 +431,7 @@ const branchDropdownWidthConfigKey: string = 'branch-dropdown-width' const defaultWorktreeDropdownWidth: number = 230 const worktreeDropdownWidthConfigKey: string = 'worktree-dropdown-width' +const MaxConcurrentSidebarWorktreePreloads = 4 const defaultPushPullButtonWidth: number = 230 const pushPullButtonWidthConfigKey: string = 'push-pull-button-width' @@ -480,6 +489,7 @@ const shellKey = 'shell' const showRecentRepositoriesKey = 'show-recent-repositories' const showWorktreesKey = 'show-worktrees' +const showWorktreesInSidebarKey = 'show-worktrees-in-sidebar' const showCompareTabKey = 'show-compare-tab' const showCompareTabDefault = true const repositoryIndicatorsEnabledKey = 'enable-repository-indicators' @@ -651,6 +661,8 @@ export class AppStore extends TypedBaseStore { private titleBarStyle: TitleBarStyle = 'native' private showRecentRepositories: boolean = true private showWorktrees: boolean = false + private showWorktreesInSidebar: boolean = false + private readonly lastSidebarWorktreeRefreshAt = new Map() private showCompareTab: boolean = showCompareTabDefault private hideWindowOnQuit: boolean = __DARWIN__ @@ -772,6 +784,8 @@ export class AppStore extends TypedBaseStore { this.showRecentRepositories = getBoolean(showRecentRepositoriesKey) ?? true this.showWorktrees = getBoolean(showWorktreesKey) ?? false + this.showWorktreesInSidebar = + this.showWorktrees && (getBoolean(showWorktreesInSidebarKey) ?? false) this.showCompareTab = getBoolean(showCompareTabKey, showCompareTabDefault) this.repositoryIndicatorUpdater = new RepositoryIndicatorUpdater( @@ -929,6 +943,16 @@ export class AppStore extends TypedBaseStore { } } + private pruneSidebarWorktreeRefreshCache() { + const currentRepositoryHashes = new Set(this.repositories.map(r => r.hash)) + + for (const hash of this.lastSidebarWorktreeRefreshAt.keys()) { + if (!currentRepositoryHashes.has(hash)) { + this.lastSidebarWorktreeRefreshAt.delete(hash) + } + } + } + private recordTutorialStepCompleted(step: TutorialStep): void { if (!isValidTutorialStep(step)) { return @@ -1043,7 +1067,11 @@ export class AppStore extends TypedBaseStore { this.repositoriesStore.onDidUpdate(updateRepositories => { this.repositories = updateRepositories + this.pruneSidebarWorktreeRefreshCache() this.updateRepositorySelectionAfterRepositoriesChanged() + if (this.showWorktreesInSidebar) { + void this.preloadSidebarWorktrees() + } this.emitUpdate() }) @@ -1227,6 +1255,7 @@ export class AppStore extends TypedBaseStore { titleBarStyle: this.titleBarStyle, showRecentRepositories: this.showRecentRepositories, showWorktrees: this.showWorktrees, + showWorktreesInSidebar: this.showWorktreesInSidebar, showCompareTab: this.showCompareTab, apiRepositories: this.apiRepositoriesStore.getState(), useWindowsOpenSSH: this.useWindowsOpenSSH, @@ -2511,8 +2540,12 @@ export class AppStore extends TypedBaseStore { this.accounts = accounts this.repositories = repositories + this.pruneSidebarWorktreeRefreshCache() this.updateRepositorySelectionAfterRepositoriesChanged() + if (this.showWorktreesInSidebar) { + void this.preloadSidebarWorktrees() + } this.sidebarWidth = constrain( getNumber(sidebarWidthConfigKey, defaultSidebarWidth) @@ -4013,6 +4046,10 @@ export class AppStore extends TypedBaseStore { const state = this.repositoryStateCache.get(repository) const gitStore = this.gitStoreCache.get(repository) + const sidebarRepository = findSidebarWorktreeStateRepository( + this.repositories, + repository + ) // if we cannot get a valid status it's a good indicator that the repository // is in a bad state - let's mark it as missing here and give up on the @@ -4029,6 +4066,35 @@ export class AppStore extends TypedBaseStore { await gitStore.loadRemotes() await gitStore.loadBranches() await gitStore.loadWorktrees() + this.repositoryStateCache.updateWorktreesState(repository, () => ({ + allWorktrees: gitStore.allWorktrees, + currentWorktree: gitStore.currentWorktree, + })) + if (sidebarRepository !== repository) { + this.repositoryStateCache.updateWorktreesState(sidebarRepository, () => ({ + allWorktrees: gitStore.allWorktrees, + currentWorktree: getCurrentWorktreeEntryForRepository( + gitStore.allWorktrees, + sidebarRepository + ), + })) + } + + if (this.showWorktreesInSidebar) { + this.lastSidebarWorktreeRefreshAt.set(repository.hash, Date.now()) + this.lastSidebarWorktreeRefreshAt.set(sidebarRepository.hash, Date.now()) + this.updateSidebarIndicator(repository, status) + const refreshed = this.localRepositoryStateLookup.get( + sidebarRepository.id + ) + if (refreshed !== undefined) { + this.localRepositoryStateLookup.set( + sidebarRepository.id, + withSidebarWorktrees(refreshed, gitStore.allWorktrees) + ) + } + } + this.emitUpdate() const section = state.selectedSection let refreshSectionPromise: Promise @@ -4080,6 +4146,63 @@ export class AppStore extends TypedBaseStore { } } + private async preloadSidebarWorktrees() { + const limit = pLimit(MaxConcurrentSidebarWorktreePreloads) + + await Promise.all( + this.repositories.map(repository => + limit(async () => { + const exists = await pathExists(repository.path) + if (!exists) { + const existing = this.localRepositoryStateLookup.get(repository.id) + if (existing !== undefined) { + this.localRepositoryStateLookup.set( + repository.id, + withSidebarWorktrees(existing, []) + ) + } + return + } + + try { + const gitStore = this.gitStoreCache.get(repository) + await gitStore.loadWorktrees() + this.repositoryStateCache.updateWorktreesState(repository, () => ({ + allWorktrees: gitStore.allWorktrees, + currentWorktree: gitStore.currentWorktree, + })) + + const existing = this.localRepositoryStateLookup.get(repository.id) + if (existing !== undefined) { + this.localRepositoryStateLookup.set( + repository.id, + withSidebarWorktrees(existing, gitStore.allWorktrees) + ) + } + this.lastSidebarWorktreeRefreshAt.set(repository.hash, Date.now()) + this.emitUpdate() + } catch (error) { + log.warn( + `[AppStore] Failed to preload sidebar worktrees for '${nameOf( + repository + )}'`, + error + ) + const existing = this.localRepositoryStateLookup.get(repository.id) + if (existing !== undefined) { + this.localRepositoryStateLookup.set( + repository.id, + withSidebarWorktrees(existing, []) + ) + } + } + }) + ) + ) + + this.emitUpdate() + } + private async updateStashEntryCountMetric( repository: Repository, desktopStashEntryCount: number, @@ -4103,10 +4226,10 @@ export class AppStore extends TypedBaseStore { /** * Update the repository sidebar indicator for the repository */ - private async updateSidebarIndicator( + private updateSidebarIndicator( repository: Repository, status: IStatusResult | null - ): Promise { + ): void { const lookup = this.localRepositoryStateLookup if (repository.missing) { @@ -4119,18 +4242,26 @@ export class AppStore extends TypedBaseStore { return } - lookup.set(repository.id, { - aheadBehind: status.branchAheadBehind || null, - changedFilesCount: status.workingDirectory.files.length, - branchName: status.currentBranch || null, - defaultBranchName: repository.defaultBranch, - }) + lookup.set( + repository.id, + createSidebarStateFromStatus( + repository, + status, + lookup.get(repository.id), + this.repositoryStateCache.get(repository).worktreesState.allWorktrees, + this.showWorktreesInSidebar + ) + ) } /** * Refresh indicator in repository list for a specific repository */ private refreshIndicatorForRepository = async (repository: Repository) => { const lookup = this.localRepositoryStateLookup + const sidebarRepository = findSidebarWorktreeStateRepository( + this.repositories, + repository + ) if (repository.missing) { lookup.delete(repository.id) @@ -4150,6 +4281,28 @@ export class AppStore extends TypedBaseStore { return } + if ( + this.showWorktreesInSidebar && + shouldRefreshSidebarWorktrees( + this.lastSidebarWorktreeRefreshAt.get(sidebarRepository.hash) + ) + ) { + const sidebarGitStore = this.gitStoreCache.get(sidebarRepository) + await sidebarGitStore.loadWorktrees() + this.repositoryStateCache.updateWorktreesState(sidebarRepository, () => ({ + allWorktrees: sidebarGitStore.allWorktrees, + currentWorktree: sidebarGitStore.currentWorktree, + })) + const refreshed = lookup.get(sidebarRepository.id) + if (refreshed !== undefined) { + lookup.set( + sidebarRepository.id, + withSidebarWorktrees(refreshed, sidebarGitStore.allWorktrees) + ) + } + this.lastSidebarWorktreeRefreshAt.set(sidebarRepository.hash, Date.now()) + } + this.updateSidebarIndicator(repository, status) this.emitUpdate() @@ -4171,6 +4324,7 @@ export class AppStore extends TypedBaseStore { changedFilesCount: existing?.changedFilesCount ?? 0, branchName: existing?.branchName ?? null, defaultBranchName: existing?.defaultBranchName ?? null, + allWorktrees: existing?.allWorktrees ?? [], }) this.emitUpdate() } @@ -4245,10 +4399,33 @@ export class AppStore extends TypedBaseStore { } setBoolean(showWorktreesKey, showWorktrees) this.showWorktrees = showWorktrees + if (!showWorktrees && this.showWorktreesInSidebar) { + setBoolean(showWorktreesInSidebarKey, false) + this.showWorktreesInSidebar = false + this.lastSidebarWorktreeRefreshAt.clear() + } this.updateResizableConstraints() this.emitUpdate() } + public _setShowWorktreesInSidebar(showWorktreesInSidebar: boolean) { + if (this.showWorktreesInSidebar === showWorktreesInSidebar) { + return + } + + if (showWorktreesInSidebar && !this.showWorktrees) { + return + } + + setBoolean(showWorktreesInSidebarKey, showWorktreesInSidebar) + this.showWorktreesInSidebar = showWorktreesInSidebar + this.lastSidebarWorktreeRefreshAt.clear() + if (showWorktreesInSidebar) { + void this.preloadSidebarWorktrees() + } + this.emitUpdate() + } + public _setShowCompareTab(showCompareTab: boolean) { if (this.showCompareTab === showCompareTab) { return diff --git a/app/src/lib/stores/helpers/sidebar-worktrees.ts b/app/src/lib/stores/helpers/sidebar-worktrees.ts new file mode 100644 index 00000000000..49f68e722da --- /dev/null +++ b/app/src/lib/stores/helpers/sidebar-worktrees.ts @@ -0,0 +1,75 @@ +import { IStatusResult } from '../../git' +import { normalizePath } from '../../helpers/path' +import { ILocalRepositoryState, Repository } from '../../../models/repository' +import { WorktreeEntry } from '../../../models/worktree' + +/** + * Refresh sidebar worktree metadata more sparingly than the repository + * indicator loop to avoid repeatedly shelling out to `git worktree list`. + */ +export const SidebarWorktreeRefreshInterval = 2 * 60 * 1000 + +export function findSidebarWorktreeStateRepository( + repositories: ReadonlyArray, + repository: Repository +) { + if (!repository.isLinkedWorktree) { + return repository + } + + const mainWorktreePath = normalizePath(repository.mainWorktreePath) + return ( + repositories.find( + candidate => normalizePath(candidate.path) === mainWorktreePath + ) ?? repository + ) +} + +export function getCurrentWorktreeEntryForRepository( + allWorktrees: ReadonlyArray, + repository: Repository +) { + return ( + allWorktrees.find( + worktree => + normalizePath(worktree.path) === normalizePath(repository.path) + ) ?? null + ) +} + +export function createSidebarStateFromStatus( + repository: Repository, + status: IStatusResult, + existing: ILocalRepositoryState | undefined, + allWorktrees: ReadonlyArray, + showWorktreesInSidebar: boolean +): ILocalRepositoryState { + return { + aheadBehind: status.branchAheadBehind || null, + changedFilesCount: status.workingDirectory.files.length, + branchName: status.currentBranch || null, + defaultBranchName: repository.defaultBranch, + allWorktrees: showWorktreesInSidebar ? allWorktrees : [], + } +} + +export function withSidebarWorktrees( + existing: ILocalRepositoryState, + allWorktrees: ReadonlyArray +): ILocalRepositoryState { + return { + ...existing, + allWorktrees, + } +} + +export function shouldRefreshSidebarWorktrees( + lastRefreshedAt: number | undefined, + now: number = Date.now() +) { + if (lastRefreshedAt === undefined) { + return true + } + + return now - lastRefreshedAt >= SidebarWorktreeRefreshInterval +} diff --git a/app/src/models/repository.ts b/app/src/models/repository.ts index e855665eb8a..2430b12b7f7 100644 --- a/app/src/models/repository.ts +++ b/app/src/models/repository.ts @@ -2,13 +2,14 @@ import * as Path from 'path' import { GitHubRepository, ForkedGitHubRepository } from './github-repository' import { IAheadBehind } from './branch' +import { WorktreeEntry } from './worktree' import { WorkflowPreferences, ForkContributionTarget, } from './workflow-preferences' import { assertNever, fatalError } from '../lib/fatal-error' import { createEqualityHash } from './equality-hash' -import { isLinkedWorktreeSync } from '../lib/git/worktree' +import { getWorktreePathInfoSync } from '../lib/git/worktree' import { getRemotes } from '../lib/git' import { findDefaultRemote } from '../lib/stores/helpers/find-default-remote' import { isTrustedRemoteHost } from '../lib/api' @@ -56,7 +57,9 @@ export class Repository { */ private _url: string | null = null + private _hasLoadedWorktreeInfo = false private _isLinkedWorktree: boolean | undefined = undefined + private _mainWorktreePath: string | undefined = undefined /** * @param path The working directory of this repository @@ -98,15 +101,29 @@ export class Repository { ) } + private ensureWorktreeInfoLoaded() { + if (this._hasLoadedWorktreeInfo) { + return + } + + const worktreeInfo = getWorktreePathInfoSync(this.path) + this._isLinkedWorktree = worktreeInfo?.isLinkedWorktree ?? false + this._mainWorktreePath = worktreeInfo?.mainWorktreePath ?? this.path + this._hasLoadedWorktreeInfo = true + } + public get path(): string { return this.mainWorkTree.path } public get isLinkedWorktree(): boolean { - if (this._isLinkedWorktree === undefined) { - this._isLinkedWorktree = isLinkedWorktreeSync(this.path) - } - return this._isLinkedWorktree + this.ensureWorktreeInfoLoaded() + return this._isLinkedWorktree ?? false + } + + public get mainWorktreePath(): string { + this.ensureWorktreeInfoLoaded() + return this._mainWorktreePath ?? this.path } public get url(): string | null { @@ -229,6 +246,10 @@ export interface ILocalRepositoryState { * The name of the default branch, or `undefined` if not available. */ readonly defaultBranchName: string | null + /** + * All worktrees known for this repository. + */ + readonly allWorktrees: ReadonlyArray } /** diff --git a/app/src/ui/app.tsx b/app/src/ui/app.tsx index 32236efe1af..92a1ff239a3 100644 --- a/app/src/ui/app.tsx +++ b/app/src/ui/app.tsx @@ -1689,6 +1689,7 @@ export class App extends React.Component { titleBarStyle={this.state.titleBarStyle} showRecentRepositories={this.state.showRecentRepositories} showWorktrees={this.state.showWorktrees} + showWorktreesInSidebar={this.state.showWorktreesInSidebar} showCompareTab={this.state.showCompareTab} repositoryIndicatorsEnabled={this.state.repositoryIndicatorsEnabled} hideWindowOnQuit={this.state.hideWindowOnQuit} @@ -3071,9 +3072,14 @@ export class App extends React.Component { const { useCustomShell, selectedShell } = this.state const filterText = this.state.repositoryFilterText - const repositories = this.state.repositories.filter( - r => !(r instanceof Repository && r.isLinkedWorktree) - ) + const repositories = this.state.showWorktreesInSidebar + ? [...this.state.repositories] + : this.state.repositories.filter( + r => !(r instanceof Repository && r.isLinkedWorktree) + ) + const localRepositoryStateLookup = this.state.showWorktreesInSidebar + ? new Map(this.state.localRepositoryStateLookup) + : this.state.localRepositoryStateLookup return ( { repositories={repositories} recentRepositories={this.state.recentRepositories} showRecentRepositories={this.state.showRecentRepositories} - localRepositoryStateLookup={this.state.localRepositoryStateLookup} + localRepositoryStateLookup={localRepositoryStateLookup} askForConfirmationOnRemoveRepository={ this.state.askForConfirmationOnRepositoryRemoval } @@ -3097,6 +3103,7 @@ export class App extends React.Component { shellLabel={useCustomShell ? undefined : selectedShell} dispatcher={this.props.dispatcher} showBranchNameInRepoList={this.state.showBranchNameInRepoList} + showWorktreesInSidebar={this.state.showWorktreesInSidebar} /> ) } diff --git a/app/src/ui/dispatcher/dispatcher.ts b/app/src/ui/dispatcher/dispatcher.ts index c877b5cd98b..c94500ef788 100644 --- a/app/src/ui/dispatcher/dispatcher.ts +++ b/app/src/ui/dispatcher/dispatcher.ts @@ -2932,6 +2932,10 @@ export class Dispatcher { this.appStore._setShowWorktrees(showWorktrees) } + public setShowWorktreesInSidebar(showWorktreesInSidebar: boolean) { + this.appStore._setShowWorktreesInSidebar(showWorktreesInSidebar) + } + public setShowCompareTab(showCompareTab: boolean) { this.appStore._setShowCompareTab(showCompareTab) } diff --git a/app/src/ui/preferences/appearance.tsx b/app/src/ui/preferences/appearance.tsx index 77cd0d1089d..5170b9ad030 100644 --- a/app/src/ui/preferences/appearance.tsx +++ b/app/src/ui/preferences/appearance.tsx @@ -29,6 +29,8 @@ interface IAppearanceProps { readonly onShowRecentRepositoriesChanged: (show: boolean) => void readonly showWorktrees: boolean readonly onShowWorktreesChanged: (show: boolean) => void + readonly showWorktreesInSidebar: boolean + readonly onShowWorktreesInSidebarChanged: (show: boolean) => void readonly showCompareTab: boolean readonly onShowCompareTabChanged: (show: boolean) => void readonly showBranchNameInRepoList: ShowBranchNameInRepoListSetting @@ -47,6 +49,7 @@ interface IAppearanceState { readonly titleBarStyle: TitleBarStyle readonly showRecentRepositories: boolean readonly showWorktrees: boolean + readonly showWorktreesInSidebar: boolean readonly showCompareTab: boolean } @@ -76,6 +79,7 @@ export class Appearance extends React.Component< titleBarStyle: props.titleBarStyle, showRecentRepositories: props.showRecentRepositories, showWorktrees: props.showWorktrees, + showWorktreesInSidebar: props.showWorktreesInSidebar, showCompareTab: props.showCompareTab, } @@ -85,7 +89,13 @@ export class Appearance extends React.Component< } public async componentDidUpdate(prevProps: IAppearanceProps) { - if (prevProps === this.props) { + if ( + prevProps.selectedTheme === this.props.selectedTheme && + prevProps.selectedTabSize === this.props.selectedTabSize && + prevProps.showWorktrees === this.props.showWorktrees && + prevProps.showWorktreesInSidebar === this.props.showWorktreesInSidebar && + prevProps.showCompareTab === this.props.showCompareTab + ) { return } @@ -99,7 +109,13 @@ export class Appearance extends React.Component< const selectedTabSize = this.props.selectedTabSize - this.setState({ selectedTheme, selectedTabSize }) + this.setState({ + selectedTheme, + selectedTabSize, + showWorktrees: this.props.showWorktrees, + showWorktreesInSidebar: this.props.showWorktreesInSidebar, + showCompareTab: this.props.showCompareTab, + }) } private initializeSelectedTheme = async () => { @@ -124,7 +140,10 @@ export class Appearance extends React.Component< event: React.FormEvent ) => { const show = event.currentTarget.checked - this.setState({ showWorktrees: show }) + this.setState({ + showWorktrees: show, + showWorktreesInSidebar: show ? this.state.showWorktreesInSidebar : false, + }) this.props.onShowWorktreesChanged(show) } @@ -136,6 +155,14 @@ export class Appearance extends React.Component< this.props.onShowCompareTabChanged(show) } + private onShowWorktreesInSidebarChanged = ( + event: React.FormEvent + ) => { + const show = event.currentTarget.checked + this.setState({ showWorktreesInSidebar: show }) + this.props.onShowWorktreesInSidebarChanged(show) + } + private onSelectedTabSizeChanged = ( event: React.FormEvent ) => { @@ -363,6 +390,17 @@ export class Appearance extends React.Component< } onChange={this.onShowWorktreesChanged} /> + {this.state.showWorktrees && ( + + )}

{'Commit list'}

diff --git a/app/src/ui/preferences/preferences.tsx b/app/src/ui/preferences/preferences.tsx index 92554346062..9400b6cf986 100644 --- a/app/src/ui/preferences/preferences.tsx +++ b/app/src/ui/preferences/preferences.tsx @@ -101,6 +101,7 @@ interface IPreferencesProps { readonly titleBarStyle: TitleBarStyle readonly showRecentRepositories: boolean readonly showWorktrees: boolean + readonly showWorktreesInSidebar: boolean readonly showCompareTab: boolean readonly repositoryIndicatorsEnabled: boolean readonly showBranchNameInRepoList: ShowBranchNameInRepoListSetting @@ -151,6 +152,7 @@ interface IPreferencesState { readonly titleBarStyle: TitleBarStyle readonly showRecentRepositories: boolean readonly showWorktrees: boolean + readonly showWorktreesInSidebar: boolean readonly showCompareTab: boolean /** * If unable to save Git configuration values (name, email) @@ -241,6 +243,7 @@ export class Preferences extends React.Component< titleBarStyle: this.props.titleBarStyle, showRecentRepositories: this.props.showRecentRepositories, showWorktrees: this.props.showWorktrees, + showWorktreesInSidebar: this.props.showWorktreesInSidebar, showCompareTab: this.props.showCompareTab, repositoryIndicatorsEnabled: this.props.repositoryIndicatorsEnabled, showBranchNameInRepoList: this.props.showBranchNameInRepoList, @@ -593,6 +596,10 @@ export class Preferences extends React.Component< } showWorktrees={this.state.showWorktrees} onShowWorktreesChanged={this.onShowWorktreesChanged} + showWorktreesInSidebar={this.state.showWorktreesInSidebar} + onShowWorktreesInSidebarChanged={ + this.onShowWorktreesInSidebarChanged + } showCompareTab={this.state.showCompareTab} onShowCompareTabChanged={this.onShowCompareTabChanged} showBranchNameInRepoList={this.state.showBranchNameInRepoList} @@ -893,7 +900,18 @@ export class Preferences extends React.Component< } private onShowWorktreesChanged = (showWorktrees: boolean) => { - this.setState({ showWorktrees }) + this.setState(state => ({ + showWorktrees, + showWorktreesInSidebar: showWorktrees + ? state.showWorktreesInSidebar + : false, + })) + } + + private onShowWorktreesInSidebarChanged = ( + showWorktreesInSidebar: boolean + ) => { + this.setState({ showWorktreesInSidebar }) } private onShowCompareTabChanged = (showCompareTab: boolean) => { @@ -981,6 +999,12 @@ export class Preferences extends React.Component< dispatcher.setShowWorktrees(this.state.showWorktrees) } + if ( + this.state.showWorktreesInSidebar !== this.props.showWorktreesInSidebar + ) { + dispatcher.setShowWorktreesInSidebar(this.state.showWorktreesInSidebar) + } + if (this.state.showCompareTab !== this.props.showCompareTab) { dispatcher.setShowCompareTab(this.state.showCompareTab) } diff --git a/app/src/ui/repositories-list/group-repositories.ts b/app/src/ui/repositories-list/group-repositories.ts index d2d4d4d83a8..9bf342fd918 100644 --- a/app/src/ui/repositories-list/group-repositories.ts +++ b/app/src/ui/repositories-list/group-repositories.ts @@ -1,18 +1,22 @@ import { Repository, ILocalRepositoryState, - nameOf, isRepositoryWithGitHubRepository, RepositoryWithGitHubRepository, } from '../../models/repository' import { CloningRepository } from '../../models/cloning-repository' import { getHTMLURL } from '../../lib/api' -import { caseInsensitiveCompare, compare } from '../../lib/compare' +import { compare } from '../../lib/compare' import { IFilterListGroup, IFilterListItem } from '../lib/filter-list' import { IAheadBehind } from '../../models/branch' import { assertNever } from '../../lib/fatal-error' import { isGHE, isGHES } from '../../lib/endpoint-capabilities' import { Owner } from '../../models/owner' +import { normalizePath } from '../../lib/helpers/path' +import { + getRepositoryListTitle, + toSortedRepositoryListItems, +} from './worktree-list-items' export type RepositoryListGroup = ( | { @@ -57,12 +61,23 @@ export type Repositoryish = Repository | CloningRepository export interface IRepositoryListItem extends IFilterListItem { readonly text: ReadonlyArray readonly id: string + readonly title: string readonly repository: Repositoryish readonly needsDisambiguation: boolean readonly aheadBehind: IAheadBehind | null readonly changedFilesCount: number readonly branchName: string | null readonly defaultBranchName: string | null + readonly isNestedWorktree: boolean + readonly mainWorktreeName: string | null + readonly isVirtualLinkedWorktree: boolean + readonly isPrunableWorktree: boolean + readonly worktreePath: string | null + readonly sourceRepository: Repository | null +} + +interface IGroupRepositoriesOptions { + readonly showWorktreesInSidebar?: boolean } const recentRepositoriesThreshold = 7 @@ -97,11 +112,24 @@ type RepoGroupItem = { group: RepositoryListGroup; repos: Repositoryish[] } export function groupRepositories( repositories: ReadonlyArray, localRepositoryStateLookup: ReadonlyMap, - recentRepositories: ReadonlyArray + recentRepositories: ReadonlyArray, + options: IGroupRepositoriesOptions = {} ): ReadonlyArray> { const includeRecentGroup = repositories.length > recentRepositoriesThreshold const recentSet = includeRecentGroup ? new Set(recentRepositories) : undefined const groups = new Map() + const repositoryByPath = new Map() + const storedRepositoryPaths = new Set() + + for (const repository of repositories) { + if (!(repository instanceof Repository)) { + continue + } + + const normalizedPath = normalizePath(repository.path) + repositoryByPath.set(normalizedPath, repository) + storedRepositoryPaths.add(normalizedPath) + } const addToGroup = (group: RepositoryListGroup, repo: Repositoryish) => { const key = getGroupKey(group) @@ -130,22 +158,24 @@ export function groupRepositories( group, repos, localRepositoryStateLookup, - groups + groups, + repositoryByPath, + storedRepositoryPaths, + options ), })) } -// Returns the display title for a repository, which is either the alias -// (if available) or the name. -const getDisplayTitle = (r: Repositoryish) => - r instanceof Repository && r.alias != null ? r.alias : r.name - const toSortedListItems = ( group: RepositoryListGroup, repositories: ReadonlyArray, localRepositoryStateLookup: ReadonlyMap, - groups: Map + groups: Map, + repositoryByPath: ReadonlyMap, + storedRepositoryPaths: ReadonlySet, + options: IGroupRepositoriesOptions ): IRepositoryListItem[] => { + const showWorktreesInSidebar = options.showWorktreesInSidebar ?? false const groupNames = new Map() const allNames = new Map() @@ -156,39 +186,23 @@ const toSortedListItems = ( continue } - for (const title of groupItem.repos.map(getDisplayTitle)) { + for (const title of groupItem.repos.map(repo => + getRepositoryListTitle(repo, showWorktreesInSidebar) + )) { allNames.set(title, (allNames.get(title) ?? 0) + 1) if (groupItem.group === group) { groupNames.set(title, (groupNames.get(title) ?? 0) + 1) } } } - - return repositories - .map(r => { - const repoState = localRepositoryStateLookup.get(r.id) - const title = getDisplayTitle(r) - - return { - text: r instanceof Repository ? [title, nameOf(r)] : [title], - id: r.id.toString(), - repository: r, - needsDisambiguation: - // If the repository is in the enterprise group and has a duplicate - // name in the group, we need to disambiguate it. We don't have to - // disambiguate repositories in the 'dotcom' group because they are - // already grouped by owner. If the repository is in the 'recent' - // group and has a duplicate name in any group, we need to - // disambiguate it. - ((groupNames.get(title) ?? 0) > 1 && group.kind === 'enterprise') || - ((allNames.get(title) ?? 0) > 1 && group.kind === 'recent'), - aheadBehind: repoState?.aheadBehind ?? null, - changedFilesCount: repoState?.changedFilesCount ?? 0, - branchName: repoState?.branchName ?? null, - defaultBranchName: repoState?.defaultBranchName ?? null, - } - }) - .sort(({ repository: x }, { repository: y }) => - caseInsensitiveCompare(getDisplayTitle(x), getDisplayTitle(y)) - ) + return toSortedRepositoryListItems({ + group, + repositories, + localRepositoryStateLookup, + groupNames, + allNames, + repositoryByPath, + storedRepositoryPaths, + showWorktreesInSidebar, + }) } diff --git a/app/src/ui/repositories-list/repositories-list.tsx b/app/src/ui/repositories-list/repositories-list.tsx index 256a4785036..121995a9524 100644 --- a/app/src/ui/repositories-list/repositories-list.tsx +++ b/app/src/ui/repositories-list/repositories-list.tsx @@ -27,6 +27,11 @@ import { SectionFilterList } from '../lib/section-filter-list' import { assertNever } from '../../lib/fatal-error' import { IAheadBehind } from '../../models/branch' import { ShowBranchNameInRepoListSetting } from '../../models/show-branch-name-in-repo-list' +import { normalizePath } from '../../lib/helpers/path' +import { ClickSource } from '../lib/list' +import { getRepositoryType } from '../../lib/git/rev-parse' +import { FoldoutType } from '../../lib/app-state' +import { pruneWorktrees } from '../../lib/git/worktree' const BlankSlateImage = encodePathAsUrl(__dirname, 'static/empty-no-repo.svg') @@ -82,6 +87,7 @@ interface IRepositoriesListProps { /** Controls when to show the branch name next to each repository */ readonly showBranchNameInRepoList: ShowBranchNameInRepoListSetting + readonly showWorktreesInSidebar: boolean } interface IRepositoriesListState { @@ -103,9 +109,14 @@ function findMatchingListItem( selectedRepository: Repositoryish | null ) { if (selectedRepository !== null) { + const selectedPath = normalizePath(selectedRepository.path) for (const group of groups) { for (const item of group.items) { - if (item.repository.id === selectedRepository.id) { + if ( + item.repository.id === selectedRepository.id || + (item.worktreePath !== null && + normalizePath(item.worktreePath) === selectedPath) + ) { return item } } @@ -115,6 +126,36 @@ function findMatchingListItem( return null } +function isPullableRepository( + repository: Repositoryish, + repositories: ReadonlyArray +): repository is Repository { + if (!(repository instanceof Repository)) { + return false + } + + if (!repository.isLinkedWorktree) { + return true + } + + const mainWorktreePath = normalizePath(repository.mainWorktreePath) + const candidatesWithSameMain = repositories.filter( + (candidate): candidate is Repository => + candidate instanceof Repository && + normalizePath(candidate.mainWorktreePath) === mainWorktreePath + ) + + if (candidatesWithSameMain.length === 0) { + return false + } + + const preferred = + candidatesWithSameMain.find(candidate => !candidate.isLinkedWorktree) ?? + candidatesWithSameMain[0] + + return preferred.id === repository.id +} + /** The list of user-added repositories. */ export class RepositoriesList extends React.Component< IRepositoriesListProps, @@ -130,14 +171,16 @@ export class RepositoriesList extends React.Component< ( repositories: ReadonlyArray | null, localRepositoryStateLookup: ReadonlyMap, - recentRepositories: ReadonlyArray + recentRepositories: ReadonlyArray, + showWorktreesInSidebar: boolean ) => repositories === null ? [] : groupRepositories( repositories, localRepositoryStateLookup, - recentRepositories + recentRepositories, + { showWorktreesInSidebar } ) ) @@ -183,13 +226,17 @@ export class RepositoriesList extends React.Component< const repository = item.repository return ( ) } @@ -228,6 +275,9 @@ export class RepositoriesList extends React.Component< const uncommittedChangesTooltip = hasChanges ? `There are uncommitted changes in this repository.` : null + const prunableWorktreeTooltip = item.isPrunableWorktree + ? 'This worktree entry is stale and should be pruned.' + : null const ahead = aheadBehind?.ahead ?? 0 const behind = aheadBehind?.behind ?? 0 @@ -270,6 +320,16 @@ export class RepositoriesList extends React.Component< {uncommittedChangesTooltip}
)} + {prunableWorktreeTooltip && ( +
+
+ + + +
+ {prunableWorktreeTooltip} +
+ )} ) } @@ -310,7 +370,31 @@ export class RepositoriesList extends React.Component< ) } - private onItemClick = (item: IRepositoryListItem) => { + private onItemClick = (item: IRepositoryListItem, source: ClickSource) => { + if ( + source.kind === 'mouseclick' && + (source.event.button === 2 || + (__DARWIN__ && source.event.button === 0 && source.event.ctrlKey)) + ) { + return + } + + if (item.isPrunableWorktree) { + void this.props.dispatcher.postError( + new Error( + 'This worktree entry is stale. Use the context menu to prune stale worktrees.' + ) + ) + return + } + + if (item.isVirtualLinkedWorktree && item.worktreePath !== null) { + void this.onVirtualWorktreeClick(item).catch(error => + this.props.dispatcher.postError(error) + ) + return + } + const hasIndicator = item.changedFilesCount > 0 || (item.aheadBehind !== null @@ -320,20 +404,94 @@ export class RepositoriesList extends React.Component< this.props.onSelectionChanged(item.repository) } + private onVirtualWorktreeClick = async (item: IRepositoryListItem) => { + if ( + item.worktreePath === null || + item.sourceRepository === null || + !(item.repository instanceof Repository) + ) { + return + } + + const { worktreePath } = item + const existingRepo = this.props.repositories.find( + r => + r instanceof Repository && + normalizePath(r.path) === normalizePath(worktreePath) + ) + + if (existingRepo instanceof Repository) { + await this.props.dispatcher.selectRepository(existingRepo) + await this.props.dispatcher.closeFoldout(FoldoutType.Repository) + return + } + + const repositoryType = await getRepositoryType(worktreePath) + if (repositoryType.kind !== 'regular') { + throw new Error(`${worktreePath} isn't a Git repository.`) + } + + await this.props.dispatcher.selectRepository(item.repository, false) + await this.props.dispatcher.closeFoldout(FoldoutType.Repository) + } + + private onRemoveLinkedWorktree = (item: IRepositoryListItem) => { + const worktreePath = + item.worktreePath ?? + (item.repository instanceof Repository ? item.repository.path : null) + if (worktreePath === null) { + return + } + + const repository = + item.isVirtualLinkedWorktree && item.sourceRepository !== null + ? item.sourceRepository + : item.repository instanceof Repository + ? item.repository + : null + + if (repository === null) { + return + } + + this.props.dispatcher.showPopup({ + type: PopupType.DeleteWorktree, + repository, + worktreePath, + }) + } + + private onPruneStaleWorktrees = async (item: IRepositoryListItem) => { + const repository = + item.sourceRepository ?? + (item.repository instanceof Repository ? item.repository : null) + + if (repository === null) { + return + } + + await pruneWorktrees(repository) + await this.props.dispatcher.refreshRepository(repository) + } + private onItemContextMenu = ( item: IRepositoryListItem, event: React.MouseEvent ) => { event.preventDefault() + event.stopPropagation() const items = generateRepositoryListContextMenu({ onRemoveRepository: this.props.onRemoveRepository, + onRemoveLinkedWorktree: () => this.onRemoveLinkedWorktree(item), onShowRepository: this.props.onShowRepository, onOpenInNewWindow: this.props.onOpenInNewWindow, onOpenInShell: this.props.onOpenInShell, onOpenInExternalEditor: this.props.onOpenInExternalEditor, askForConfirmationOnRemoveRepository: this.props.askForConfirmationOnRemoveRepository, + isLinkedWorktreeRow: item.isVirtualLinkedWorktree, + isPrunableWorktreeRow: item.isPrunableWorktree, externalEditorLabel: this.props.externalEditorLabel, onChangeRepositoryAlias: this.onChangeRepositoryAlias, onRemoveRepositoryAlias: this.onRemoveRepositoryAlias, @@ -343,6 +501,11 @@ export class RepositoriesList extends React.Component< repository: item.repository, shellLabel: this.props.shellLabel, onCopyRepoPath: path => this.props.dispatcher.copyPathToClipboard(path), + onPruneStaleWorktrees: () => { + void this.onPruneStaleWorktrees(item).catch(error => + this.props.dispatcher.postError(error) + ) + }, }) showContextualMenu(items) @@ -362,7 +525,8 @@ export class RepositoriesList extends React.Component< let groups = this.getRepositoryGroups( this.props.repositories, this.props.localRepositoryStateLookup, - this.props.recentRepositories + this.props.recentRepositories, + this.props.showWorktreesInSidebar ) if (!this.props.showRecentRepositories) { @@ -506,14 +670,20 @@ export class RepositoriesList extends React.Component< private onPullRepositoriesButtonClick = async () => { this.setState({ pullingRepositories: true }) try { + const repositoriesToPull = this.props.repositories.filter(repository => + isPullableRepository(repository, this.props.repositories) + ) + await Promise.all( - this.props.repositories - .filter(r => r instanceof Repository) - .map(r => - this.props.dispatcher.pull(r).catch(e => { - throw Error(`Error pulling '${r.name}' (${r.path}):\n${e}`, e) - }) - ) + repositoriesToPull.map(repository => + this.props.dispatcher.pull(repository).catch(e => { + const message = e instanceof Error ? e.message : String(e) + throw new Error( + `Error pulling '${repository.name}' (${repository.path}): ${message}`, + { cause: e } + ) + }) + ) ) } catch (e) { this.props.dispatcher.postError(e) diff --git a/app/src/ui/repositories-list/repository-list-item-context-menu.ts b/app/src/ui/repositories-list/repository-list-item-context-menu.ts index e0b2dd41fd0..eee459b4043 100644 --- a/app/src/ui/repositories-list/repository-list-item-context-menu.ts +++ b/app/src/ui/repositories-list/repository-list-item-context-menu.ts @@ -18,12 +18,16 @@ interface IRepositoryListItemContextMenuConfig { shellLabel: string | undefined externalEditorLabel: string | undefined askForConfirmationOnRemoveRepository: boolean + readonly isLinkedWorktreeRow?: boolean + readonly isPrunableWorktreeRow?: boolean onViewInBrowser: (repository: Repositoryish) => void onOpenInNewWindow?: (repository: Repositoryish) => void onOpenInShell: (repository: Repositoryish) => void onShowRepository: (repository: Repositoryish) => void onOpenInExternalEditor: (repository: Repositoryish) => void onRemoveRepository: (repository: Repositoryish) => void + onRemoveLinkedWorktree?: () => void + onPruneStaleWorktrees?: () => void onChangeRepositoryAlias: (repository: Repository) => void onRemoveRepositoryAlias: (repository: Repository) => void onChangeRepositoryGroupName: (repository: Repository) => void @@ -35,6 +39,11 @@ export const generateRepositoryListContextMenu = ( config: IRepositoryListItemContextMenuConfig ) => { const { repository } = config + const isLinkedWorktreeRow = config.isLinkedWorktreeRow ?? false + const isPrunableWorktreeRow = config.isPrunableWorktreeRow ?? false + const aliasMenuItems = buildAliasMenuItems(config) + const groupNameMenuItems = buildGroupNameMenuItems(config) + const identityMenuItems = [...aliasMenuItems, ...groupNameMenuItems] const missing = repository instanceof Repository && repository.missing const isGitHub = repository instanceof Repository && @@ -51,9 +60,8 @@ export const generateRepositoryListContextMenu = ( : DefaultShellLabel const items: ReadonlyArray = [ - ...buildAliasMenuItems(config), - ...buildGroupNameMenuItems(config), - { type: 'separator' }, + ...identityMenuItems, + ...(identityMenuItems.length > 0 ? [{ type: 'separator' as const }] : []), { label: __DARWIN__ ? 'Copy Repo Name' : 'Copy repo name', action: () => clipboard.writeText(repository.name), @@ -95,11 +103,39 @@ export const generateRepositoryListContextMenu = ( action: () => config.onOpenInExternalEditor(repository), enabled: !missing, }, - { type: 'separator' }, - { - label: config.askForConfirmationOnRemoveRepository ? 'Remove…' : 'Remove', - action: () => config.onRemoveRepository(repository), - }, + ...(isPrunableWorktreeRow && config.onPruneStaleWorktrees !== undefined + ? [ + { type: 'separator' as const }, + { + label: __DARWIN__ + ? 'Prune Stale Worktrees' + : 'Prune stale worktrees', + action: config.onPruneStaleWorktrees, + }, + ] + : []), + ...(!(isPrunableWorktreeRow && isLinkedWorktreeRow) + ? [ + { type: 'separator' as const }, + { + label: isPrunableWorktreeRow + ? config.askForConfirmationOnRemoveRepository + ? 'Remove…' + : 'Remove' + : isLinkedWorktreeRow + ? 'Delete…' + : config.askForConfirmationOnRemoveRepository + ? 'Remove…' + : 'Remove', + action: + !isPrunableWorktreeRow && + isLinkedWorktreeRow && + config.onRemoveLinkedWorktree !== undefined + ? config.onRemoveLinkedWorktree + : () => config.onRemoveRepository(repository), + }, + ] + : []), ] return items @@ -123,7 +159,7 @@ const buildAliasMenuItems = ( ): ReadonlyArray => { const { repository } = config - if (!(repository instanceof Repository)) { + if (!(repository instanceof Repository) || config.isLinkedWorktreeRow) { return [] } @@ -150,7 +186,7 @@ const buildGroupNameMenuItems = ( ): ReadonlyArray => { const { repository } = config - if (!(repository instanceof Repository)) { + if (!(repository instanceof Repository) || config.isLinkedWorktreeRow) { return [] } diff --git a/app/src/ui/repositories-list/repository-list-item.tsx b/app/src/ui/repositories-list/repository-list-item.tsx index ed6adf7d4bb..2fbf964ebac 100644 --- a/app/src/ui/repositories-list/repository-list-item.tsx +++ b/app/src/ui/repositories-list/repository-list-item.tsx @@ -14,6 +14,7 @@ import { enableAccessibleListToolTips } from '../../lib/feature-flag' import { TooltippedContent } from '../lib/tooltipped-content' interface IRepositoryListItemProps { + readonly title: string readonly repository: Repositoryish /** Does the repository need to be disambiguated in the list? */ @@ -30,6 +31,9 @@ interface IRepositoryListItemProps { /** The name of the current branch, if it should be displayed */ readonly branchName: string | null + readonly isNestedWorktree: boolean + readonly mainWorktreeName: string | null + readonly isPrunableWorktree: boolean } /** A repository item. */ @@ -44,6 +48,9 @@ export class RepositoryListItem extends React.Component< const gitHubRepo = repository instanceof Repository ? repository.gitHubRepository : null const hasChanges = this.props.changedFilesCount > 0 + const icon = this.props.isNestedWorktree + ? octicons.fileDirectory + : iconForRepository(repository) const alias: string | null = repository instanceof Repository ? repository.alias : null @@ -54,11 +61,19 @@ export class RepositoryListItem extends React.Component< } const classNameList = classNames('name', { - alias: alias !== null, + alias: + alias !== null && + !this.props.isNestedWorktree && + this.props.title === alias, }) return ( -
+
- + -
+
{prefix ? {prefix} : null}
@@ -85,7 +97,7 @@ export class RepositoryListItem extends React.Component< {this.props.branchName} )} - + {this.props.isPrunableWorktree && renderPrunableIndicator()} {repository instanceof Repository && renderRepoIndicators({ aheadBehind: this.props.aheadBehind, @@ -109,6 +121,12 @@ export class RepositoryListItem extends React.Component<
{repo.path}
{this.props.branchName &&
Branch: {this.props.branchName}
} + {this.props.mainWorktreeName && ( +
Repository: {this.props.mainWorktreeName}
+ )} + {this.props.isPrunableWorktree && ( +
This worktree entry is stale and should be pruned.
+ )} ) } @@ -121,7 +139,14 @@ export class RepositoryListItem extends React.Component< return ( nextProps.repository.id !== this.props.repository.id || nextProps.matches !== this.props.matches || - nextProps.branchName !== this.props.branchName + nextProps.title !== this.props.title || + nextProps.needsDisambiguation !== this.props.needsDisambiguation || + nextProps.branchName !== this.props.branchName || + nextProps.aheadBehind !== this.props.aheadBehind || + nextProps.changedFilesCount !== this.props.changedFilesCount || + nextProps.isNestedWorktree !== this.props.isNestedWorktree || + nextProps.mainWorktreeName !== this.props.mainWorktreeName || + nextProps.isPrunableWorktree !== this.props.isPrunableWorktree ) } else { return true @@ -179,5 +204,17 @@ const renderChangesIndicator = () => { ) } +const renderPrunableIndicator = () => { + return ( + + + + ) +} + export const commitGrammar = (commitNum: number) => `${commitNum} commit${commitNum > 1 ? 's' : ''}` // english is hard diff --git a/app/src/ui/repositories-list/worktree-list-items.ts b/app/src/ui/repositories-list/worktree-list-items.ts new file mode 100644 index 00000000000..e57559440e0 --- /dev/null +++ b/app/src/ui/repositories-list/worktree-list-items.ts @@ -0,0 +1,325 @@ +import * as Path from 'path' + +import { + Repository, + ILocalRepositoryState, + nameOf, +} from '../../models/repository' +import { caseInsensitiveCompare } from '../../lib/compare' +import { normalizePath } from '../../lib/helpers/path' +import type { IAheadBehind } from '../../models/branch' +import type { WorktreeEntry } from '../../models/worktree' +import type { + IRepositoryListItem, + RepositoryListGroup, + Repositoryish, +} from './group-repositories' + +let nextVirtualRepositoryId = -1 +const virtualRepositoryIdsByPath = new Map() + +export const getDisplayTitle = (repository: Repositoryish) => + repository instanceof Repository && repository.alias != null + ? repository.alias + : repository.name + +export const getRepositoryListTitle = ( + repository: Repositoryish, + showWorktreesInSidebar: boolean +) => + showWorktreesInSidebar && + repository instanceof Repository && + repository.isLinkedWorktree + ? Path.basename(repository.path) + : getDisplayTitle(repository) + +const getVirtualRepositoryId = (worktreePath: string) => { + const normalizedPath = normalizePath(worktreePath) + const existingId = virtualRepositoryIdsByPath.get(normalizedPath) + if (existingId !== undefined) { + return existingId + } + + const id = nextVirtualRepositoryId-- + virtualRepositoryIdsByPath.set(normalizedPath, id) + return id +} + +const pruneVirtualRepositoryIds = ( + storedRepositoryPaths: ReadonlySet, + localRepositoryStateLookup: ReadonlyMap +) => { + const knownWorktreePaths = new Set(storedRepositoryPaths) + + for (const state of localRepositoryStateLookup.values()) { + for (const worktree of state.allWorktrees) { + knownWorktreePaths.add(normalizePath(worktree.path)) + } + } + + for (const worktreePath of virtualRepositoryIdsByPath.keys()) { + if (!knownWorktreePaths.has(worktreePath)) { + virtualRepositoryIdsByPath.delete(worktreePath) + } + } +} + +const getBranchNameForWorktree = (worktree: WorktreeEntry) => + worktree.branch?.replace(/^refs\/heads\//, '') ?? null + +const getWorktreeEntryForPath = ( + allWorktrees: ReadonlyArray, + worktreePath: string +) => + allWorktrees.find( + worktree => normalizePath(worktree.path) === normalizePath(worktreePath) + ) ?? null + +interface IToListItemOptions { + readonly isVirtualLinkedWorktree?: boolean + readonly worktreePath?: string + readonly sourceRepository?: Repository | null + readonly branchName?: string | null + readonly changedFilesCount?: number + readonly aheadBehind?: IAheadBehind | null +} + +interface IToSortedListItemsOptions { + readonly group: RepositoryListGroup + readonly repositories: ReadonlyArray + readonly localRepositoryStateLookup: ReadonlyMap< + number, + ILocalRepositoryState + > + readonly groupNames: ReadonlyMap + readonly allNames: ReadonlyMap + readonly repositoryByPath: ReadonlyMap + readonly storedRepositoryPaths: ReadonlySet + readonly showWorktreesInSidebar: boolean +} + +export function toSortedRepositoryListItems({ + group, + repositories, + localRepositoryStateLookup, + groupNames, + allNames, + repositoryByPath, + storedRepositoryPaths, + showWorktreesInSidebar, +}: IToSortedListItemsOptions): IRepositoryListItem[] { + pruneVirtualRepositoryIds(storedRepositoryPaths, localRepositoryStateLookup) + + const toListItem = ( + repository: Repositoryish, + isNestedWorktree: boolean, + options?: IToListItemOptions + ): IRepositoryListItem => { + const repoState = localRepositoryStateLookup.get(repository.id) + const isVirtualLinkedWorktree = options?.isVirtualLinkedWorktree ?? false + const isLinkedWorktree = + !isVirtualLinkedWorktree && + repository instanceof Repository && + repository.isLinkedWorktree + const worktreePath = options?.worktreePath ?? repository.path + const parentRepository = + options?.sourceRepository ?? + (repository instanceof Repository && isLinkedWorktree + ? repositoryByPath.get(normalizePath(repository.mainWorktreePath)) ?? + null + : null) + const parentRepoState = + parentRepository !== null + ? localRepositoryStateLookup.get(parentRepository.id) + : null + const startupWorktreeEntry = + (isLinkedWorktree || isVirtualLinkedWorktree) && parentRepoState != null + ? getWorktreeEntryForPath(parentRepoState.allWorktrees, worktreePath) + : null + const title = + isLinkedWorktree || isVirtualLinkedWorktree + ? Path.basename(worktreePath) + : getDisplayTitle(repository) + const defaultBranchName = + repoState?.defaultBranchName ?? + options?.sourceRepository?.defaultBranch ?? + (repository instanceof Repository ? repository.defaultBranch : null) + const mainWorktreePath = + isVirtualLinkedWorktree && options?.sourceRepository != null + ? options.sourceRepository.mainWorktreePath + : repository instanceof Repository + ? repository.mainWorktreePath + : options?.sourceRepository?.mainWorktreePath ?? repository.path + const mainWorktreeName = + (isLinkedWorktree || isVirtualLinkedWorktree) && isNestedWorktree + ? Path.basename(mainWorktreePath) + : null + + return { + text: + repository instanceof Repository + ? isLinkedWorktree || isVirtualLinkedWorktree + ? [title, nameOf(repository), Path.basename(mainWorktreePath)] + : [title, nameOf(repository)] + : [title], + title, + id: options?.worktreePath + ? `worktree:${normalizePath(options.worktreePath)}` + : repository.id.toString(), + repository, + needsDisambiguation: + ((groupNames.get(title) ?? 0) > 1 && group.kind === 'enterprise') || + ((allNames.get(title) ?? 0) > 1 && group.kind === 'recent'), + aheadBehind: options?.aheadBehind ?? repoState?.aheadBehind ?? null, + changedFilesCount: + options?.changedFilesCount ?? repoState?.changedFilesCount ?? 0, + branchName: + options?.branchName ?? + repoState?.branchName ?? + (startupWorktreeEntry + ? getBranchNameForWorktree(startupWorktreeEntry) + : null), + defaultBranchName, + isNestedWorktree, + mainWorktreeName, + isVirtualLinkedWorktree, + isPrunableWorktree: startupWorktreeEntry?.isPrunable ?? false, + worktreePath: options?.worktreePath ?? null, + sourceRepository: options?.sourceRepository ?? parentRepository, + } + } + + const appendVirtualWorktreeItems = ( + items: IRepositoryListItem[], + repository: Repository, + sourceRepository: Repository, + emittedVirtualPaths: Set + ) => { + const repoState = localRepositoryStateLookup.get(repository.id) + const allWorktrees = repoState?.allWorktrees ?? [] + const excludedPaths = new Set([ + ...storedRepositoryPaths, + ...emittedVirtualPaths, + normalizePath(repository.path), + ]) + const virtualWorktrees = allWorktrees + .filter( + worktree => + worktree.type === 'linked' && + !excludedPaths.has(normalizePath(worktree.path)) + ) + .sort((x, y) => + caseInsensitiveCompare(Path.basename(x.path), Path.basename(y.path)) + ) + + for (const worktree of virtualWorktrees) { + const virtualRepositoryPath = normalizePath(worktree.path) + const virtualRepository = new Repository( + worktree.path, + getVirtualRepositoryId(virtualRepositoryPath), + sourceRepository.gitHubRepository, + false, + null, + sourceRepository.groupName, + sourceRepository.defaultBranch, + sourceRepository.workflowPreferences, + sourceRepository.customEditorOverride, + sourceRepository.isTutorialRepository, + sourceRepository.overrideLogin + ) + + items.push( + toListItem(virtualRepository, true, { + isVirtualLinkedWorktree: true, + worktreePath: worktree.path, + sourceRepository, + branchName: getBranchNameForWorktree(worktree), + changedFilesCount: 0, + aheadBehind: null, + }) + ) + emittedVirtualPaths.add(virtualRepositoryPath) + } + } + + const sortedRepositories = [...repositories].sort((x, y) => + caseInsensitiveCompare( + getRepositoryListTitle(x, showWorktreesInSidebar), + getRepositoryListTitle(y, showWorktreesInSidebar) + ) + ) + + if (!showWorktreesInSidebar || group.kind === 'recent') { + return sortedRepositories.map(repository => toListItem(repository, false)) + } + + const mainRepos: Repositoryish[] = [] + const orphanLinkedRepos: Repository[] = [] + const linkedReposByParentPath = new Map() + + for (const repository of sortedRepositories) { + if (!(repository instanceof Repository) || !repository.isLinkedWorktree) { + mainRepos.push(repository) + continue + } + + const parentPath = normalizePath(repository.mainWorktreePath) + const linkedRepos = linkedReposByParentPath.get(parentPath) + if (linkedRepos !== undefined) { + linkedRepos.push(repository) + } else { + linkedReposByParentPath.set(parentPath, [repository]) + } + } + + const items: IRepositoryListItem[] = [] + const seenLinkedRepoIds = new Set() + const emittedVirtualPaths = new Set() + + for (const repository of mainRepos) { + items.push(toListItem(repository, false)) + + if (!(repository instanceof Repository)) { + continue + } + + const linkedRepos = linkedReposByParentPath.get( + normalizePath(repository.path) + ) + if (linkedRepos !== undefined) { + for (const linkedRepo of linkedRepos) { + seenLinkedRepoIds.add(linkedRepo.id) + items.push(toListItem(linkedRepo, true)) + } + } + + appendVirtualWorktreeItems( + items, + repository, + repository, + emittedVirtualPaths + ) + } + + for (const repository of sortedRepositories) { + if ( + repository instanceof Repository && + repository.isLinkedWorktree && + !seenLinkedRepoIds.has(repository.id) + ) { + orphanLinkedRepos.push(repository) + } + } + + for (const repository of orphanLinkedRepos) { + items.push(toListItem(repository, false)) + appendVirtualWorktreeItems( + items, + repository, + repository, + emittedVirtualPaths + ) + } + + return items +} diff --git a/app/styles/ui/_repository-list.scss b/app/styles/ui/_repository-list.scss index aa7dcf31ab1..10d66848223 100644 --- a/app/styles/ui/_repository-list.scss +++ b/app/styles/ui/_repository-list.scss @@ -34,6 +34,10 @@ // name and truncate accordingly width: 100%; + &.nested-worktree { + padding-left: calc(var(--spacing) + 20px); + } + .icon-for-repository { // Some room between the icon and repository name margin-right: var(--spacing-half); @@ -88,6 +92,13 @@ vertical-align: text-bottom; } } + + .worktrees-loading { + color: var(--text-secondary-color); + font-size: var(--font-size-sm); + margin-left: var(--spacing); + flex-shrink: 0; + } } .filter-list-group-header { @@ -216,6 +227,19 @@ } } + .prunable-indicator-wrapper { + display: flex; + min-width: 12px; + justify-content: center; + align-items: center; + margin-left: var(--spacing-half); + + .octicon { + color: var(--toolbar-dropdown-text-warning-color); + width: auto; + } + } + .ahead-behind { height: 16px; background: var(--list-item-badge-background-color); diff --git a/app/test/unit/repositories-list-grouping-test.ts b/app/test/unit/repositories-list-grouping-test.ts index 2de41d25bed..de9230ca13d 100644 --- a/app/test/unit/repositories-list-grouping-test.ts +++ b/app/test/unit/repositories-list-grouping-test.ts @@ -1,9 +1,13 @@ import { describe, it } from 'node:test' import assert from 'node:assert' +import * as os from 'node:os' +import * as path from 'node:path' +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' import { groupRepositories } from '../../src/ui/repositories-list/group-repositories' import { Repository, ILocalRepositoryState } from '../../src/models/repository' import { CloningRepository } from '../../src/models/cloning-repository' import { gitHubRepoFixture } from '../helpers/github-repo-builder' +import { WorktreeEntry } from '../../src/models/worktree' describe('repository list grouping', () => { const repositories: Array = [ @@ -153,4 +157,237 @@ describe('repository list grouping', () => { assert.equal(grouped[2].items[1].text[0], 'enterprise-repo') assert(grouped[2].items[1].needsDisambiguation) }) + + it('nests linked worktrees under their main repository in non-recent groups', async () => { + const tempRoot = await mkdtemp( + path.join(os.tmpdir(), 'github-desktop-plus-worktree-grouping-') + ) + try { + const mainRepoPath = path.join(tempRoot, 'repo') + const linkedRepoPath = path.join(tempRoot, 'repo-feature-worktree') + + await mkdir(path.join(mainRepoPath, '.git'), { recursive: true }) + await mkdir(path.join(mainRepoPath, '.git', 'worktrees', 'fix-node'), { + recursive: true, + }) + await mkdir(linkedRepoPath, { recursive: true }) + await writeFile( + path.join(linkedRepoPath, '.git'), + 'gitdir: ../repo/.git/worktrees/fix-node\n' + ) + await writeFile( + path.join(mainRepoPath, '.git', 'worktrees', 'fix-node', 'commondir'), + '../..\n' + ) + + const mainRepo = new Repository( + mainRepoPath, + 1, + gitHubRepoFixture({ owner: 'example', name: 'repo' }), + false + ) + const linkedRepo = new Repository( + linkedRepoPath, + 2, + gitHubRepoFixture({ owner: 'example', name: 'repo' }), + false + ) + + const grouped = groupRepositories([linkedRepo, mainRepo], cache, [], { + showWorktreesInSidebar: true, + }) + + assert.equal(grouped.length, 1) + assert.equal(grouped[0].items.length, 2) + assert.equal(grouped[0].items[0].repository.path, mainRepoPath) + assert.equal(grouped[0].items[0].isNestedWorktree, false) + assert.equal(grouped[0].items[1].repository.path, linkedRepoPath) + assert.equal(grouped[0].items[1].isNestedWorktree, true) + assert.equal(grouped[0].items[1].mainWorktreeName, 'repo') + } finally { + await rm(tempRoot, { recursive: true, force: true }) + } + }) + + it('shows unstored linked worktrees under their main repository', async () => { + const tempRoot = await mkdtemp( + path.join(os.tmpdir(), 'github-desktop-plus-worktree-virtual-') + ) + try { + const mainRepoPath = path.join(tempRoot, 'repo') + const unstoredWorktreePath = path.join(tempRoot, 'repo-feature-a') + const mainRepo = new Repository( + mainRepoPath, + 10, + gitHubRepoFixture({ owner: 'example', name: 'repo' }), + false + ) + + const allWorktrees: ReadonlyArray = [ + { + path: mainRepoPath, + head: 'a', + branch: 'refs/heads/main', + isDetached: false, + type: 'main', + isLocked: false, + isPrunable: false, + }, + { + path: unstoredWorktreePath, + head: 'b', + branch: 'refs/heads/feature/a', + isDetached: false, + type: 'linked', + isLocked: false, + isPrunable: false, + }, + ] + + cache.set(mainRepo.id, { + aheadBehind: null, + changedFilesCount: 0, + branchName: 'main', + defaultBranchName: 'main', + allWorktrees, + }) + + const grouped = groupRepositories([mainRepo], cache, [], { + showWorktreesInSidebar: true, + }) + + assert.equal(grouped.length, 1) + assert.equal(grouped[0].items.length, 2) + assert.equal(grouped[0].items[0].repository.path, mainRepoPath) + assert.equal(grouped[0].items[1].worktreePath, unstoredWorktreePath) + assert.equal(grouped[0].items[1].isVirtualLinkedWorktree, true) + assert.equal(grouped[0].items[1].text[0], 'repo-feature-a') + assert.equal(grouped[0].items[1].branchName, 'feature/a') + } finally { + await rm(tempRoot, { recursive: true, force: true }) + } + }) + + it('marks prunable linked worktrees in the sidebar item model', () => { + const mainRepo = new Repository( + '/tmp/repo', + 1, + gitHubRepoFixture({ owner: 'example', name: 'repo' }), + false + ) + + const cache = new Map() + cache.set(mainRepo.id, { + changedFilesCount: 0, + aheadBehind: null, + branchName: 'main', + defaultBranchName: 'main', + allWorktrees: [ + { + path: '/tmp/repo', + head: 'abc', + branch: 'refs/heads/main', + isDetached: false, + type: 'main', + isLocked: false, + isPrunable: false, + }, + { + path: '/tmp/repo-stale', + head: 'def', + branch: 'refs/heads/feature/stale', + isDetached: false, + type: 'linked', + isLocked: false, + isPrunable: true, + }, + ], + }) + + const grouped = groupRepositories([mainRepo], cache, [], { + showWorktreesInSidebar: true, + }) + + assert.equal(grouped.length, 1) + assert.equal(grouped[0].items.length, 2) + assert.equal(grouped[0].items[1].isPrunableWorktree, true) + }) + + it('uses parent preloaded worktree data for stored linked worktree branch names', async () => { + const tempRoot = await mkdtemp( + path.join(os.tmpdir(), 'github-desktop-plus-worktree-branch-fallback-') + ) + try { + const mainRepoPath = path.join(tempRoot, 'repo') + const linkedRepoPath = path.join(tempRoot, 'repo-feature-a') + + await mkdir(path.join(mainRepoPath, '.git'), { recursive: true }) + await mkdir(path.join(mainRepoPath, '.git', 'worktrees', 'feature-a'), { + recursive: true, + }) + await mkdir(linkedRepoPath, { recursive: true }) + await writeFile( + path.join(linkedRepoPath, '.git'), + 'gitdir: ../repo/.git/worktrees/feature-a\n' + ) + await writeFile( + path.join(mainRepoPath, '.git', 'worktrees', 'feature-a', 'commondir'), + '../..\n' + ) + + const mainRepo = new Repository( + mainRepoPath, + 12, + gitHubRepoFixture({ owner: 'example', name: 'repo' }), + false + ) + const linkedRepo = new Repository( + linkedRepoPath, + 13, + gitHubRepoFixture({ owner: 'example', name: 'repo' }), + false + ) + + const allWorktrees: ReadonlyArray = [ + { + path: mainRepoPath, + head: 'a', + branch: 'refs/heads/main', + isDetached: false, + type: 'main', + isLocked: false, + isPrunable: false, + }, + { + path: linkedRepoPath, + head: 'b', + branch: 'refs/heads/feature/a', + isDetached: false, + type: 'linked', + isLocked: false, + isPrunable: false, + }, + ] + + cache.set(mainRepo.id, { + aheadBehind: null, + changedFilesCount: 0, + branchName: 'main', + defaultBranchName: 'main', + allWorktrees, + }) + + const grouped = groupRepositories([linkedRepo, mainRepo], cache, [], { + showWorktreesInSidebar: true, + }) + + assert.equal(grouped.length, 1) + assert.equal(grouped[0].items.length, 2) + assert.equal(grouped[0].items[1].repository.path, linkedRepoPath) + assert.equal(grouped[0].items[1].isNestedWorktree, true) + assert.equal(grouped[0].items[1].branchName, 'feature/a') + } finally { + await rm(tempRoot, { recursive: true, force: true }) + } + }) }) diff --git a/app/test/unit/repository-list-item-context-menu-test.ts b/app/test/unit/repository-list-item-context-menu-test.ts new file mode 100644 index 00000000000..00fe4854907 --- /dev/null +++ b/app/test/unit/repository-list-item-context-menu-test.ts @@ -0,0 +1,157 @@ +import assert from 'node:assert' +import { describe, it } from 'node:test' + +import { Repository } from '../../src/models/repository' +import { generateRepositoryListContextMenu } from '../../src/ui/repositories-list/repository-list-item-context-menu' +import { gitHubRepoFixture } from '../helpers/github-repo-builder' + +describe('repository list item context menu', () => { + const buildConfig = ( + overrides: Partial< + Parameters[0] + > = {} + ) => { + const repository = + overrides.repository ?? + new Repository( + '/tmp/repo', + 1, + gitHubRepoFixture({ owner: 'example', name: 'repo' }), + false, + 'alias', + 'group' + ) + + return { + repository, + shellLabel: undefined, + externalEditorLabel: undefined, + askForConfirmationOnRemoveRepository: true, + onViewInBrowser: () => {}, + onOpenInNewWindow: () => {}, + onOpenInShell: () => {}, + onShowRepository: () => {}, + onOpenInExternalEditor: () => {}, + onRemoveRepository: () => {}, + onChangeRepositoryAlias: () => {}, + onRemoveRepositoryAlias: () => {}, + onChangeRepositoryGroupName: () => {}, + onRemoveRepositoryGroupName: () => {}, + onCopyRepoPath: () => {}, + ...overrides, + } + } + + it('shows alias and group name actions for normal repository rows', () => { + const items = generateRepositoryListContextMenu(buildConfig()) + const labels = items.flatMap(item => ('label' in item ? [item.label] : [])) + + assert(labels.includes('Change alias') || labels.includes('Change Alias')) + assert(labels.includes('Remove alias') || labels.includes('Remove Alias')) + assert( + labels.includes('Change group name') || + labels.includes('Change Group Name') + ) + assert( + labels.includes('Restore group name') || + labels.includes('Restore Group Name') + ) + assert(labels.includes('Remove…')) + }) + + it('hides alias and group name actions for linked worktree rows and deletes the worktree', () => { + let removedRepository = false + let removedLinkedWorktree = false + + const items = generateRepositoryListContextMenu( + buildConfig({ + isLinkedWorktreeRow: true, + onRemoveRepository: () => { + removedRepository = true + }, + onRemoveLinkedWorktree: () => { + removedLinkedWorktree = true + }, + }) + ) + const labels = items.flatMap(item => ('label' in item ? [item.label] : [])) + + assert(!labels.includes('Change alias')) + assert(!labels.includes('Change Alias')) + assert(!labels.includes('Remove alias')) + assert(!labels.includes('Remove Alias')) + assert(!labels.includes('Change group name')) + assert(!labels.includes('Change Group Name')) + assert(!labels.includes('Restore group name')) + assert(!labels.includes('Restore Group Name')) + assert(labels.includes('Delete…')) + + const deleteItem = items.find( + (item): item is { label: string; action: () => void } => + 'label' in item && item.label === 'Delete…' + ) + assert(deleteItem !== undefined) + + deleteItem.action() + + assert.equal(removedLinkedWorktree, true) + assert.equal(removedRepository, false) + }) + + it('shows a prune action for stale worktree rows and keeps remove semantics', () => { + let prunedStaleWorktrees = false + let removedRepository = false + + const items = generateRepositoryListContextMenu( + buildConfig({ + isPrunableWorktreeRow: true, + onPruneStaleWorktrees: () => { + prunedStaleWorktrees = true + }, + onRemoveRepository: () => { + removedRepository = true + }, + }) + ) + const labels = items.flatMap(item => ('label' in item ? [item.label] : [])) + + assert( + labels.includes('Prune stale worktrees') || + labels.includes('Prune Stale Worktrees') + ) + assert(labels.includes('Remove…')) + assert(!labels.includes('Delete…')) + + const pruneItem = items.find( + (item): item is { label: string; action: () => void } => + 'label' in item && + (item.label === 'Prune stale worktrees' || + item.label === 'Prune Stale Worktrees') + ) + assert(pruneItem !== undefined) + + pruneItem.action() + + assert.equal(prunedStaleWorktrees, true) + assert.equal(removedRepository, false) + }) + + it('shows only prune for stale virtual worktree rows', () => { + const items = generateRepositoryListContextMenu( + buildConfig({ + isLinkedWorktreeRow: true, + isPrunableWorktreeRow: true, + onPruneStaleWorktrees: () => {}, + }) + ) + const labels = items.flatMap(item => ('label' in item ? [item.label] : [])) + + assert.equal('type' in items[0] && items[0].type === 'separator', false) + assert( + labels.includes('Prune stale worktrees') || + labels.includes('Prune Stale Worktrees') + ) + assert(!labels.includes('Delete…')) + assert(!labels.includes('Remove…')) + }) +}) From f0b2324a0c0cb517b79061c45c9794a6377bd74b Mon Sep 17 00:00:00 2001 From: Ignat Remizov Date: Thu, 2 Apr 2026 18:19:03 +0300 Subject: [PATCH 2/5] fix(sidebar): avoid stash metric failures for transient synthetic worktree rows Synthetic sidebar worktree rows are transient `Repository` objects created only for sidebar navigation and they are not persisted in `repositories-store`. Stash metric collection still assumed every selected repository existed in the store, which caused fatal lookup failures when a synthetic row became active. Changes: - short-circuit stash-check read/write helpers for transient repositories with negative ids instead of querying the repositories database - keep persisted repositories on the existing stash metric path unchanged - add a repositories-store regression test covering synthetic sidebar rows so transient worktree selection cannot reintroduce the fatal lookup path Behavioral effect: Selecting a synthetic sidebar worktree row no longer throws `getLastStashCheckDate` errors or leaves the app in an inconsistent state just because the row is transient and not saved in Desktop's repository store. Testing: - yarn test:unit app/test/unit/repositories-store-test.ts - yarn eslint app/src/lib/stores/repositories-store.ts app/test/unit/repositories-store-test.ts - yarn compile:prod --- app/src/lib/stores/repositories-store.ts | 12 ++++++++++++ app/test/unit/repositories-store-test.ts | 24 +++++++++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/app/src/lib/stores/repositories-store.ts b/app/src/lib/stores/repositories-store.ts index eb4a98e49f5..9d4d598b05b 100644 --- a/app/src/lib/stores/repositories-store.ts +++ b/app/src/lib/stores/repositories-store.ts @@ -479,6 +479,12 @@ export class RepositoriesStore extends TypedBaseStore< repository: Repository, date: number = Date.now() ): Promise { + // Synthetic sidebar-only worktree rows are transient repositories that + // are not persisted in the repositories store. + if (repository.id < 0) { + return + } + await this.db.repositories.update(repository.id, { lastStashCheckDate: date, }) @@ -496,6 +502,12 @@ export class RepositoriesStore extends TypedBaseStore< public async getLastStashCheckDate( repository: Repository ): Promise { + // Synthetic sidebar-only worktree rows are transient repositories that + // are not persisted in the repositories store. + if (repository.id < 0) { + return null + } + let lastCheckDate = this.lastStashCheckCache.get(repository.id) || null if (lastCheckDate !== null) { return lastCheckDate diff --git a/app/test/unit/repositories-store-test.ts b/app/test/unit/repositories-store-test.ts index 5fa00ce517f..a54af384a27 100644 --- a/app/test/unit/repositories-store-test.ts +++ b/app/test/unit/repositories-store-test.ts @@ -3,7 +3,11 @@ import assert from 'node:assert' import { RepositoriesStore } from '../../src/lib/stores/repositories-store' import { TestRepositoriesDatabase } from '../helpers/databases' import { IAPIFullRepository, getDotComAPIEndpoint } from '../../src/lib/api' -import { assertIsRepositoryWithGitHubRepository } from '../../src/models/repository' +import { + assertIsRepositoryWithGitHubRepository, + Repository, +} from '../../src/models/repository' +import { gitHubRepoFixture } from '../helpers/github-repo-builder' describe('RepositoriesStore', () => { let repoDb = new TestRepositoriesDatabase() @@ -97,4 +101,22 @@ describe('RepositoriesStore', () => { ) }) }) + + describe('stash check tracking', () => { + it('ignores transient synthetic repositories', async () => { + const syntheticRepo = new Repository( + '/tmp/repo-feature-a', + -1, + gitHubRepoFixture({ owner: 'example', name: 'repo' }), + false + ) + + assert.equal( + await repositoriesStore.getLastStashCheckDate(syntheticRepo), + null + ) + + await repositoriesStore.updateLastStashCheckDate(syntheticRepo) + }) + }) }) From 43a2ddc825e28e9ef91493e139d517e5cd332217 Mon Sep 17 00:00:00 2001 From: Ignat Remizov Date: Thu, 2 Apr 2026 22:11:39 +0300 Subject: [PATCH 3/5] fix(sidebar): let sidebar parent rows open the main worktree explicitly Upstream now restores the last selected linked worktree when reselecting a repository, which is useful for normal repo switching but conflicts with the sidebar parent-row interaction. After visiting a synthetic child row, clicking the parent row should open the parent repository itself instead of bouncing back into the remembered worktree. Changes: - add an explicit `followPreferredWorktree` selection flag through dispatcher and app-store repository selection paths - keep the upstream preferred-worktree restore behavior enabled by default for existing selection flows - make repository sidebar row clicks opt out of preferred-worktree restoration so an explicit parent-row click opens the main worktree Behavioral effect: When a user clicks a repository parent row in the sidebar after visiting a linked worktree, the app now opens the parent repository directly instead of restoring the previously selected child worktree behind the user's back. Testing: - yarn eslint app/src/ui/dispatcher/dispatcher.ts app/src/lib/stores/app-store.ts app/src/ui/app.tsx - yarn compile:prod --- app/src/lib/stores/app-store.ts | 5 +++-- app/src/ui/app.tsx | 2 +- app/src/ui/dispatcher/dispatcher.ts | 9 +++++++-- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/app/src/lib/stores/app-store.ts b/app/src/lib/stores/app-store.ts index 02b59e362b2..032770ff61a 100644 --- a/app/src/lib/stores/app-store.ts +++ b/app/src/lib/stores/app-store.ts @@ -2176,7 +2176,8 @@ export class AppStore extends TypedBaseStore { /** This shouldn't be called directly. See `Dispatcher`. */ public async _selectRepository( repository: Repository | CloningRepository | null, - persistSelection: boolean = true + persistSelection: boolean = true, + followPreferredWorktree: boolean = true ): Promise { const previouslySelectedRepository = this.selectedRepository @@ -2212,7 +2213,7 @@ export class AppStore extends TypedBaseStore { // When returning to a repository that has worktrees, restore the // previously active linked worktree so the user doesn't always land // on the main worktree after switching repos. - if (!repository.isLinkedWorktree) { + if (followPreferredWorktree && !repository.isLinkedWorktree) { const repoPath = normalizePath(repository.path) const preferredPath = getPreferredWorktreePath(repoPath) diff --git a/app/src/ui/app.tsx b/app/src/ui/app.tsx index 92a1ff239a3..e90d9c353fc 100644 --- a/app/src/ui/app.tsx +++ b/app/src/ui/app.tsx @@ -3827,7 +3827,7 @@ export class App extends React.Component { } private onSelectionChanged = (repository: Repository | CloningRepository) => { - this.props.dispatcher.selectRepository(repository) + this.props.dispatcher.selectRepository(repository, true, false) this.props.dispatcher.closeFoldout(FoldoutType.Repository) } diff --git a/app/src/ui/dispatcher/dispatcher.ts b/app/src/ui/dispatcher/dispatcher.ts index c94500ef788..010e187f218 100644 --- a/app/src/ui/dispatcher/dispatcher.ts +++ b/app/src/ui/dispatcher/dispatcher.ts @@ -314,9 +314,14 @@ export class Dispatcher { /** Select the repository. */ public selectRepository( repository: Repository | CloningRepository, - persistSelection: boolean = true + persistSelection: boolean = true, + followPreferredWorktree: boolean = true ): Promise { - return this.appStore._selectRepository(repository, persistSelection) + return this.appStore._selectRepository( + repository, + persistSelection, + followPreferredWorktree + ) } /** Change the selected section in the repository. */ From 7aae8cf35215ca8621793ccfe1c49b376f01685f Mon Sep 17 00:00:00 2001 From: Ignat Remizov Date: Fri, 3 Apr 2026 16:17:12 +0300 Subject: [PATCH 4/5] fix(sidebar): unify linked worktree grouping and transient selection behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tighten the follow-up linked-worktree behavior so saved linked worktrees stay attached to their main repository, remain leaf rows, and do not lose selection state when synthetic sidebar rows are active. Changes: - keep linked worktree rows as leaf nodes so only main worktrees own nested children in the sidebar - use `Delete…` for both saved and synthetic linked worktree rows, while removing group-name actions from saved linked worktree rows - ensure deleting a saved linked worktree also removes its Desktop repository entry so the stored list stays in sync with Git worktree deletion - inherit the main repository's GitHub association when a linked worktree is added through the local repository flow and its main worktree is already known to Desktop - keep saved linked worktrees in the same top-level group as their main repository after restart instead of dropping them into `Other` - preserve transient synthetic worktree selections across repository-store updates so selecting a worktree from another repository family does not snap back to the previously selected saved repository - keep alias support for saved linked worktrees while treating synthetic rows as transient sidebar-only entries - retain the regression coverage for orphan linked rows staying flat and for the linked-worktree context menu behavior Behavioral effect: Linked worktrees now behave consistently as leaf rows whether they are saved or synthetic. Saved linked worktrees stay grouped under their real main repository, deleting a saved linked worktree updates both Git and Desktop state, and synthetic worktree selection no longer jumps back to an unrelated repository when the saved repository list refreshes. Testing: - yarn test:unit app/test/unit/repositories-list-grouping-test.ts - yarn test:unit app/test/unit/repository-list-item-context-menu-test.ts - yarn eslint app/src/ui/repositories-list/worktree-list-items.ts app/src/ui/repositories-list/repository-list-item-context-menu.ts app/src/ui/repositories-list/repositories-list.tsx app/src/ui/worktrees/delete-worktree-dialog.tsx app/src/ui/app.tsx app/src/models/popup.ts app/src/lib/stores/app-store.ts app/test/unit/repositories-list-grouping-test.ts app/test/unit/repository-list-item-context-menu-test.ts - yarn compile:prod --- app/src/lib/stores/app-store.ts | 35 +++++++- app/src/models/popup.ts | 2 + app/src/ui/app.tsx | 2 + .../repositories-list/repositories-list.tsx | 15 +++- .../repository-list-item-context-menu.ts | 12 ++- .../repositories-list/worktree-list-items.ts | 18 ++-- .../ui/worktrees/delete-worktree-dialog.tsx | 18 +++- .../unit/repositories-list-grouping-test.ts | 88 +++++++++++++++++++ .../repository-list-item-context-menu-test.ts | 22 +++++ 9 files changed, 195 insertions(+), 17 deletions(-) diff --git a/app/src/lib/stores/app-store.ts b/app/src/lib/stores/app-store.ts index 032770ff61a..9e52c62a13b 100644 --- a/app/src/lib/stores/app-store.ts +++ b/app/src/lib/stores/app-store.ts @@ -3033,7 +3033,20 @@ export class AppStore extends TypedBaseStore { r.id === selectedRepository.id ) || null - newSelectedRepository = r + if (r !== null) { + newSelectedRepository = r + } else if ( + selectedRepository instanceof Repository && + selectedRepository.id < 0 + ) { + // Synthetic sidebar-only worktree rows are transient selections and + // won't exist in the saved repositories list. Preserve the current + // selection across repository store updates instead of falling back to + // the previously selected saved repository. + newSelectedRepository = selectedRepository + } else { + newSelectedRepository = null + } } if (newSelectedRepository === null && this.repositories.length > 0) { @@ -7419,11 +7432,29 @@ export class AppStore extends TypedBaseStore { continue } - const addedRepo = await this.repositoriesStore.addRepository( + let addedRepo = await this.repositoriesStore.addRepository( validatedPath, login ) + // When a linked worktree is added as a standalone repository and the + // main worktree is already known to Desktop, inherit that GitHub + // association up front so the saved row lands in the same top-level + // group after restart. + const mainWorktreeRepo = addedRepo.isLinkedWorktree + ? matchExistingRepository(repositories, addedRepo.mainWorktreePath) + : undefined + + if ( + mainWorktreeRepo !== undefined && + isRepositoryWithGitHubRepository(mainWorktreeRepo) + ) { + addedRepo = await this.repositoriesStore.setGitHubRepository( + addedRepo, + mainWorktreeRepo.gitHubRepository + ) + } + // initialize the remotes for this new repository to ensure it can fetch // it's GitHub-related details using the GitHub API (if applicable) const gitStore = this.gitStoreCache.get(addedRepo) diff --git a/app/src/models/popup.ts b/app/src/models/popup.ts index 549d6bfb737..50a25c18f54 100644 --- a/app/src/models/popup.ts +++ b/app/src/models/popup.ts @@ -513,5 +513,7 @@ export type PopupDetail = type: PopupType.DeleteWorktree repository: Repository worktreePath: string + storedRepositoryToRemove?: Repository + isDeletingCurrentWorktree?: boolean } export type Popup = IBasePopup & PopupDetail diff --git a/app/src/ui/app.tsx b/app/src/ui/app.tsx index e90d9c353fc..d134e83e3f9 100644 --- a/app/src/ui/app.tsx +++ b/app/src/ui/app.tsx @@ -2763,6 +2763,8 @@ export class App extends React.Component { key="delete-worktree" repository={popup.repository} worktreePath={popup.worktreePath} + storedRepositoryToRemove={popup.storedRepositoryToRemove} + isDeletingCurrentWorktree={popup.isDeletingCurrentWorktree} dispatcher={this.props.dispatcher} onDismissed={onPopupDismissedFn} /> diff --git a/app/src/ui/repositories-list/repositories-list.tsx b/app/src/ui/repositories-list/repositories-list.tsx index 121995a9524..c396f5140c2 100644 --- a/app/src/ui/repositories-list/repositories-list.tsx +++ b/app/src/ui/repositories-list/repositories-list.tsx @@ -449,6 +449,10 @@ export class RepositoriesList extends React.Component< : item.repository instanceof Repository ? item.repository : null + const storedRepositoryToRemove = + item.repository instanceof Repository && !item.isVirtualLinkedWorktree + ? item.repository + : undefined if (repository === null) { return @@ -458,6 +462,11 @@ export class RepositoriesList extends React.Component< type: PopupType.DeleteWorktree, repository, worktreePath, + storedRepositoryToRemove, + isDeletingCurrentWorktree: + this.props.selectedRepository !== null && + normalizePath(this.props.selectedRepository.path) === + normalizePath(worktreePath), }) } @@ -490,7 +499,11 @@ export class RepositoriesList extends React.Component< onOpenInExternalEditor: this.props.onOpenInExternalEditor, askForConfirmationOnRemoveRepository: this.props.askForConfirmationOnRemoveRepository, - isLinkedWorktreeRow: item.isVirtualLinkedWorktree, + isLinkedWorktreeRow: + item.isVirtualLinkedWorktree || + (item.repository instanceof Repository && + item.repository.isLinkedWorktree), + isVirtualLinkedWorktreeRow: item.isVirtualLinkedWorktree, isPrunableWorktreeRow: item.isPrunableWorktree, externalEditorLabel: this.props.externalEditorLabel, onChangeRepositoryAlias: this.onChangeRepositoryAlias, diff --git a/app/src/ui/repositories-list/repository-list-item-context-menu.ts b/app/src/ui/repositories-list/repository-list-item-context-menu.ts index eee459b4043..d212e46c3da 100644 --- a/app/src/ui/repositories-list/repository-list-item-context-menu.ts +++ b/app/src/ui/repositories-list/repository-list-item-context-menu.ts @@ -19,6 +19,7 @@ interface IRepositoryListItemContextMenuConfig { externalEditorLabel: string | undefined askForConfirmationOnRemoveRepository: boolean readonly isLinkedWorktreeRow?: boolean + readonly isVirtualLinkedWorktreeRow?: boolean readonly isPrunableWorktreeRow?: boolean onViewInBrowser: (repository: Repositoryish) => void onOpenInNewWindow?: (repository: Repositoryish) => void @@ -159,7 +160,10 @@ const buildAliasMenuItems = ( ): ReadonlyArray => { const { repository } = config - if (!(repository instanceof Repository) || config.isLinkedWorktreeRow) { + if ( + !(repository instanceof Repository) || + config.isVirtualLinkedWorktreeRow + ) { return [] } @@ -186,7 +190,11 @@ const buildGroupNameMenuItems = ( ): ReadonlyArray => { const { repository } = config - if (!(repository instanceof Repository) || config.isLinkedWorktreeRow) { + if ( + !(repository instanceof Repository) || + config.isLinkedWorktreeRow || + config.isVirtualLinkedWorktreeRow + ) { return [] } diff --git a/app/src/ui/repositories-list/worktree-list-items.ts b/app/src/ui/repositories-list/worktree-list-items.ts index e57559440e0..e0eb32dbc31 100644 --- a/app/src/ui/repositories-list/worktree-list-items.ts +++ b/app/src/ui/repositories-list/worktree-list-items.ts @@ -23,6 +23,14 @@ export const getDisplayTitle = (repository: Repositoryish) => ? repository.alias : repository.name +const getLinkedWorktreeDisplayTitle = ( + repository: Repositoryish, + worktreePath?: string +) => + repository instanceof Repository && repository.alias != null + ? repository.alias + : Path.basename(worktreePath ?? repository.path) + export const getRepositoryListTitle = ( repository: Repositoryish, showWorktreesInSidebar: boolean @@ -30,7 +38,7 @@ export const getRepositoryListTitle = ( showWorktreesInSidebar && repository instanceof Repository && repository.isLinkedWorktree - ? Path.basename(repository.path) + ? getLinkedWorktreeDisplayTitle(repository) : getDisplayTitle(repository) const getVirtualRepositoryId = (worktreePath: string) => { @@ -138,7 +146,7 @@ export function toSortedRepositoryListItems({ : null const title = isLinkedWorktree || isVirtualLinkedWorktree - ? Path.basename(worktreePath) + ? getLinkedWorktreeDisplayTitle(repository, worktreePath) : getDisplayTitle(repository) const defaultBranchName = repoState?.defaultBranchName ?? @@ -313,12 +321,6 @@ export function toSortedRepositoryListItems({ for (const repository of orphanLinkedRepos) { items.push(toListItem(repository, false)) - appendVirtualWorktreeItems( - items, - repository, - repository, - emittedVirtualPaths - ) } return items diff --git a/app/src/ui/worktrees/delete-worktree-dialog.tsx b/app/src/ui/worktrees/delete-worktree-dialog.tsx index d30c2ccd239..933886a2a21 100644 --- a/app/src/ui/worktrees/delete-worktree-dialog.tsx +++ b/app/src/ui/worktrees/delete-worktree-dialog.tsx @@ -16,6 +16,8 @@ import { interface IDeleteWorktreeDialogProps { readonly repository: Repository readonly worktreePath: string + readonly storedRepositoryToRemove?: Repository + readonly isDeletingCurrentWorktree?: boolean readonly dispatcher: Dispatcher readonly onDismissed: () => void } @@ -66,9 +68,13 @@ export class DeleteWorktreeDialog extends React.Component< private onDeleteWorktree = async () => { this.setState({ isDeleting: true }) - const { repository, worktreePath, dispatcher } = this.props - const isDeletingCurrentWorktree = - normalizePath(repository.path) === normalizePath(worktreePath) + const { + repository, + worktreePath, + dispatcher, + storedRepositoryToRemove, + isDeletingCurrentWorktree = false, + } = this.props const mainPathForCleanup = await getMainWorktreePath(repository) @@ -94,9 +100,13 @@ export class DeleteWorktreeDialog extends React.Component< const mainRepo = addedRepos[0] await dispatcher.selectRepository(mainRepo) await removeWorktree(mainRepo, worktreePath) - await dispatcher.removeRepository(repository, false) } else { await removeWorktree(repository, worktreePath) + } + + if (storedRepositoryToRemove !== undefined) { + await dispatcher.removeRepository(storedRepositoryToRemove, false) + } else if (!isDeletingCurrentWorktree) { await dispatcher.refreshRepository(repository) } } catch (e) { diff --git a/app/test/unit/repositories-list-grouping-test.ts b/app/test/unit/repositories-list-grouping-test.ts index de9230ca13d..90c6087e31d 100644 --- a/app/test/unit/repositories-list-grouping-test.ts +++ b/app/test/unit/repositories-list-grouping-test.ts @@ -390,4 +390,92 @@ describe('repository list grouping', () => { await rm(tempRoot, { recursive: true, force: true }) } }) + + it('does not synthesize linked worktree siblings under orphan linked worktrees', async () => { + const tempRoot = await mkdtemp( + path.join(os.tmpdir(), 'github-desktop-plus-worktree-orphan-leaf-') + ) + try { + const mainRepoPath = path.join(tempRoot, 'repo') + const linkedRepoPath = path.join(tempRoot, 'repo-feature-a') + const secondLinkedRepoPath = path.join(tempRoot, 'repo-feature-b') + + await mkdir(path.join(mainRepoPath, '.git'), { recursive: true }) + await mkdir(path.join(mainRepoPath, '.git', 'worktrees', 'feature-a'), { + recursive: true, + }) + await mkdir(path.join(mainRepoPath, '.git', 'worktrees', 'feature-b'), { + recursive: true, + }) + await mkdir(linkedRepoPath, { recursive: true }) + await writeFile( + path.join(linkedRepoPath, '.git'), + 'gitdir: ../repo/.git/worktrees/feature-a\n' + ) + await writeFile( + path.join(mainRepoPath, '.git', 'worktrees', 'feature-a', 'commondir'), + '../..\n' + ) + await writeFile( + path.join(mainRepoPath, '.git', 'worktrees', 'feature-b', 'commondir'), + '../..\n' + ) + + const linkedRepo = new Repository( + linkedRepoPath, + 20, + gitHubRepoFixture({ owner: 'example', name: 'repo' }), + false, + 'custom alias' + ) + + cache.set(linkedRepo.id, { + aheadBehind: null, + changedFilesCount: 0, + branchName: 'feature/a', + defaultBranchName: 'main', + allWorktrees: [ + { + path: mainRepoPath, + head: 'a', + branch: 'refs/heads/main', + isDetached: false, + type: 'main', + isLocked: false, + isPrunable: false, + }, + { + path: linkedRepoPath, + head: 'b', + branch: 'refs/heads/feature/a', + isDetached: false, + type: 'linked', + isLocked: false, + isPrunable: false, + }, + { + path: secondLinkedRepoPath, + head: 'c', + branch: 'refs/heads/feature/b', + isDetached: false, + type: 'linked', + isLocked: false, + isPrunable: false, + }, + ], + }) + + const grouped = groupRepositories([linkedRepo], cache, [], { + showWorktreesInSidebar: true, + }) + + assert.equal(grouped.length, 1) + assert.equal(grouped[0].items.length, 1) + assert.equal(grouped[0].items[0].repository.path, linkedRepoPath) + assert.equal(grouped[0].items[0].isNestedWorktree, false) + assert.equal(grouped[0].items[0].title, 'custom alias') + } finally { + await rm(tempRoot, { recursive: true, force: true }) + } + }) }) diff --git a/app/test/unit/repository-list-item-context-menu-test.ts b/app/test/unit/repository-list-item-context-menu-test.ts index 00fe4854907..a8dd9c7fe22 100644 --- a/app/test/unit/repository-list-item-context-menu-test.ts +++ b/app/test/unit/repository-list-item-context-menu-test.ts @@ -66,6 +66,7 @@ describe('repository list item context menu', () => { const items = generateRepositoryListContextMenu( buildConfig({ isLinkedWorktreeRow: true, + isVirtualLinkedWorktreeRow: true, onRemoveRepository: () => { removedRepository = true }, @@ -98,6 +99,26 @@ describe('repository list item context menu', () => { assert.equal(removedRepository, false) }) + it('keeps alias and group name actions for saved linked worktree rows', () => { + const items = generateRepositoryListContextMenu( + buildConfig({ + isLinkedWorktreeRow: true, + isVirtualLinkedWorktreeRow: false, + onRemoveLinkedWorktree: () => {}, + }) + ) + const labels = items.flatMap(item => ('label' in item ? [item.label] : [])) + + assert(labels.includes('Change alias') || labels.includes('Change Alias')) + assert(labels.includes('Remove alias') || labels.includes('Remove Alias')) + assert(!labels.includes('Change group name')) + assert(!labels.includes('Change Group Name')) + assert(!labels.includes('Restore group name')) + assert(!labels.includes('Restore Group Name')) + assert(labels.includes('Delete…')) + assert(!labels.includes('Remove…')) + }) + it('shows a prune action for stale worktree rows and keeps remove semantics', () => { let prunedStaleWorktrees = false let removedRepository = false @@ -140,6 +161,7 @@ describe('repository list item context menu', () => { const items = generateRepositoryListContextMenu( buildConfig({ isLinkedWorktreeRow: true, + isVirtualLinkedWorktreeRow: true, isPrunableWorktreeRow: true, onPruneStaleWorktrees: () => {}, }) From cf8bcb609250b40ff8d2393fb03e01c75f2b4118 Mon Sep 17 00:00:00 2001 From: Ignat Remizov Date: Fri, 3 Apr 2026 16:30:14 +0300 Subject: [PATCH 5/5] fix(add-repository): make local repository dialog submission reliable Tighten the Add Local Repository dialog so choosing a valid repository path and immediately pressing Add consistently runs the add flow instead of silently stalling in the dialog. Changes: - resolve the entered local path before submit-time validation so `validatePath(...)` uses the same normalized path handling as the live `onPathChanged(...)` validation path - wait for the folder-picker path to finish landing in component state before allowing the chosen path to be used for submission - keep the rest of the add-existing-repository flow unchanged so successful submissions still dismiss the dialog, add the repository, and select it Behavioral effect: The Add Local Repository dialog no longer ends up in a no-op state where a repository path appears valid in the picker flow but submit-time validation never reaches `_addRepositories(...)`. Choosing a worktree path from the folder picker and immediately pressing Add now reliably adds the repository. Testing: - yarn eslint app/src/ui/add-repository/add-existing-repository.tsx - yarn compile:prod --- app/src/ui/add-repository/add-existing-repository.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/src/ui/add-repository/add-existing-repository.tsx b/app/src/ui/add-repository/add-existing-repository.tsx index f17e24fb217..a70ef1540b1 100644 --- a/app/src/ui/add-repository/add-existing-repository.tsx +++ b/app/src/ui/add-repository/add-existing-repository.tsx @@ -77,7 +77,9 @@ export class AddExistingRepository extends React.Component< } private async updatePath(path: string) { - this.setState({ path }) + await new Promise(resolve => { + this.setState({ path }, resolve) + }) } private async validatePath(path: string): Promise { @@ -89,7 +91,8 @@ export class AddExistingRepository extends React.Component< return false } - const type = await getRepositoryType(path) + const resolvedPath = this.resolvedPath(path) + const type = await getRepositoryType(resolvedPath) const isRepository = type.kind !== 'missing' && type.kind !== 'unsafe' const isRepositoryUnsafe = type.kind === 'unsafe'