Skip to content

Commit b40e2b3

Browse files
committed
Return to main worktree if the selected worktree is deleted outside the app
Closes #158
1 parent 5187907 commit b40e2b3

3 files changed

Lines changed: 203 additions & 0 deletions

File tree

app/src/lib/git/worktree.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import * as Path from 'path'
2+
import { readFile } from 'fs/promises'
23
import type { Repository } from '../../models/repository'
34
import type { WorktreeEntry, WorktreeType } from '../../models/worktree'
45
import { git } from './core'
6+
import { directoryExists } from '../directory-exists'
57

68
export function parseWorktreePorcelainOutput(
79
stdout: string
@@ -64,6 +66,45 @@ export async function listWorktrees(
6466
return parseWorktreePorcelainOutput(result.stdout)
6567
}
6668

69+
/**
70+
* Path to the main worktree's working directory for a repository pointing at a
71+
* linked worktree, derived purely from on-disk Git metadata so it still works
72+
* when the linked worktree's working directory is gone.
73+
*
74+
* Returns null when it can't be determined (unknown `gitDir`) or doesn't exist.
75+
*/
76+
export async function getMainWorktreePath(
77+
repository: Repository
78+
): Promise<string | null> {
79+
const { gitDir } = repository
80+
if (gitDir === undefined) {
81+
return null
82+
}
83+
84+
const commonDir = await resolveCommonGitDir(gitDir)
85+
const mainWorktreePath = Path.dirname(commonDir)
86+
87+
if (!(await directoryExists(mainWorktreePath))) {
88+
return null
89+
}
90+
return mainWorktreePath
91+
}
92+
93+
async function resolveCommonGitDir(gitDir: string): Promise<string> {
94+
if (Path.basename(Path.dirname(gitDir)) !== 'worktrees') {
95+
return gitDir
96+
}
97+
98+
// Prefer the `commondir` file, but fall back to the conventional layout (two
99+
// levels up) when it's unreadable, e.g. `git worktree remove` deleted the
100+
// worktree's admin files too.
101+
const conventionalCommonDir = Path.dirname(Path.dirname(gitDir))
102+
return readFile(Path.join(gitDir, 'commondir'), 'utf8')
103+
.then(content => content.replace(/\r?\n$/, ''))
104+
.then(p => (p ? Path.resolve(gitDir, p) : conventionalCommonDir))
105+
.catch(() => conventionalCommonDir)
106+
}
107+
67108
export async function addWorktree(
68109
repository: Repository,
69110
path: string,

app/src/lib/stores/app-store.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,7 @@ import {
240240
getRepositoryType,
241241
RepositoryType,
242242
listWorktrees,
243+
getMainWorktreePath,
243244
removeWorktree,
244245
getCommitRangeDiff,
245246
getCommitRangeChangedFiles,
@@ -2573,6 +2574,16 @@ export class AppStore extends TypedBaseStore<IAppState> {
25732574

25742575
// if repository might be marked missing, try checking if it has been restored
25752576
const refreshedRepository = await this.recoverMissingRepository(repository)
2577+
2578+
// A removed linked worktree falls back to its main worktree instead of the
2579+
// missing-repository view.
2580+
const mainWorktreeRepository = await this.switchToMainWorktreeIfMissing(
2581+
refreshedRepository
2582+
)
2583+
if (mainWorktreeRepository !== null) {
2584+
return this._selectRepository(mainWorktreeRepository)
2585+
}
2586+
25762587
if (refreshedRepository.missing) {
25772588
// as the repository is no longer found on disk, cleaning this up
25782589
// ensures we don't accidentally run any Git operations against the
@@ -4434,6 +4445,32 @@ export class AppStore extends TypedBaseStore<IAppState> {
44344445
}
44354446
}
44364447

4448+
/**
4449+
* If the repository points at a linked worktree that's gone from disk, switch
4450+
* it back to its main worktree so we don't needlessly show it as missing.
4451+
*
4452+
* Returns the repository pointing at the main worktree, or null if no switch
4453+
* happened (path still exists, not a linked worktree, or main worktree gone
4454+
* too). Validating the switched-to worktree is left to the following refresh.
4455+
*/
4456+
private async switchToMainWorktreeIfMissing(
4457+
repository: Repository
4458+
): Promise<Repository | null> {
4459+
if (await pathExists(repository.path)) {
4460+
return null
4461+
}
4462+
4463+
const mainWorktreePath = await getMainWorktreePath(repository)
4464+
if (mainWorktreePath === null) {
4465+
return null
4466+
}
4467+
4468+
const { repository: updatedRepository } =
4469+
await this.repositoriesStore.switchWorktree(repository, mainWorktreePath)
4470+
4471+
return updatedRepository
4472+
}
4473+
44374474
private async recoverMissingRepository(
44384475
repository: Repository
44394476
): Promise<Repository> {
@@ -4469,6 +4506,22 @@ export class AppStore extends TypedBaseStore<IAppState> {
44694506
// set the flag and don't try anything Git-related
44704507
const exists = await pathExists(repository.path)
44714508
if (!exists) {
4509+
// Only for the selected repo, so a background refresh of another repo
4510+
// can't hijack the current selection by switching it to its main worktree.
4511+
const isSelected =
4512+
this.selectedRepository instanceof Repository &&
4513+
this.selectedRepository.id === repository.id
4514+
4515+
if (isSelected) {
4516+
const mainWorktreeRepository = await this.switchToMainWorktreeIfMissing(
4517+
repository
4518+
)
4519+
if (mainWorktreeRepository !== null) {
4520+
await this._selectRepository(mainWorktreeRepository)
4521+
return
4522+
}
4523+
}
4524+
44724525
this._updateRepositoryMissing(repository, true)
44734526
return
44744527
}

app/test/unit/git/worktree-test.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
import assert from 'node:assert'
22
import * as Path from 'path'
33
import { describe, it } from 'node:test'
4+
import { rm } from 'fs/promises'
45
import { exec } from 'dugite'
56
import { setupEmptyRepository } from '../../helpers/repositories'
67
import { makeCommit } from '../../helpers/repository-scaffolding'
78
import {
89
parseWorktreePorcelainOutput,
910
listWorktrees,
11+
getMainWorktreePath,
12+
getRepositoryType,
1013
} from '../../../src/lib/git'
14+
import { Repository } from '../../../src/models/repository'
1115

1216
describe('git/worktree', () => {
1317
describe('parseWorktreePorcelainOutput', () => {
@@ -293,4 +297,109 @@ describe('git/worktree', () => {
293297
assert(branches.has('refs/heads/main'))
294298
})
295299
})
300+
301+
describe('getMainWorktreePath', () => {
302+
/** Build a Repository pointing at `path`, populating its real `gitDir`. */
303+
async function repositoryAt(path: string): Promise<Repository> {
304+
const type = await getRepositoryType(path)
305+
const gitDir = type.kind === 'regular' ? type.gitDir : undefined
306+
return new Repository(
307+
path,
308+
-1,
309+
null,
310+
false,
311+
null,
312+
null,
313+
null,
314+
{},
315+
null,
316+
false,
317+
null,
318+
gitDir
319+
)
320+
}
321+
322+
it('returns the main worktree path for a removed linked worktree', async t => {
323+
const repo = await setupEmptyRepository(t, 'main')
324+
await makeCommit(repo, {
325+
entries: [{ path: 'README', contents: 'hello' }],
326+
})
327+
await exec(['branch', 'feature-a'], repo.path)
328+
329+
const worktreePath = repo.path + '-wt-a'
330+
await exec(['worktree', 'add', worktreePath, 'feature-a'], repo.path)
331+
332+
const linkedRepo = await repositoryAt(worktreePath)
333+
334+
// rm leaves the worktree's admin files (and `commondir`) intact.
335+
await rm(worktreePath, { recursive: true, force: true })
336+
337+
assert.strictEqual(
338+
await getMainWorktreePath(linkedRepo),
339+
Path.normalize(repo.path)
340+
)
341+
})
342+
343+
it('returns the main worktree path when the linked worktree was fully removed with `git worktree remove`', async t => {
344+
const repo = await setupEmptyRepository(t, 'main')
345+
await makeCommit(repo, {
346+
entries: [{ path: 'README', contents: 'hello' }],
347+
})
348+
await exec(['branch', 'feature-a'], repo.path)
349+
await exec(['branch', 'feature-b'], repo.path)
350+
351+
const worktreeA = repo.path + '-wt-a'
352+
const worktreeB = repo.path + '-wt-b'
353+
await exec(['worktree', 'add', worktreeA, 'feature-a'], repo.path)
354+
await exec(['worktree', 'add', worktreeB, 'feature-b'], repo.path)
355+
356+
const linkedRepo = await repositoryAt(worktreeA)
357+
358+
// `git worktree remove` also deletes the admin files (so `commondir` is
359+
// unreadable); worktree B keeps `.git/worktrees` itself on disk.
360+
await exec(['worktree', 'remove', '--force', worktreeA], repo.path)
361+
362+
assert.strictEqual(
363+
await getMainWorktreePath(linkedRepo),
364+
Path.normalize(repo.path)
365+
)
366+
})
367+
368+
it('returns its own path when called on the main worktree', async t => {
369+
const repo = await setupEmptyRepository(t, 'main')
370+
await makeCommit(repo, {
371+
entries: [{ path: 'README', contents: 'hello' }],
372+
})
373+
374+
const mainRepo = await repositoryAt(repo.path)
375+
assert.strictEqual(
376+
await getMainWorktreePath(mainRepo),
377+
Path.normalize(repo.path)
378+
)
379+
})
380+
381+
it('returns null when the repository gitDir is unknown', async t => {
382+
const repo = await setupEmptyRepository(t, 'main')
383+
assert.strictEqual(await getMainWorktreePath(repo), null)
384+
})
385+
386+
it('returns null when the main worktree no longer exists on disk', async () => {
387+
const missingRepo = new Repository(
388+
Path.normalize('/this/path/does/not/exist'),
389+
-1,
390+
null,
391+
false,
392+
null,
393+
null,
394+
null,
395+
{},
396+
null,
397+
false,
398+
null,
399+
Path.normalize('/this/path/does/not/exist/.git/worktrees/foo')
400+
)
401+
402+
assert.strictEqual(await getMainWorktreePath(missingRepo), null)
403+
})
404+
})
296405
})

0 commit comments

Comments
 (0)