Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
Prev Previous commit
Next Next commit
feat(worktrees): list worktrees and switch between them
Add a Worktrees popup (Shift+W from the Status tab) listing the primary
and linked worktrees with branch, path, and lock/validity markers, and
switch gitui to the selected worktree on Enter by reusing the existing
OpenRepo reopen mechanism (absolute worktree paths pass through the
handler unchanged).

Backend: asyncgit sync::get_worktrees builds WorktreeInfo entries via
git2 (worktrees/find_worktree/open_from_worktree), synthesizing the
primary tree from commondir and flagging the current worktree; detached
HEAD reports no branch.

- new asyncgit/src/sync/worktree.rs (+ sync mod wiring, 3 tests)
- new src/popups/worktrees.rs (+ app/queue/keys/strings/status wiring)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMQxBdr5f3B3jmDtdvGtTu
  • Loading branch information
stevenpollack and claude committed Jul 13, 2026
commit 51f7f347ef53ed5229916f30161d4b1d9eee0421
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added
* support x509 commit signing [[@kaden-l-nelson](https://github.com/kaden-l-nelson)] ([#2514](https://github.com/gitui-org/gitui/issues/2514))
* worktrees popup: list worktrees and switch between them ([Shift+W])

### Changed
* use [tombi](https://github.com/tombi-toml/tombi) for all toml file formatting
Expand Down
2 changes: 2 additions & 0 deletions asyncgit/src/sync/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ mod submodules;
mod tags;
mod tree;
pub mod utils;
mod worktree;

pub use blame::{blame_file, BlameHunk, FileBlame};
pub use branch::{
Expand Down Expand Up @@ -108,6 +109,7 @@ pub use utils::{
get_head, get_head_tuple, repo_dir, repo_open_error,
stage_add_all, stage_add_file, stage_addremoved, Head,
};
pub use worktree::{get_worktrees, WorktreeInfo};

pub use git2::ResetType;

Expand Down
226 changes: 226 additions & 0 deletions asyncgit/src/sync/worktree.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
//! read-only listing of git worktrees

use std::path::{Path, PathBuf};

use git2::{Repository, WorktreeLockStatus};
use scopetime::scope_time;

use super::{repo, RepoPath};
use crate::error::Result;

/// name reported for the primary working tree
const MAIN_WORKTREE_NAME: &str = "(main)";

/// Information about one git worktree (the primary tree or a linked one).
#[derive(Debug, Clone)]
pub struct WorktreeInfo {
/// Linked-worktree name; "(main)" for the primary working tree.
pub name: String,
/// Absolute path to the worktree's working directory.
pub path: PathBuf,
/// Short name of the checked-out branch (HEAD), or None if
/// detached/unborn.
pub branch: Option<String>,
/// Whether the worktree's working directory is present/valid.
pub is_valid: bool,
/// Whether the worktree is locked.
pub is_locked: bool,
/// Whether this is the worktree gitui is currently operating in.
pub is_current: bool,
}

/// Lists the worktrees of the repo at `repo_path`, returning the
/// primary working tree first followed by any linked worktrees.
pub fn get_worktrees(
repo_path: &RepoPath,
) -> Result<Vec<WorktreeInfo>> {
scope_time!("get_worktrees");

let repo = repo(repo_path)?;

// working dir gitui is currently operating in, used to flag the
// matching entry as `is_current`.
let current = repo.workdir();

let mut worktrees = Vec::new();

if let Some(main) = main_worktree_info(&repo, current) {
worktrees.push(main);
}

// `iter()` yields `Result<Option<&str>, Error>`; the two
// flattens drop unreadable/non-utf8 names, leaving `&str`.
for name in repo.worktrees()?.iter().flatten().flatten() {
worktrees.push(linked_worktree_info(&repo, name, current)?);
}

Ok(worktrees)
}

/// synthesizes the entry for the primary working tree, which is not
/// part of `Repository::worktrees`. returns `None` for bare repos
/// (no working tree) or when the primary workdir cannot be located.
fn main_worktree_info(
repo: &Repository,
current: Option<&Path>,
) -> Option<WorktreeInfo> {
if repo.is_bare() {
return None;
}

// primary working dir and the repo handle used to read its branch.
let (main_workdir, branch) = if repo.is_worktree() {
// gitui is operating inside a linked worktree; the primary
// tree sits next to the shared common git dir.
let workdir =
repo.commondir().parent().map(Path::to_path_buf)?;

let branch = Repository::open(&workdir)
.ok()
.and_then(|r| head_branch(&r));

(workdir, branch)
} else {
let workdir = repo.workdir()?;

(workdir.to_path_buf(), head_branch(repo))
};

Some(WorktreeInfo {
is_current: same_workdir(&main_workdir, current),
name: MAIN_WORKTREE_NAME.to_string(),
path: main_workdir,
branch,
is_valid: true,
is_locked: false,
})
}

/// builds the entry for a single linked worktree.
fn linked_worktree_info(
repo: &Repository,
name: &str,
current: Option<&Path>,
) -> Result<WorktreeInfo> {
let wt = repo.find_worktree(name)?;

let branch = Repository::open_from_worktree(&wt)
.ok()
.and_then(|r| head_branch(&r));

let is_locked = wt.is_locked().is_ok_and(|status| {
matches!(status, WorktreeLockStatus::Locked(_))
});

Ok(WorktreeInfo {
is_current: same_workdir(wt.path(), current),
name: name.to_string(),
path: wt.path().to_path_buf(),
branch,
is_valid: wt.validate().is_ok(),
is_locked,
})
}

/// short name of the branch a repo's HEAD points at, or `None` when
/// HEAD is unborn or detached.
fn head_branch(repo: &Repository) -> Option<String> {
// a detached HEAD points at a commit, not a branch, and its
// shorthand is "HEAD" rather than a branch name.
if repo.head_detached().unwrap_or(false) {
return None;
}

repo.head()
.ok()
.as_ref()
.and_then(|head| head.shorthand().ok().map(String::from))
}

/// whether `path` and `current` refer to the same working directory,
/// comparing canonicalized paths and falling back to raw equality when
/// canonicalization fails.
fn same_workdir(path: &Path, current: Option<&Path>) -> bool {
current.is_some_and(|current| {
let canon = |p: &Path| std::fs::canonicalize(p).ok();
match (canon(path), canon(current)) {
(Some(a), Some(b)) => a == b,
_ => path == current,
}
})
}

#[cfg(test)]
mod tests {
use super::{get_worktrees, WorktreeInfo};
use crate::sync::{tests::repo_init, RepoPath};
use pretty_assertions::assert_eq;

fn find<'a>(
list: &'a [WorktreeInfo],
name: &str,
) -> &'a WorktreeInfo {
list.iter().find(|w| w.name == name).unwrap()
}

#[test]
fn test_lists_primary_and_linked() {
let (_td, repo) = repo_init().unwrap();

let root = repo.path().parent().unwrap();
let repo_path: RepoPath = root.to_str().unwrap().into();

// linked worktree kept outside the main workdir to avoid
// nesting; `wt_dir` must stay alive for the whole test.
let wt_dir = tempfile::TempDir::new().unwrap();
let wt_path = wt_dir.path().join("wt1");
repo.worktree("wt1", &wt_path, None).unwrap();

let list = get_worktrees(&repo_path).unwrap();

assert_eq!(list.len(), 2);

let linked = find(&list, "wt1");
assert!(linked.path.is_absolute());
assert!(linked.path.ends_with("wt1"));
// git names the branch after the worktree by default.
assert!(linked.branch.is_some());
assert!(!linked.is_current);

let primary = find(&list, "(main)");
assert!(primary.is_current);
}

#[test]
fn test_primary_only() {
let (_td, repo) = repo_init().unwrap();

let root = repo.path().parent().unwrap();
let repo_path: RepoPath = root.to_str().unwrap().into();

let list = get_worktrees(&repo_path).unwrap();

assert_eq!(list.len(), 1);
assert_eq!(list[0].name, "(main)");
assert!(list[0].is_current);
assert!(list[0].is_valid);
assert!(!list[0].is_locked);
}

#[test]
fn test_detached_head_has_no_branch() {
let (_td, repo) = repo_init().unwrap();

let root = repo.path().parent().unwrap();
let repo_path: RepoPath = root.to_str().unwrap().into();

let oid = repo.head().unwrap().target().unwrap();
repo.set_head_detached(oid).unwrap();

let list = get_worktrees(&repo_path).unwrap();

assert_eq!(list.len(), 1);
assert_eq!(list[0].name, "(main)");
assert!(list[0].branch.is_none());
}
}
9 changes: 8 additions & 1 deletion src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ use crate::{
PushPopup, PushTagsPopup, RemoteListPopup, RenameBranchPopup,
RenameRemotePopup, ResetPopup, RevisionFilesPopup,
StashMsgPopup, SubmodulesListPopup, TagCommitPopup,
TagListPopup, UpdateRemoteUrlPopup,
TagListPopup, UpdateRemoteUrlPopup, WorktreesPopup,
},
queue::{
Action, AppTabs, InternalEvent, NeedsUpdate, Queue,
Expand Down Expand Up @@ -97,6 +97,7 @@ pub struct App {
select_branch_popup: BranchListPopup,
options_popup: OptionsPopup,
submodule_popup: SubmodulesListPopup,
worktree_popup: WorktreesPopup,
tags_popup: TagListPopup,
reset_popup: ResetPopup,
checkout_option_popup: CheckoutOptionPopup,
Expand Down Expand Up @@ -221,6 +222,7 @@ impl App {
tags_popup: TagListPopup::new(&env),
options_popup: OptionsPopup::new(&env),
submodule_popup: SubmodulesListPopup::new(&env),
worktree_popup: WorktreesPopup::new(&env),
log_search_popup: LogSearchPopupPopup::new(&env),
fuzzy_find_popup: FuzzyFindPopup::new(&env),
do_quit: QuitState::None,
Expand Down Expand Up @@ -523,6 +525,7 @@ impl App {
select_branch_popup,
revision_files_popup,
submodule_popup,
worktree_popup,
tags_popup,
options_popup,
help_popup,
Expand Down Expand Up @@ -552,6 +555,7 @@ impl App {
rename_remote_popup,
update_remote_url_popup,
submodule_popup,
worktree_popup,
tags_popup,
reset_popup,
checkout_option_popup,
Expand Down Expand Up @@ -797,6 +801,9 @@ impl App {
InternalEvent::ViewSubmodules => {
self.submodule_popup.open()?;
}
InternalEvent::ViewWorktrees => {
self.worktree_popup.open()?;
}
InternalEvent::Tags => {
self.tags_popup.open()?;
}
Expand Down
2 changes: 2 additions & 0 deletions src/keys/key_list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ pub struct KeysList {
pub stage_unstage_item: GituiKeyEvent,
pub tag_annotate: GituiKeyEvent,
pub view_submodules: GituiKeyEvent,
pub view_worktrees: GituiKeyEvent,
pub view_remotes: GituiKeyEvent,
pub update_remote_name: GituiKeyEvent,
pub update_remote_url: GituiKeyEvent,
Expand Down Expand Up @@ -216,6 +217,7 @@ impl Default for KeysList {
stage_unstage_item: GituiKeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
tag_annotate: GituiKeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL),
view_submodules: GituiKeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT),
view_worktrees: GituiKeyEvent::new(KeyCode::Char('W'), KeyModifiers::SHIFT),
view_remotes: GituiKeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
update_remote_name: GituiKeyEvent::new(KeyCode::Char('n'),KeyModifiers::NONE),
update_remote_url: GituiKeyEvent::new(KeyCode::Char('u'),KeyModifiers::NONE),
Expand Down
2 changes: 2 additions & 0 deletions src/popups/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ mod submodules;
mod tag_commit;
mod taglist;
mod update_remote_url;
mod worktrees;

pub use blame_file::{BlameFileOpen, BlameFilePopup};
pub use branchlist::BranchListPopup;
Expand Down Expand Up @@ -61,6 +62,7 @@ pub use submodules::SubmodulesListPopup;
pub use tag_commit::TagCommitPopup;
pub use taglist::TagListPopup;
pub use update_remote_url::UpdateRemoteUrlPopup;
pub use worktrees::WorktreesPopup;

use crate::ui::style::Theme;
use ratatui::{
Expand Down
Loading