Skip to content

Worktree management - #140

Closed
songurov wants to merge 2 commits into
jorio:masterfrom
songurov:feature/worktree-support
Closed

songurov wants to merge 2 commits into
jorio:masterfrom
songurov:feature/worktree-support

Conversation

@songurov

Copy link
Copy Markdown

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/worktrees directly. pygit2 gives worktree names and paths, but neither the ref checked out in a worktree nor its lock state, so HEAD, locked and gitdir are read from the worktree's private git dir. 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 rather than at the bottom, on the grounds that worktrees are working context rather than rarely-touched metadata. Happy to move it if you'd rather it went next to Submodules.
  • A repo with no linked worktrees doesn't get the section at all — no header, no rows. 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; the section appears with it. Within the section, the main worktree wears the working-directory icon rather than the worktree one, so it reads as the repo itself rather than as one checkout among several.
  • 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 / PruneWorktrees round out the section.
  • A worktree's tab is labelled <repo>: <worktree>, reusing the prefix RepoModel.shortName
    already 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.

python test.py          # 976 passed, 19 skipped
ruff check gitfourchette test

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 cover with the reason.

One unrelated failure, test_graphview.py::testCommitToolTip, reproduces on a clean master on 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.

Copilot AI lite review requested due to automatic review settings September 17, 2026 08:19
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.
@songurov
songurov force-pushed the feature/worktree-support branch from 57e8e2d to 294682f Compare September 17, 2026 08:21

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.bare is a Git boolean, not necessarily the literal string "true"; configurations may use valid values such as yes, on, or 1. 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.workdir unset, so os.path.normpath(None) raises TypeError before 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 from repo.path when 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.

Comment thread gitfourchette/sidebar/sidebar.py Outdated
Comment on lines +537 to +538
TaskBook.action(self, RemoveWorktree, taskArgs=data,
enabled=not worktree.is_current),

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +83 to +85
if worktree.is_main:
raise AbortTask(_("The main worktree can’t be removed."))
if worktree.is_current:

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 151956f: RemoveWorktree now refuses any worktree whose path is open in a tab, and says which tab to close. Covered by testCantRemoveAWorktreeOpenInAnotherTab.

Comment on lines +156 to +159
class PruneWorktrees(_WorktreeTask):
def flow(self):
stale = [w for w in self.repoModel.worktrees if w.prunable and not w.locked]

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +843 to +844
else: # this Repo is a linked worktree; the main one is next to $GIT_COMMON_DIR
main_path = _dirname(commondir)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
songurov added a commit to songurov/gitfourchette that referenced this pull request Sep 19, 2026
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.
@songurov

Copy link
Copy Markdown
Author

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.

@songurov songurov closed this Sep 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants