Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
d4817c6
Add worktree operations layer and types
devxoul Feb 9, 2026
3823c83
Add FoldoutType.Worktree, MenuEvent, and Cmd+E shortcut
devxoul Feb 9, 2026
1e98167
Add WorktreeDropdown toolbar component
devxoul Feb 9, 2026
9ee2aa0
Add WorktreeList and WorktreeListItem components
devxoul Feb 9, 2026
0aa25dc
Add worktree context menu and add-worktree dialog
devxoul Feb 9, 2026
0c34573
Add worktree dropdown and list styling
devxoul Feb 9, 2026
9638c79
Integrate worktree toolbar button and menu shortcut in app
devxoul Feb 9, 2026
d8a1e6b
Fix worktree list not rendering items in dropdown
devxoul Feb 10, 2026
f29e206
Fix worktree dropdown dismissing on any keyboard input
devxoul Feb 10, 2026
2b4a8d7
Auto-remove worktree repo from list when switching away
devxoul Feb 10, 2026
69e59dc
Wire up worktree context menu with delete action in dropdown
devxoul Feb 12, 2026
949492c
Add moveWorktree git operation and RenameWorktree popup type
devxoul Feb 12, 2026
383fe7d
Add RenameWorktreeDialog component
devxoul Feb 12, 2026
c50ec72
Wire up rename worktree in context menu, dropdown, and app popup
devxoul Feb 12, 2026
520e949
Add DeleteWorktree popup type and confirmation dialog
devxoul Feb 12, 2026
101c635
Wire up delete confirmation popup in dropdown and app
devxoul Feb 12, 2026
1d2c5e5
Show current worktree name in toolbar to match branch dropdown pattern
devxoul Feb 12, 2026
06950cd
Add resizable worktree dropdown to match branch dropdown pattern
devxoul Feb 15, 2026
4205f5f
Add New Worktree button next to search in worktree dropdown
devxoul Feb 15, 2026
f21f8aa
Replace title attribute with TooltippedContent in worktree list item
devxoul Feb 15, 2026
748334b
Fix worktree dropdown width to expand with foldout like branch dropdown
devxoul Feb 16, 2026
9cdd797
Auto-switch to newly created worktree after creation
devxoul Feb 16, 2026
546af41
Hide linked worktrees from the repository list
devxoul Feb 16, 2026
1559cb3
Use RefNameTextBox in worktree creation dialog for branch name input
devxoul Feb 16, 2026
9369200
Cache isLinkedWorktree as a lazy property on Repository
devxoul Feb 16, 2026
5330094
Fix deleting the currently selected worktree by switching to main first
devxoul Feb 16, 2026
a5040c1
Fix accelerator conflict
pol-rivero Feb 17, 2026
d5d61f2
Update worktree creation flow
pol-rivero Feb 17, 2026
759cec4
Auto-focus workspace name text box
pol-rivero Feb 17, 2026
fd1e7c0
Add showWorktrees preference to state and store layer
devxoul Feb 18, 2026
d628076
Add worktrees visibility toggle to Appearance preferences
devxoul Feb 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions app/src/lib/app-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,9 @@ export interface IAppState {
/** The width of the resizable branch drop down button in the toolbar. */
readonly branchDropdownWidth: IConstrainedValue

/** The width of the resizable worktree drop down button in the toolbar. */
readonly worktreeDropdownWidth: IConstrainedValue

/** The width of the resizable push/pull button in the toolbar. */
readonly pushPullButtonWidth: IConstrainedValue

Expand Down Expand Up @@ -310,6 +313,9 @@ export interface IAppState {
/** Whether or not recent repositories should be shown in the repo list */
readonly showRecentRepositories: boolean

/** Whether or not the worktrees dropdown should be shown in the toolbar */
readonly showWorktrees: boolean

/**
* A map keyed on a user account (GitHub.com or GitHub Enterprise)
* containing an object with repositories that the authenticated
Expand Down Expand Up @@ -411,6 +417,7 @@ export enum FoldoutType {
AppMenu,
AddMenu,
PushPull,
Worktree,
}

export type AppMenuFoldout = {
Expand All @@ -434,6 +441,7 @@ export type Foldout =
| BranchFoldout
| AppMenuFoldout
| { type: FoldoutType.PushPull }
| { type: FoldoutType.Worktree }

export enum RepositorySectionTab {
Changes,
Expand Down
1 change: 1 addition & 0 deletions app/src/lib/git/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,4 @@ export * from './gitignore'
export * from './rebase'
export * from './format-patch'
export * from './tag'
export * from './worktree'
155 changes: 155 additions & 0 deletions app/src/lib/git/worktree.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import * as Path from 'path'
import * as Fs from 'fs'
import type { Repository } from '../../models/repository'
import type { WorktreeEntry, WorktreeType } from '../../models/worktree'
import { git } from './core'

export function parseWorktreePorcelainOutput(
stdout: string
): ReadonlyArray<WorktreeEntry> {
if (stdout.trim().length === 0) {
return []
}

const blocks = stdout.trim().split('\n\n')
const entries: WorktreeEntry[] = []

for (let i = 0; i < blocks.length; i++) {
const lines = blocks[i].split('\n')
let path = ''
let head = ''
let branch: string | null = null
let isDetached = false
let isLocked = false
let isPrunable = false

for (const line of lines) {
if (line.startsWith('worktree ')) {
path = line.substring('worktree '.length)
} else if (line.startsWith('HEAD ')) {
head = line.substring('HEAD '.length)
} else if (line.startsWith('branch ')) {
branch = line.substring('branch '.length)
} else if (line === 'detached') {
isDetached = true
} else if (line === 'locked' || line.startsWith('locked ')) {
isLocked = true
} else if (line === 'prunable' || line.startsWith('prunable ')) {
isPrunable = true
}
}

const type: WorktreeType = i === 0 ? 'main' : 'linked'
entries.push({ path, head, branch, isDetached, type, isLocked, isPrunable })
}

return entries
}

export async function listWorktrees(
repository: Repository
): Promise<ReadonlyArray<WorktreeEntry>> {
const result = await git(
['worktree', 'list', '--porcelain'],
repository.path,
'listWorktrees'
)

return parseWorktreePorcelainOutput(result.stdout)
}

export async function addWorktree(
repository: Repository,
path: string,
options: {
readonly branch?: string
readonly createBranch?: string
readonly detach?: boolean
readonly commitish?: string
} = {}
): Promise<void> {
const args = ['worktree', 'add']

if (options.detach) {
args.push('--detach')
}

if (options.createBranch) {
args.push('-b', options.createBranch)
}

args.push(path)

if (options.branch) {
args.push(options.branch)
} else if (options.commitish) {
args.push(options.commitish)
}

await git(args, repository.path, 'addWorktree')
}

export async function removeWorktree(
repository: Repository,
path: string,
force: boolean = false
): Promise<void> {
const args = ['worktree', 'remove']
if (force) {
args.push('--force')
}
args.push(path)

await git(args, repository.path, 'removeWorktree')
}

export async function moveWorktree(
repository: Repository,
oldPath: string,
newPath: string
): Promise<void> {
await git(
['worktree', 'move', oldPath, newPath],
repository.path,
'moveWorktree'
)
}

export async function isLinkedWorktree(
repository: Repository
): Promise<boolean> {
const worktrees = await listWorktrees(repository)
const repoPath = repository.path

return worktrees.some(
wt =>
wt.type === 'linked' && normalizePath(wt.path) === normalizePath(repoPath)
)
}

export async function getMainWorktreePath(
repository: Repository
): Promise<string | null> {
const worktrees = await listWorktrees(repository)
const main = worktrees.find(wt => wt.type === 'main')
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 {
try {
const dotGit = Path.join(repositoryPath, '.git')
// eslint-disable-next-line no-sync
const stats = Fs.statSync(dotGit)
return stats.isFile()
} catch {
return false
}
}

function normalizePath(p: string): string {
return p.replace(/\/+$/, '')
}
91 changes: 75 additions & 16 deletions app/src/lib/stores/app-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,9 @@ const pullRequestFileListConfigKey: string = 'pull-request-files-width'
const defaultBranchDropdownWidth: number = 230
const branchDropdownWidthConfigKey: string = 'branch-dropdown-width'

const defaultWorktreeDropdownWidth: number = 230
const worktreeDropdownWidthConfigKey: string = 'worktree-dropdown-width'

const defaultPushPullButtonWidth: number = 230
const pushPullButtonWidthConfigKey: string = 'push-pull-button-width'

Expand Down Expand Up @@ -442,6 +445,7 @@ const tabSizeKey: string = 'tab-size'
const shellKey = 'shell'

const showRecentRepositoriesKey = 'show-recent-repositories'
const showWorktreesKey = 'show-worktrees'
const repositoryIndicatorsEnabledKey = 'enable-repository-indicators'

// background fetching should occur hourly when Desktop is active, but this
Expand Down Expand Up @@ -539,6 +543,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
private stashedFilesWidth = constrain(defaultStashedFilesWidth)
private pullRequestFileListWidth = constrain(defaultPullRequestFileListWidth)
private branchDropdownWidth = constrain(defaultBranchDropdownWidth)
private worktreeDropdownWidth = constrain(defaultWorktreeDropdownWidth)
private pushPullButtonWidth = constrain(defaultPushPullButtonWidth)

private windowState: WindowState | null = null
Expand Down Expand Up @@ -603,6 +608,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
private selectedTabSize = tabSizeDefault
private titleBarStyle: TitleBarStyle = 'native'
private showRecentRepositories: boolean = true
private showWorktrees: boolean = false
private hideWindowOnQuit: boolean = false

private useWindowsOpenSSH: boolean = false
Expand Down Expand Up @@ -711,6 +717,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
getBoolean(repositoryIndicatorsEnabledKey) ?? true

this.showRecentRepositories = getBoolean(showRecentRepositoriesKey) ?? true
this.showWorktrees = getBoolean(showWorktreesKey) ?? false

this.repositoryIndicatorUpdater = new RepositoryIndicatorUpdater(
this.getRepositoriesForIndicatorRefresh,
Expand Down Expand Up @@ -1104,6 +1111,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
emoji: this.emoji,
sidebarWidth: this.sidebarWidth,
branchDropdownWidth: this.branchDropdownWidth,
worktreeDropdownWidth: this.worktreeDropdownWidth,
pushPullButtonWidth: this.pushPullButtonWidth,
commitSummaryWidth: this.commitSummaryWidth,
stashedFilesWidth: this.stashedFilesWidth,
Expand Down Expand Up @@ -1146,6 +1154,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
selectedTabSize: this.selectedTabSize,
titleBarStyle: this.titleBarStyle,
showRecentRepositories: this.showRecentRepositories,
showWorktrees: this.showWorktrees,
apiRepositories: this.apiRepositoriesStore.getState(),
useWindowsOpenSSH: this.useWindowsOpenSSH,
showCommitLengthWarning: this.showCommitLengthWarning,
Expand Down Expand Up @@ -2373,6 +2382,9 @@ export class AppStore extends TypedBaseStore<IAppState> {
this.branchDropdownWidth = constrain(
getNumber(branchDropdownWidthConfigKey, defaultBranchDropdownWidth)
)
this.worktreeDropdownWidth = constrain(
getNumber(worktreeDropdownWidthConfigKey, defaultWorktreeDropdownWidth)
)
this.pushPullButtonWidth = constrain(
getNumber(pushPullButtonWidthConfigKey, defaultPushPullButtonWidth)
)
Expand Down Expand Up @@ -2564,12 +2576,13 @@ export class AppStore extends TypedBaseStore<IAppState> {
* dimensions change.
*/
private updateResizableConstraints() {
// The combined width of the branch dropdown and the push/pull/fetch button
// The combined width of the toolbar buttons (worktree, branch, push/pull).
// Since the repository list toolbar button width is tied to the width of
// the sidebar we can't let it push the branch, and push/pull/fetch button
// off screen.
// the sidebar we can't let it push these buttons off screen.
const toolbarButtonsMinWidth =
defaultPushPullButtonWidth + defaultBranchDropdownWidth
defaultPushPullButtonWidth +
defaultBranchDropdownWidth +
defaultWorktreeDropdownWidth

// Start with all the available width
let available = window.innerWidth
Expand Down Expand Up @@ -2606,27 +2619,40 @@ export class AppStore extends TypedBaseStore<IAppState> {
this.commitSummaryWidth = constrain(this.commitSummaryWidth, 100, filesMax)
this.stashedFilesWidth = constrain(this.stashedFilesWidth, 100, filesMax)

// Update the maximum width available for the branch dropdown resizable.
// The branch dropdown can only be as wide as the available space after
// taking the sidebar and pull/push/fetch button widths. If the room
// available is less than the default width, we will split the difference
// between the branch dropdown and the push/pull/fetch button so they stay
// visible on the most zoomed view.
const branchDropdownMax = available - defaultPushPullButtonWidth
// Allocate branch first (highest priority), then worktree, then
// push-pull. Each subsequent allocation uses the clamped value of the
// previous to prevent the total from exceeding the available space.
const branchDropdownMax =
available - defaultWorktreeDropdownWidth - defaultPushPullButtonWidth
const minimumBranchDropdownWidth =
defaultBranchDropdownWidth > available / 2
? available / 2 - 10 // 10 is to give a little bit of space to see the fetch dropdown button
defaultBranchDropdownWidth > available / 3
? available / 3 - 10
: defaultBranchDropdownWidth
this.branchDropdownWidth = constrain(
this.branchDropdownWidth,
minimumBranchDropdownWidth,
branchDropdownMax
)

const pushPullButtonMaxWidth = available - this.branchDropdownWidth.value
const worktreeDropdownMax =
available - clamp(this.branchDropdownWidth) - defaultPushPullButtonWidth
const minimumWorktreeDropdownWidth =
defaultWorktreeDropdownWidth > available / 3
? available / 3 - 10
: defaultWorktreeDropdownWidth
this.worktreeDropdownWidth = constrain(
this.worktreeDropdownWidth,
minimumWorktreeDropdownWidth,
worktreeDropdownMax
)

const pushPullButtonMaxWidth =
available -
clamp(this.branchDropdownWidth) -
clamp(this.worktreeDropdownWidth)
const minimumPushPullToolBarWidth =
defaultPushPullButtonWidth > available / 2
? available / 2 + 30 // 30 to clip the fetch dropdown button in favor of seeing more of the words on the toolbar buttons
defaultPushPullButtonWidth > available / 3
? available / 3
: defaultPushPullButtonWidth
this.pushPullButtonWidth = constrain(
this.pushPullButtonWidth,
Expand Down Expand Up @@ -4022,6 +4048,15 @@ export class AppStore extends TypedBaseStore<IAppState> {
this.emitUpdate()
}

public _setShowWorktrees(showWorktrees: boolean) {
if (this.showWorktrees === showWorktrees) {
return
}
setBoolean(showWorktreesKey, showWorktrees)
this.showWorktrees = showWorktrees
this.emitUpdate()
}

public _setCommitSpellcheckEnabled(commitSpellcheckEnabled: boolean) {
if (this.commitSpellcheckEnabled === commitSpellcheckEnabled) {
return
Expand Down Expand Up @@ -5763,6 +5798,30 @@ export class AppStore extends TypedBaseStore<IAppState> {
return Promise.resolve()
}

public _setWorktreeDropdownWidth(width: number): Promise<void> {
this.worktreeDropdownWidth = {
...this.worktreeDropdownWidth,
value: width,
}
setNumber(worktreeDropdownWidthConfigKey, width)
this.updateResizableConstraints()
this.emitUpdate()

return Promise.resolve()
}

public _resetWorktreeDropdownWidth(): Promise<void> {
this.worktreeDropdownWidth = {
...this.worktreeDropdownWidth,
value: defaultWorktreeDropdownWidth,
}
localStorage.removeItem(worktreeDropdownWidthConfigKey)
this.updateResizableConstraints()
this.emitUpdate()

return Promise.resolve()
}

public _setPushPullButtonWidth(width: number): Promise<void> {
this.pushPullButtonWidth = { ...this.pushPullButtonWidth, value: width }
setNumber(pushPullButtonWidthConfigKey, width)
Expand Down
Loading