Skip to content

Commit 0cd4fad

Browse files
committed
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 are available on first render instead of only after opening the worktree dropdown or clicking around - show a lightweight loading hint while linked worktrees are still being discovered for a repository - refresh parent sidebar rows when linked worktrees are selected so nested rows stay in sync with the active repository view - use preloaded parent worktree metadata so stored linked worktree rows show their branch pill on first render instead of waiting for a later refresh - 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 - 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 and cap preload concurrency to reduce repeated `git worktree list` churn - prune sidebar worktree refresh timestamps when repository lists change so stale cache entries do not accumulate - harden the shared CI setup action by retrying the `yarn` install step to absorb transient Electron download failures in Actions - 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, loading state, linked-only setups, and stored linked-worktree branch labels Behavioral effect: Users can opt into seeing and switching linked worktrees directly from the main repository sidebar, including unstored Git worktrees, with branch labels and parent-child grouping available on initial render. Testing: - yarn test:unit app/test/unit/repositories-list-grouping-test.ts - yarn lint - yarn compile:dev - yarn compile:prod
1 parent bce9f0a commit 0cd4fad

15 files changed

Lines changed: 1185 additions & 76 deletions

File tree

.github/actions/setup-ci-environment/action.yml

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,24 @@ runs:
3333

3434
- name: Install and build dependencies
3535
shell: bash
36-
run: yarn
36+
run: |
37+
max_attempts=3
38+
attempt=1
39+
40+
while [ "$attempt" -le "$max_attempts" ]; do
41+
if yarn; then
42+
exit 0
43+
fi
44+
45+
if [ "$attempt" -eq "$max_attempts" ]; then
46+
exit 1
47+
fi
48+
49+
sleep_seconds=$((attempt * 10))
50+
echo "yarn install failed on attempt $attempt/$max_attempts, retrying in ${sleep_seconds}s..."
51+
sleep "$sleep_seconds"
52+
attempt=$((attempt + 1))
53+
done
3754
env:
3855
npm_config_arch: ${{ inputs.arch }}
3956
TARGET_ARCH: ${{ inputs.arch }}

app/src/lib/app-state.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,9 @@ export interface IAppState {
321321
/** Whether or not the worktrees dropdown should be shown in the toolbar */
322322
readonly showWorktrees: boolean
323323

324+
/** Whether linked worktrees should be shown under their repository in the sidebar */
325+
readonly showWorktreesInSidebar: boolean
326+
324327
/** Whether or not the Compare tab should be shown in the repository view */
325328
readonly showCompareTab: boolean
326329

app/src/lib/git/worktree.ts

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,15 @@ import type { WorktreeEntry, WorktreeType } from '../../models/worktree'
55
import { git } from './core'
66
import { normalizePath } from '../helpers/path'
77

8+
function getDotGitPath(repositoryPath: string): string {
9+
return Path.join(repositoryPath, '.git')
10+
}
11+
12+
export interface IWorktreePathInfo {
13+
readonly isLinkedWorktree: boolean
14+
readonly mainWorktreePath: string | null
15+
}
16+
817
export function parseWorktreePorcelainOutput(
918
stdout: string
1019
): ReadonlyArray<WorktreeEntry> {
@@ -136,16 +145,61 @@ export async function getMainWorktreePath(
136145
}
137146

138147
/**
139-
* Synchronously checks if a repository path is a linked worktree by examining
140-
* whether `.git` is a file (linked worktree) or directory (main worktree).
148+
* Synchronously checks if a repository path is a linked worktree by inspecting
149+
* the `.git` entry and validating linked-worktree metadata when it points at a
150+
* gitdir file rather than a main worktree directory.
141151
*/
142152
export function isLinkedWorktreeSync(repositoryPath: string): boolean {
153+
const worktreeInfo = getWorktreePathInfoSync(repositoryPath)
154+
return worktreeInfo?.isLinkedWorktree ?? false
155+
}
156+
157+
export function getWorktreePathInfoSync(
158+
repositoryPath: string
159+
): IWorktreePathInfo | null {
143160
try {
144-
const dotGit = Path.join(repositoryPath, '.git')
161+
const dotGit = getDotGitPath(repositoryPath)
145162
// eslint-disable-next-line no-sync
146163
const stats = Fs.statSync(dotGit)
147-
return stats.isFile()
164+
165+
if (stats.isDirectory()) {
166+
return { isLinkedWorktree: false, mainWorktreePath: repositoryPath }
167+
}
168+
169+
if (!stats.isFile()) {
170+
return null
171+
}
172+
173+
// eslint-disable-next-line no-sync
174+
const contents = Fs.readFileSync(dotGit, 'utf8').trim()
175+
if (!contents.startsWith('gitdir: ')) {
176+
return null
177+
}
178+
179+
const gitDirPath = Path.resolve(
180+
repositoryPath,
181+
contents.substring('gitdir: '.length)
182+
)
183+
184+
// eslint-disable-next-line no-sync
185+
const commondir = Fs.readFileSync(
186+
Path.join(gitDirPath, 'commondir'),
187+
'utf8'
188+
).trim()
189+
if (commondir.length === 0) {
190+
return null
191+
}
192+
193+
const commonGitDir = Path.resolve(gitDirPath, commondir)
194+
return {
195+
isLinkedWorktree: true,
196+
mainWorktreePath: Path.dirname(commonGitDir),
197+
}
148198
} catch {
149-
return false
199+
return null
150200
}
151201
}
202+
203+
export function getMainWorktreePathSync(repositoryPath: string): string | null {
204+
return getWorktreePathInfoSync(repositoryPath)?.mainWorktreePath ?? null
205+
}

0 commit comments

Comments
 (0)