Conversation
GitFourchette could already open a worktree as a repo, but it couldn't tell you that any existed. Add a Worktrees section to the sidebar and the operations that go with it, so linked worktrees can be created, opened, locked and removed without dropping to a terminal. - Repo.listall_worktrees() reads $GIT_COMMON_DIR/worktrees directly. pygit2 gives worktree names and paths, but neither the ref checked out in a worktree nor its lock state. The output was checked against `git worktree list --porcelain` on a main worktree, linked worktrees (attached, detached and locked), a bare repo, and a bare repo's worktree. - RepoModel caches them the same way it caches submodules, resynced by RefreshRepo on Head/Refs effects. - The sidebar section sits right below Working Directory, because worktrees are working context rather than rarely-touched metadata. A repo with no linked worktrees doesn't get the section at all: unlike Stashes or Submodules, an always-present Worktrees section reads as a claim that you're in one, and most repos never use worktrees. The first worktree is created from the Repo menu and the section appears with it. Inside it, the main worktree wears the working-directory icon rather than the worktree one, so it reads as the repo itself. - NewWorktree covers the three forms of `git worktree add`: a new branch (named after the folder until you type your own), an existing branch that isn't checked out elsewhere, or a detached HEAD. Branches already checked out in another worktree are filtered out of the picker, since git would refuse them anyway. - RemoveWorktree asks a second time, offering --force, when git refuses because of uncommitted changes or untracked files. The main worktree and the one you're currently looking at are refused outright. - LockWorktree/UnlockWorktree and PruneWorktrees complete the section. - A worktree's tab is labelled "<repo>: <worktree>", reusing the prefix RepoModel.shortName already applies to submodules. Two tabs showing two working copies of the same repo looked like two unrelated repos. All git mutations go through flowCallGit; nothing here calls libgit2 to write.
57e8e2d to
294682f
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Critical worktree removal and pruning safety issues, plus metadata handling defects, remain unresolved.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Adds Git worktree discovery and management across the sidebar, repository model, dialogs, and tasks.
Changes:
- Adds worktree parsing, caching, display, and tab labeling.
- Adds creation, navigation, locking, removal, and pruning operations.
- Adds UI resources, translations, and tests.
File summaries
| File | Description |
|---|---|
test/test_tasks_worktree.py |
Tests worktree behavior. |
gitfourchette/trtables.py |
Adds localized worktree labels. |
gitfourchette/tasks/worktreetasks.py |
Implements worktree operations. |
gitfourchette/tasks/taskbook.py |
Registers task metadata. |
gitfourchette/tasks/jumptasks.py |
Refreshes worktree state. |
gitfourchette/tasks/__init__.py |
Exports worktree tasks. |
gitfourchette/sidebar/sidebarmodel.py |
Displays worktree entries. |
gitfourchette/sidebar/sidebar.py |
Adds worktree menus and actions. |
gitfourchette/repowidget.py |
Connects worktree navigation. |
gitfourchette/repomodel.py |
Caches worktrees and labels tabs. |
gitfourchette/porcelain.py |
Parses worktree metadata. |
gitfourchette/forms/ui_newworktreedialog.py |
Provides generated dialog UI. |
gitfourchette/forms/newworktreedialog.ui |
Defines dialog layout. |
gitfourchette/forms/newworktreedialog.py |
Implements creation dialog logic. |
gitfourchette/assets/icons/git-worktree.svg |
Adds the worktree icon. |
Review details
Suppressed comments (2)
gitfourchette/porcelain.py:840
core.bareis a Git boolean, not necessarily the literal string"true"; configurations may use valid values such asyes,on, or1. With one of those values this branch incorrectly invents a main worktree for a bare repository, so the sidebar and worktree tab metadata are wrong. Parse the setting with Git/pygit2's boolean accessor, treating an absent setting as false.
if self.get_config_value("core.bare") != "true":
gitfourchette/tasks/worktreetasks.py:47
- Opening the New Worktree task from a bare repository reaches this line with
repo.workdirunset, soos.path.normpath(None)raisesTypeErrorbefore the dialog can open. Git supports adding linked worktrees from a bare repository (and this PR already handles bare-repo worktrees); derive the default parent fromrepo.pathwhen no workdir exists.
parentDir = os.path.dirname(os.path.normpath(repo.workdir))
- Files reviewed: 14/15 changed files
- Comments generated: 4
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| TaskBook.action(self, RemoveWorktree, taskArgs=data, | ||
| enabled=not worktree.is_current), |
There was a problem hiding this comment.
Good catch, fixed in 151956f. The action is now disabled when the worktree is open in any tab, not just the current one — MainWindow.openWorkdirs() is the source of truth.
| if worktree.is_main: | ||
| raise AbortTask(_("The main worktree can’t be removed.")) | ||
| if worktree.is_current: |
There was a problem hiding this comment.
Fixed in 151956f: RemoveWorktree now refuses any worktree whose path is open in a tab, and says which tab to close. Covered by testCantRemoveAWorktreeOpenInAnotherTab.
| class PruneWorktrees(_WorktreeTask): | ||
| def flow(self): | ||
| stale = [w for w in self.repoModel.worktrees if w.prunable and not w.locked] | ||
|
|
There was a problem hiding this comment.
Right, and worse than it looks: git worktree prune takes no path, so sparing one isn't possible. It now refuses outright when a stale worktree is still open, naming the ones in the way, rather than pretending to protect them. Covered by testPruneRefusesWhileAStaleWorktreeIsOpen.
| else: # this Repo is a linked worktree; the main one is next to $GIT_COMMON_DIR | ||
| main_path = _dirname(commondir) |
There was a problem hiding this comment.
Confirmed by building such a repo: we reported the git dir's parent, which isn't a worktree at all. There's no back-pointer to recover the real path from — git worktree list can't find it either — so instead of guessing, the derived path is now validated against the .git it should contain and dropped when it doesn't match. A git dir that does live inside its worktree still resolves. Covered by testSeparateGitDirDoesntInventAMainWorktree and testSeparateGitDirInsideTheWorktreeStillFindsMain.
Three holes with the same shape, all found in review: WorktreeInfo.is_current only speaks for the repo it was read from, so a worktree open in a second tab looked untouched from the first one. - RemoveWorktree refused only the worktree of the current tab. From any other tab it would happily delete a working directory that a tab was sitting in, leaving that tab pointing at nothing. The sidebar action is now disabled for those, and the task refuses them by path. - PruneWorktrees is all or nothing - git worktree prune takes no path - so sparing one isn't possible. If a stale worktree is still open in a tab it now refuses and names it, rather than taking away the administrative files that tab is still using. And a fourth, unrelated: with `git init --separate-git-dir`, $GIT_COMMON_DIR isn't inside the main worktree, so deriving the main worktree from its parent pointed at a folder that isn't one - which the sidebar then listed and the tab prefix then used. Nothing records where that main worktree went, not even git itself, so the derived path is now checked against the .git it should contain and dropped when it doesn't match. A git dir that lives inside its worktree still resolves, since there the check passes.
Brings in the Worktrees sidebar section, the New Worktree dialog and the remove/lock/prune/open actions from feature/worktree-support (upstream PR jorio#140), with the fix that lets it import on Python 3.12 and 3.13.
|
Closing this one: the worktree work has grown past what a single-subject PR should carry, and it now sits alongside a larger set of changes in my fork. I'd rather come back with a branch that stands on its own and is easier to review against current master than keep this one rebasing behind it. Thanks for the look so far — the issue #139 discussion still holds, and nothing here is abandoned. |
What
Adds a Worktrees section to the sidebar, plus the operations to go with it: create, open in a new tab, open the folder, copy the path, lock/unlock, remove, and prune stale entries. Creating a worktree is also available from the Repo menu.
Refs #139.
Why
GitFourchette can already open a worktree as a repo, but it can't tell you that any exist. Everything else about worktrees still means dropping to a terminal, and then coming back to the UI to see the result.
@hgranthorner opened #139 a few days ago asking for worktree switching in the sidebar and offered to send a patch for it. I had started on this before seeing the issue and ended up with a broader version, so I'm putting it up rather than sitting on it — but it's entirely your call which shape you'd rather take, and I'm happy to cut this down to just the switching part, or to step aside if @hgranthorner's patch is the better starting point.
How
Repo.listall_worktrees()reads$GIT_COMMON_DIR/worktreesdirectly. pygit2 gives worktree names and paths, but neither the ref checked out in a worktree nor its lock state, soHEAD,lockedandgitdirare read from the worktree's private git dir. The output was checked againstgit worktree list --porcelainon a main worktree, linked worktrees (attached, detached and locked), a bare repo, and a bare repo's worktree.RepoModelcaches them the same way it caches submodules, resynced byRefreshRepoonHead/Refseffects.NewWorktreecovers the three forms ofgit worktree add: a new branch (named after the folder until you type your own), an existing branch that isn't checked out elsewhere, or a detached HEAD. Branches already checked out in another worktree are filtered out of the picker, since git would refuse them anyway.RemoveWorktreeasks a second time, offering--force, when git refuses because of uncommitted changes or untracked files. The main worktree and the one you're currently looking at are refused outright.LockWorktree/UnlockWorktree/PruneWorktreesround out the section.<repo>: <worktree>, reusing the prefixRepoModel.shortNamealready applies to submodules. Without it, two tabs showing two working copies of the same repo
look like two unrelated repos.
All git mutations go through
flowCallGit; nothing here calls libgit2 to write.Testing
test/test_tasks_worktree.py— 38 tests covering the porcelain reader (main, linked, detached, locked, bare, and a half-deleted registry entry), the sidebar section appearing and disappearing with the first and last worktree, its menus, the dialog's three modes and its validation, the tab labelling, and each task including the refusal paths.Every line this PR adds to
gitfourchette/is executed by the suite; the two defensive branches that can't be reached deterministically are marked# pragma: no coverwith the reason.One unrelated failure,
test_graphview.py::testCommitToolTip, reproduces on a cleanmasteron my machine (Arch, PyQt 6.11.0 / Qt 6.11.2), so I've left it alone.I haven't touched
CHANGELOG.md, assuming you'd rather write that entry yourself at release time — let me know if you'd like it in the PR.