Skip to content

Commit 46a2bf9

Browse files
stevenpollackclaude
andcommitted
feat(worktrees): create a new worktree from the popup
Press `c` in the Worktrees popup to open a Create Worktree input. Enter a path (absolute, or relative to the repo root); gitui creates a linked worktree there with a new branch named after the final path component, then the list refreshes to show it. - asyncgit: new `create_worktree(repo_path, path) -> Result<PathBuf>` via git2 `Repository::worktree` (no-ref -> new branch at HEAD), + 2 tests - new src/popups/create_worktree.rs (path input, no branch-name normalization; live-validates the derived branch name) - wiring: queue `CreateWorktree`, app field/ctor/macros/handler, worktrees popup `c` trigger, strings; App::update refreshes an open worktrees list Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BMQxBdr5f3B3jmDtdvGtTu
1 parent 8672275 commit 46a2bf9

9 files changed

Lines changed: 313 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Added
1111
* support x509 commit signing [[@kaden-l-nelson](https://github.com/kaden-l-nelson)] ([#2514](https://github.com/gitui-org/gitui/issues/2514))
1212
* worktrees popup: list worktrees and switch between them ([Shift+W])
13+
* worktrees popup: create a new worktree with a new branch ([c] from the worktrees list)
1314

1415
### Changed
1516
* use [tombi](https://github.com/tombi-toml/tombi) for all toml file formatting

asyncgit/src/sync/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ pub use utils::{
109109
get_head, get_head_tuple, repo_dir, repo_open_error,
110110
stage_add_all, stage_add_file, stage_addremoved, Head,
111111
};
112-
pub use worktree::{get_worktrees, WorktreeInfo};
112+
pub use worktree::{create_worktree, get_worktrees, WorktreeInfo};
113113

114114
pub use git2::ResetType;
115115

asyncgit/src/sync/worktree.rs

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use git2::{Repository, WorktreeLockStatus};
66
use scopetime::scope_time;
77

88
use super::{repo, RepoPath};
9-
use crate::error::Result;
9+
use crate::error::{Error, Result};
1010

1111
/// name reported for the primary working tree
1212
const MAIN_WORKTREE_NAME: &str = "(main)";
@@ -122,6 +122,44 @@ fn linked_worktree_info(
122122
})
123123
}
124124

125+
/// Creates a new linked worktree at `worktree_path`, checking out a
126+
/// new branch named after the final path component.
127+
///
128+
/// `worktree_path` may be absolute or relative to the repository's
129+
/// working directory. Returns the absolute path of the created
130+
/// worktree.
131+
pub fn create_worktree(
132+
repo_path: &RepoPath,
133+
worktree_path: &str,
134+
) -> Result<PathBuf> {
135+
scope_time!("create_worktree");
136+
137+
let repo = repo(repo_path)?;
138+
139+
let requested = Path::new(worktree_path);
140+
141+
let target = if requested.is_absolute() {
142+
requested.to_path_buf()
143+
} else {
144+
repo.workdir().ok_or(Error::NoWorkDir)?.join(requested)
145+
};
146+
147+
let name = target
148+
.file_name()
149+
.and_then(|n| n.to_str())
150+
.ok_or_else(|| {
151+
Error::Generic(
152+
"invalid worktree path: no final component"
153+
.to_string(),
154+
)
155+
})?
156+
.to_string();
157+
158+
let worktree = repo.worktree(&name, &target, None)?;
159+
160+
Ok(worktree.path().to_path_buf())
161+
}
162+
125163
/// short name of the branch a repo's HEAD points at, or `None` when
126164
/// HEAD is unborn or detached.
127165
fn head_branch(repo: &Repository) -> Option<String> {
@@ -152,7 +190,7 @@ fn same_workdir(path: &Path, current: Option<&Path>) -> bool {
152190

153191
#[cfg(test)]
154192
mod tests {
155-
use super::{get_worktrees, WorktreeInfo};
193+
use super::{create_worktree, get_worktrees, WorktreeInfo};
156194
use crate::sync::{tests::repo_init, RepoPath};
157195
use pretty_assertions::assert_eq;
158196

@@ -223,4 +261,40 @@ mod tests {
223261
assert_eq!(list[0].name, "(main)");
224262
assert!(list[0].branch.is_none());
225263
}
264+
265+
#[test]
266+
fn test_create_worktree_new_branch() {
267+
let (_td, repo) = repo_init().unwrap();
268+
let root = repo.path().parent().unwrap();
269+
let repo_path: RepoPath = root.to_str().unwrap().into();
270+
271+
// keep the linked worktree outside the main workdir; wt_dir
272+
// must stay alive for the whole test.
273+
let wt_dir = tempfile::TempDir::new().unwrap();
274+
let wt_path = wt_dir.path().join("feature-x");
275+
276+
let created =
277+
create_worktree(&repo_path, wt_path.to_str().unwrap())
278+
.unwrap();
279+
280+
assert!(created.ends_with("feature-x"));
281+
282+
let list = get_worktrees(&repo_path).unwrap();
283+
let wt = find(&list, "feature-x");
284+
assert!(wt.path.is_absolute());
285+
// libgit2 names the new branch after the worktree.
286+
assert_eq!(wt.branch.as_deref(), Some("feature-x"));
287+
assert!(!wt.is_current);
288+
}
289+
290+
#[test]
291+
fn test_create_worktree_rejects_pathless_name() {
292+
let (_td, repo) = repo_init().unwrap();
293+
let root = repo.path().parent().unwrap();
294+
let repo_path: RepoPath = root.to_str().unwrap().into();
295+
296+
// a path ending in ".." has no usable final component
297+
let res = create_worktree(&repo_path, "..");
298+
assert!(res.is_err());
299+
}
226300
}

src/app.rs

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,14 @@ use crate::{
1414
AppOption, BlameFilePopup, BranchListPopup,
1515
CheckoutOptionPopup, CommitPopup, CompareCommitsPopup,
1616
ConfirmPopup, CreateBranchPopup, CreateRemotePopup,
17-
ExternalEditorPopup, FetchPopup, FileRevlogPopup,
18-
FuzzyFindPopup, GotoLinePopup, HelpPopup, InspectCommitPopup,
19-
LogSearchPopupPopup, MsgPopup, OptionsPopup, PullPopup,
20-
PushPopup, PushTagsPopup, RemoteListPopup, RenameBranchPopup,
21-
RenameRemotePopup, ResetPopup, RevisionFilesPopup,
22-
StashMsgPopup, SubmodulesListPopup, TagCommitPopup,
23-
TagListPopup, UpdateRemoteUrlPopup, WorktreesPopup,
17+
CreateWorktreePopup, ExternalEditorPopup, FetchPopup,
18+
FileRevlogPopup, FuzzyFindPopup, GotoLinePopup, HelpPopup,
19+
InspectCommitPopup, LogSearchPopupPopup, MsgPopup,
20+
OptionsPopup, PullPopup, PushPopup, PushTagsPopup,
21+
RemoteListPopup, RenameBranchPopup, RenameRemotePopup,
22+
ResetPopup, RevisionFilesPopup, StashMsgPopup,
23+
SubmodulesListPopup, TagCommitPopup, TagListPopup,
24+
UpdateRemoteUrlPopup, WorktreesPopup,
2425
},
2526
queue::{
2627
Action, AppTabs, InternalEvent, NeedsUpdate, Queue,
@@ -89,6 +90,7 @@ pub struct App {
8990
fetch_popup: FetchPopup,
9091
tag_commit_popup: TagCommitPopup,
9192
create_branch_popup: CreateBranchPopup,
93+
create_worktree_popup: CreateWorktreePopup,
9294
create_remote_popup: CreateRemotePopup,
9395
rename_remote_popup: RenameRemotePopup,
9496
update_remote_url_popup: UpdateRemoteUrlPopup,
@@ -213,6 +215,7 @@ impl App {
213215
fetch_popup: FetchPopup::new(&env),
214216
tag_commit_popup: TagCommitPopup::new(&env),
215217
create_branch_popup: CreateBranchPopup::new(&env),
218+
create_worktree_popup: CreateWorktreePopup::new(&env),
216219
create_remote_popup: CreateRemotePopup::new(&env),
217220
rename_remote_popup: RenameRemotePopup::new(&env),
218221
update_remote_url_popup: UpdateRemoteUrlPopup::new(&env),
@@ -418,6 +421,7 @@ impl App {
418421
self.stashing_tab.update()?;
419422
self.stashlist_tab.update()?;
420423
self.reset_popup.update()?;
424+
self.worktree_popup.update_worktrees()?;
421425

422426
self.update_commands();
423427

@@ -523,6 +527,7 @@ impl App {
523527
reset_popup,
524528
checkout_option_popup,
525529
create_branch_popup,
530+
create_worktree_popup,
526531
create_remote_popup,
527532
rename_remote_popup,
528533
update_remote_url_popup,
@@ -566,6 +571,7 @@ impl App {
566571
reset_popup,
567572
checkout_option_popup,
568573
create_branch_popup,
574+
create_worktree_popup,
569575
rename_branch_popup,
570576
revision_files_popup,
571577
fuzzy_find_popup,
@@ -797,6 +803,9 @@ impl App {
797803
InternalEvent::CreateBranch => {
798804
self.create_branch_popup.open()?;
799805
}
806+
InternalEvent::CreateWorktree => {
807+
self.create_worktree_popup.open()?;
808+
}
800809
InternalEvent::RenameBranch(branch_ref, cur_name) => {
801810
self.rename_branch_popup
802811
.open(branch_ref, cur_name)?;

src/popups/create_worktree.rs

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
use crate::components::{
2+
visibility_blocking, CommandBlocking, CommandInfo, Component,
3+
DrawableComponent, EventState, InputType, TextInputComponent,
4+
};
5+
use crate::{
6+
app::Environment,
7+
keys::{key_match, SharedKeyConfig},
8+
queue::{InternalEvent, NeedsUpdate, Queue},
9+
strings,
10+
ui::style::SharedTheme,
11+
};
12+
use anyhow::Result;
13+
use asyncgit::sync::{self, RepoPathRef};
14+
use crossterm::event::Event;
15+
use easy_cast::Cast;
16+
use ratatui::{layout::Rect, widgets::Paragraph, Frame};
17+
18+
pub struct CreateWorktreePopup {
19+
repo: RepoPathRef,
20+
input: TextInputComponent,
21+
queue: Queue,
22+
key_config: SharedKeyConfig,
23+
theme: SharedTheme,
24+
}
25+
26+
impl DrawableComponent for CreateWorktreePopup {
27+
fn draw(&self, f: &mut Frame, rect: Rect) -> Result<()> {
28+
if self.is_visible() {
29+
self.input.draw(f, rect)?;
30+
self.draw_warnings(f);
31+
}
32+
33+
Ok(())
34+
}
35+
}
36+
37+
impl Component for CreateWorktreePopup {
38+
fn commands(
39+
&self,
40+
out: &mut Vec<CommandInfo>,
41+
force_all: bool,
42+
) -> CommandBlocking {
43+
if self.is_visible() || force_all {
44+
self.input.commands(out, force_all);
45+
46+
out.push(CommandInfo::new(
47+
strings::commands::create_worktree_confirm_msg(
48+
&self.key_config,
49+
),
50+
true,
51+
true,
52+
));
53+
}
54+
55+
visibility_blocking(self)
56+
}
57+
58+
fn event(&mut self, ev: &Event) -> Result<EventState> {
59+
if self.is_visible() {
60+
if self.input.event(ev)?.is_consumed() {
61+
return Ok(EventState::Consumed);
62+
}
63+
64+
if let Event::Key(e) = ev {
65+
if key_match(e, self.key_config.keys.enter) {
66+
self.create_worktree();
67+
}
68+
69+
return Ok(EventState::Consumed);
70+
}
71+
}
72+
Ok(EventState::NotConsumed)
73+
}
74+
75+
fn is_visible(&self) -> bool {
76+
self.input.is_visible()
77+
}
78+
79+
fn hide(&mut self) {
80+
self.input.hide();
81+
}
82+
83+
fn show(&mut self) -> Result<()> {
84+
self.input.show()?;
85+
86+
Ok(())
87+
}
88+
}
89+
90+
impl CreateWorktreePopup {
91+
///
92+
pub fn new(env: &Environment) -> Self {
93+
Self {
94+
queue: env.queue.clone(),
95+
input: TextInputComponent::new(
96+
env,
97+
&strings::create_worktree_popup_title(
98+
&env.key_config,
99+
),
100+
&strings::create_worktree_popup_msg(&env.key_config),
101+
true,
102+
)
103+
.with_input_type(InputType::Singleline),
104+
theme: env.theme.clone(),
105+
key_config: env.key_config.clone(),
106+
repo: env.repo.clone(),
107+
}
108+
}
109+
110+
///
111+
pub fn open(&mut self) -> Result<()> {
112+
self.show()?;
113+
114+
Ok(())
115+
}
116+
117+
///
118+
pub fn create_worktree(&mut self) {
119+
let path = self.input.get_text().to_string();
120+
121+
if path.trim().is_empty() {
122+
self.hide();
123+
return;
124+
}
125+
126+
let res = sync::create_worktree(&self.repo.borrow(), &path);
127+
128+
self.input.clear();
129+
self.hide();
130+
131+
match res {
132+
Ok(_) => {
133+
self.queue
134+
.push(InternalEvent::Update(NeedsUpdate::ALL));
135+
}
136+
Err(e) => {
137+
log::error!("create worktree: {e}");
138+
self.queue.push(InternalEvent::ShowErrorMsg(
139+
format!("create worktree error:\n{e}"),
140+
));
141+
}
142+
}
143+
}
144+
145+
fn draw_warnings(&self, f: &mut Frame) {
146+
let current_text = self.input.get_text();
147+
148+
let derived_name = std::path::Path::new(current_text)
149+
.file_name()
150+
.and_then(|s| s.to_str());
151+
152+
if let Some(derived_name) = derived_name {
153+
let valid = sync::validate_branch_name(derived_name)
154+
.unwrap_or_default();
155+
156+
if !valid {
157+
let msg = strings::branch_name_invalid();
158+
let msg_length: u16 = msg.len().cast();
159+
let w = Paragraph::new(msg)
160+
.style(self.theme.text_danger());
161+
162+
let rect = {
163+
let mut rect = self.input.get_area();
164+
rect.y += rect.height.saturating_sub(1);
165+
rect.height = 1;
166+
let offset =
167+
rect.width.saturating_sub(msg_length + 1);
168+
rect.width =
169+
rect.width.saturating_sub(offset + 1);
170+
rect.x += offset;
171+
172+
rect
173+
};
174+
175+
f.render_widget(w, rect);
176+
}
177+
}
178+
}
179+
}

src/popups/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ mod compare_commits;
66
mod confirm;
77
mod create_branch;
88
mod create_remote;
9+
mod create_worktree;
910
mod externaleditor;
1011
mod fetch;
1112
mod file_revlog;
@@ -39,6 +40,7 @@ pub use compare_commits::CompareCommitsPopup;
3940
pub use confirm::ConfirmPopup;
4041
pub use create_branch::CreateBranchPopup;
4142
pub use create_remote::CreateRemotePopup;
43+
pub use create_worktree::CreateWorktreePopup;
4244
pub use externaleditor::ExternalEditorPopup;
4345
pub use fetch::FetchPopup;
4446
pub use file_revlog::{FileRevOpen, FileRevlogPopup};

0 commit comments

Comments
 (0)