aboutsummaryrefslogtreecommitdiff
path: root/src/app/worktrees.rs
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-22 02:21:12 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-22 02:23:35 +0300
commit0148c1379b76c5c0a9d05588c07dcb06ec32f93f (patch)
tree554766d959c738ca2aa433c3d4b4a7de25568008 /src/app/worktrees.rs
parenta9ff4012bdca0dbdbc77bc6e53a430689fc899be (diff)
feat: add git worktree workspace management
refs #137
Diffstat (limited to 'src/app/worktrees.rs')
-rw-r--r--src/app/worktrees.rs1090
1 files changed, 1090 insertions, 0 deletions
diff --git a/src/app/worktrees.rs b/src/app/worktrees.rs
new file mode 100644
index 00000000..d20b5fb0
--- /dev/null
+++ b/src/app/worktrees.rs
@@ -0,0 +1,1090 @@
+use std::sync::atomic::Ordering;
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use crossterm::event::{KeyCode, KeyEvent};
+
+use super::{
+ state::{WorktreeCreateState, WorktreeOpenEntry, WorktreeOpenState, WorktreeRemoveState},
+ App, Mode,
+};
+use crate::events::{AppEvent, WorktreeAddResult, WorktreeRemoveResult};
+
+impl App {
+ fn worktree_source_metadata(
+ &self,
+ ws_idx: usize,
+ ) -> Result<
+ (
+ Option<crate::workspace::WorktreeSpaceMembership>,
+ crate::workspace::GitSpaceMetadata,
+ std::path::PathBuf,
+ String,
+ ),
+ String,
+ > {
+ let Some(ws) = self.state.workspaces.get(ws_idx) else {
+ return Err("Workspace not found.".into());
+ };
+ let existing_membership = ws.worktree_space().cloned();
+ if existing_membership
+ .as_ref()
+ .is_some_and(|membership| membership.is_linked_worktree)
+ {
+ return Err(
+ "New and open worktree actions start from the repo parent workspace.".into(),
+ );
+ }
+
+ let git_space = ws.git_space().cloned().or_else(|| {
+ ws.resolved_identity_cwd_from(&self.state.terminals, &self.terminal_runtimes)
+ .as_deref()
+ .and_then(crate::workspace::git_space_metadata)
+ });
+ if git_space
+ .as_ref()
+ .is_some_and(|metadata| metadata.is_linked_worktree)
+ {
+ return Err(
+ "New and open worktree actions start from the repo parent workspace.".into(),
+ );
+ }
+
+ let space = existing_membership
+ .as_ref()
+ .map_or(git_space, |membership| {
+ Some(crate::workspace::GitSpaceMetadata {
+ key: membership.key.clone(),
+ checkout_key: membership.checkout_path.display().to_string(),
+ label: membership.label.clone(),
+ repo_root: membership.repo_root.clone(),
+ is_linked_worktree: membership.is_linked_worktree,
+ })
+ })
+ .ok_or_else(|| {
+ "Herdr worktree actions require a workspace inside a Git work tree.".to_string()
+ })?;
+ let source_checkout_path = existing_membership
+ .as_ref()
+ .map(|membership| membership.checkout_path.clone())
+ .unwrap_or_else(|| space.repo_root.clone());
+ let source_workspace_id = self.state.workspaces[ws_idx].id.clone();
+ Ok((
+ existing_membership,
+ space,
+ source_checkout_path,
+ source_workspace_id,
+ ))
+ }
+
+ pub(crate) fn open_new_linked_worktree_dialog(&mut self, ws_idx: usize) {
+ let (existing_membership, space, source_checkout_path, source_workspace_id) =
+ match self.worktree_source_metadata(ws_idx) {
+ Ok(metadata) => metadata,
+ Err(err) => {
+ self.state.config_diagnostic = Some(err);
+ return;
+ }
+ };
+
+ let repo_name = space.label.clone();
+ let seed = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|duration| duration.as_micros().min(u128::from(u64::MAX)) as u64)
+ .unwrap_or(0);
+ let branch = crate::worktree::generated_branch_slug(seed);
+ let checkout_path = crate::worktree::default_checkout_path(
+ &self.state.worktree_directory,
+ &repo_name,
+ &branch,
+ );
+
+ tracing::info!(
+ ws_idx,
+ repo_root = %space.repo_root.display(),
+ branch,
+ checkout_path = %checkout_path.display(),
+ "opening worktree dialog"
+ );
+ self.state.selected = ws_idx;
+ self.state.name_input = branch.clone();
+ self.state.name_input_replace_on_type = true;
+ self.state.worktree_create = Some(WorktreeCreateState {
+ source_workspace_id,
+ source_checkout_path,
+ source_existing_membership: existing_membership,
+ source_repo_root: space.repo_root,
+ repo_key: space.key,
+ repo_name,
+ branch,
+ checkout_path,
+ error: None,
+ creating: false,
+ });
+ self.state.mode = Mode::NewLinkedWorktree;
+ }
+
+ pub(crate) fn open_remove_linked_worktree_confirmation(&mut self, ws_idx: usize) {
+ let Some(ws) = self.state.workspaces.get(ws_idx) else {
+ return;
+ };
+ if !ws
+ .worktree_space()
+ .is_some_and(|space| space.is_linked_worktree)
+ {
+ self.state.config_diagnostic =
+ Some("This workspace is not a Herdr-managed worktree checkout.".into());
+ return;
+ }
+ let Some(space) = ws.worktree_space().cloned() else {
+ return;
+ };
+ self.state.selected = ws_idx;
+ self.state.worktree_remove = Some(WorktreeRemoveState {
+ workspace_id: ws.id.clone(),
+ repo_root: space.repo_root,
+ path: space.checkout_path,
+ error: None,
+ removing: false,
+ force_confirmation: false,
+ });
+ self.state.mode = Mode::ConfirmRemoveWorktree;
+ }
+
+ pub(crate) fn open_existing_worktree_dialog(&mut self, ws_idx: usize) {
+ let (existing_membership, space, source_checkout_path, source_workspace_id) =
+ match self.worktree_source_metadata(ws_idx) {
+ Ok(metadata) => metadata,
+ Err(err) => {
+ self.state.config_diagnostic = Some(err);
+ return;
+ }
+ };
+
+ let list = match crate::worktree::list_existing_worktrees(&space.repo_root) {
+ Ok(list) => list,
+ Err(err) => {
+ self.state.config_diagnostic = Some(err);
+ return;
+ }
+ };
+ let entries = list
+ .into_iter()
+ .filter(|entry| !entry.is_bare && !entry.is_prunable)
+ .map(|entry| {
+ let entry_checkout_path = crate::worktree::canonical_or_original(&entry.path);
+ let entry_checkout_key = entry_checkout_path.display().to_string();
+ let repo_checkout_path = crate::worktree::canonical_or_original(&space.repo_root);
+ let already_open_ws_idx = self.state.workspaces.iter().position(|ws| {
+ if let Some(membership) = ws.worktree_space() {
+ return crate::worktree::canonical_or_original(&membership.checkout_path)
+ == entry_checkout_path;
+ }
+
+ let git_space = ws.git_space().cloned().or_else(|| {
+ ws.resolved_identity_cwd_from(
+ &self.state.terminals,
+ &self.terminal_runtimes,
+ )
+ .as_deref()
+ .and_then(crate::workspace::git_space_metadata)
+ });
+ if git_space
+ .as_ref()
+ .is_some_and(|metadata| metadata.checkout_key == entry_checkout_key)
+ {
+ return true;
+ }
+
+ ws.resolved_identity_cwd_from(&self.state.terminals, &self.terminal_runtimes)
+ .as_deref()
+ .is_some_and(|cwd| {
+ crate::worktree::canonical_or_original(cwd) == entry_checkout_path
+ })
+ });
+ WorktreeOpenEntry {
+ is_linked_worktree: entry_checkout_path != repo_checkout_path,
+ path: entry.path,
+ branch: entry.branch,
+ already_open_ws_idx,
+ }
+ })
+ .collect::<Vec<_>>();
+
+ if entries.is_empty() {
+ self.state.config_diagnostic = Some("No Git worktrees found for this repo.".into());
+ return;
+ }
+
+ self.state.selected = ws_idx;
+ self.state.worktree_open = Some(WorktreeOpenState {
+ source_workspace_id,
+ source_existing_membership: existing_membership,
+ source_checkout_path,
+ source_repo_root: space.repo_root,
+ repo_key: space.key,
+ repo_name: space.label,
+ entries,
+ selected: 0,
+ error: None,
+ });
+ self.state.mode = Mode::OpenExistingWorktree;
+ }
+
+ pub(crate) fn handle_worktree_create_key(&mut self, key: KeyEvent) {
+ match key.code {
+ KeyCode::Esc => {
+ if self
+ .state
+ .worktree_create
+ .as_ref()
+ .is_some_and(|create| create.creating)
+ {
+ return;
+ }
+ self.close_worktree_create_dialog();
+ }
+ KeyCode::Enter => self.start_worktree_add(),
+ KeyCode::Backspace => {
+ if self.state.name_input_replace_on_type {
+ self.state.name_input.clear();
+ self.state.name_input_replace_on_type = false;
+ } else {
+ self.state.name_input.pop();
+ }
+ self.sync_worktree_branch_from_input();
+ }
+ KeyCode::Char(c) => {
+ if self.state.name_input_replace_on_type {
+ self.state.name_input.clear();
+ self.state.name_input_replace_on_type = false;
+ }
+ self.state.name_input.push(c);
+ self.sync_worktree_branch_from_input();
+ }
+ _ => {}
+ }
+ }
+
+ pub(crate) fn handle_worktree_open_key(&mut self, key: KeyEvent) {
+ match key.code {
+ KeyCode::Esc => {
+ self.state.worktree_open = None;
+ self.state.mode = if self.state.active.is_some() {
+ Mode::Terminal
+ } else {
+ Mode::Navigate
+ };
+ }
+ KeyCode::Up => {
+ if let Some(open) = &mut self.state.worktree_open {
+ open.selected = open.selected.saturating_sub(1);
+ }
+ }
+ KeyCode::Down => {
+ if let Some(open) = &mut self.state.worktree_open {
+ open.selected = open
+ .selected
+ .saturating_add(1)
+ .min(open.entries.len().saturating_sub(1));
+ }
+ }
+ KeyCode::Enter => self.open_selected_existing_worktree(),
+ _ => {}
+ }
+ }
+
+ pub(crate) fn open_selected_existing_worktree(&mut self) {
+ let Some(open) = self.state.worktree_open.as_ref() else {
+ return;
+ };
+ let Some(entry) = open.entries.get(open.selected).cloned() else {
+ return;
+ };
+ let source_workspace_id = open.source_workspace_id.clone();
+ let source_existing_membership = open.source_existing_membership.clone();
+ let source_checkout_path = open.source_checkout_path.clone();
+ let source_repo_root = open.source_repo_root.clone();
+ let repo_key = open.repo_key.clone();
+ let repo_name = open.repo_name.clone();
+ self.state.worktree_open = None;
+
+ if let Some(ws_idx) = entry.already_open_ws_idx {
+ self.mark_opened_existing_worktree_membership(
+ &source_workspace_id,
+ source_existing_membership,
+ source_checkout_path,
+ source_repo_root,
+ repo_key,
+ repo_name,
+ ws_idx,
+ entry.path,
+ entry.is_linked_worktree,
+ );
+ self.state.switch_workspace(ws_idx);
+ self.state.mode = Mode::Terminal;
+ return;
+ }
+
+ match self.create_workspace_with_options(entry.path.clone(), true) {
+ Ok(new_ws_idx) => {
+ self.mark_opened_existing_worktree_membership(
+ &source_workspace_id,
+ source_existing_membership,
+ source_checkout_path,
+ source_repo_root,
+ repo_key,
+ repo_name,
+ new_ws_idx,
+ entry.path,
+ entry.is_linked_worktree,
+ );
+ }
+ Err(err) => {
+ self.state.worktree_open = Some(WorktreeOpenState {
+ source_workspace_id,
+ source_existing_membership,
+ source_checkout_path,
+ source_repo_root,
+ repo_key,
+ repo_name,
+ entries: vec![entry],
+ selected: 0,
+ error: Some(format!("failed to open worktree: {err}")),
+ });
+ self.state.mode = Mode::OpenExistingWorktree;
+ }
+ }
+ }
+
+ // The caller has already extracted the open-worktree dialog state; keeping the
+ // membership fields explicit here avoids borrowing AppState across workspace creation.
+ #[allow(clippy::too_many_arguments)]
+ fn mark_opened_existing_worktree_membership(
+ &mut self,
+ source_workspace_id: &str,
+ source_existing_membership: Option<crate::workspace::WorktreeSpaceMembership>,
+ source_checkout_path: std::path::PathBuf,
+ source_repo_root: std::path::PathBuf,
+ repo_key: String,
+ repo_name: String,
+ target_ws_idx: usize,
+ target_path: std::path::PathBuf,
+ target_is_linked_worktree: bool,
+ ) {
+ if let Some(source_ws_idx) = self
+ .state
+ .workspaces
+ .iter()
+ .position(|ws| ws.id == source_workspace_id)
+ {
+ if let Some(source_membership) = source_existing_membership {
+ self.state.workspaces[source_ws_idx].worktree_space = Some(source_membership);
+ } else {
+ self.state.workspaces[source_ws_idx].worktree_space =
+ Some(crate::workspace::WorktreeSpaceMembership {
+ key: repo_key.clone(),
+ label: repo_name.clone(),
+ repo_root: source_repo_root.clone(),
+ checkout_path: source_checkout_path,
+ is_linked_worktree: false,
+ });
+ }
+ }
+ if let Some(target) = self.state.workspaces.get_mut(target_ws_idx) {
+ target.worktree_space = Some(crate::workspace::WorktreeSpaceMembership {
+ key: repo_key,
+ label: repo_name,
+ repo_root: source_repo_root,
+ checkout_path: target_path,
+ is_linked_worktree: target_is_linked_worktree,
+ });
+ }
+ self.state.mark_session_dirty();
+ }
+
+ fn close_worktree_create_dialog(&mut self) {
+ self.state.worktree_create = None;
+ self.state.name_input.clear();
+ self.state.name_input_replace_on_type = false;
+ self.state.mode = if self.state.active.is_some() {
+ Mode::Terminal
+ } else {
+ Mode::Navigate
+ };
+ }
+
+ fn sync_worktree_branch_from_input(&mut self) {
+ let Some(create) = &mut self.state.worktree_create else {
+ return;
+ };
+ create.branch = self.state.name_input.clone();
+ create.checkout_path = crate::worktree::default_checkout_path(
+ &self.state.worktree_directory,
+ &create.repo_name,
+ &create.branch,
+ );
+ create.error = None;
+ }
+
+ pub(crate) fn start_worktree_add(&mut self) {
+ self.sync_worktree_branch_from_input();
+ let Some(create) = &mut self.state.worktree_create else {
+ return;
+ };
+ let branch = create.branch.trim().to_string();
+ if branch.is_empty() {
+ create.error = Some("branch is required".into());
+ return;
+ }
+ if create.creating {
+ return;
+ }
+
+ create.branch = branch.clone();
+ self.state.name_input = branch.clone();
+ create.checkout_path = crate::worktree::default_checkout_path(
+ &self.state.worktree_directory,
+ &create.repo_name,
+ &branch,
+ );
+ create.creating = true;
+ create.error = None;
+
+ let command = crate::worktree::build_worktree_add_new_branch_command(
+ &create.source_checkout_path,
+ &create.checkout_path,
+ &create.branch,
+ "HEAD",
+ );
+ let parent_dir = create
+ .checkout_path
+ .parent()
+ .map(std::path::Path::to_path_buf);
+ tracing::info!(
+ repo_root = %create.source_repo_root.display(),
+ branch = %create.branch,
+ checkout_path = %create.checkout_path.display(),
+ "starting git worktree add"
+ );
+ let path = create.checkout_path.clone();
+ let event_tx = self.event_tx.clone();
+ std::thread::spawn(move || {
+ let result = if let Some(parent_dir) = parent_dir {
+ std::fs::create_dir_all(&parent_dir)
+ .map_err(|err| err.to_string())
+ .and_then(|()| crate::worktree::run_worktree_command(&command))
+ } else {
+ crate::worktree::run_worktree_command(&command)
+ };
+ let _ = event_tx.blocking_send(AppEvent::WorktreeAddFinished(WorktreeAddResult {
+ path,
+ result,
+ }));
+ });
+ }
+
+ pub(crate) fn handle_worktree_remove_key(&mut self, key: KeyEvent) {
+ match key.code {
+ KeyCode::Esc => {
+ if self
+ .state
+ .worktree_remove
+ .as_ref()
+ .is_some_and(|remove| remove.removing)
+ {
+ return;
+ }
+ self.state.worktree_remove = None;
+ self.state.mode = if self.state.active.is_some() {
+ Mode::Terminal
+ } else {
+ Mode::Navigate
+ };
+ }
+ KeyCode::Enter => self.start_worktree_remove(),
+ _ => {}
+ }
+ }
+
+ pub(crate) fn start_worktree_remove(&mut self) {
+ let Some(remove) = &mut self.state.worktree_remove else {
+ return;
+ };
+ if remove.removing {
+ return;
+ }
+ remove.removing = true;
+ remove.error = None;
+ let force = remove.force_confirmation;
+
+ let command =
+ crate::worktree::build_worktree_remove_command(&remove.repo_root, &remove.path, force);
+ tracing::info!(workspace_id = %remove.workspace_id, path = %remove.path.display(), force, "starting git worktree remove");
+ let path = remove.path.clone();
+ let workspace_id = remove.workspace_id.clone();
+ let event_tx = self.event_tx.clone();
+ std::thread::spawn(move || {
+ let result = crate::worktree::run_worktree_command(&command);
+ let _ =
+ event_tx.blocking_send(AppEvent::WorktreeRemoveFinished(WorktreeRemoveResult {
+ workspace_id,
+ path,
+ result,
+ }));
+ });
+ }
+
+ pub(crate) fn handle_worktree_add_finished(&mut self, result: WorktreeAddResult) {
+ let Some(create) = &mut self.state.worktree_create else {
+ return;
+ };
+ if create.checkout_path != result.path {
+ return;
+ }
+
+ match result.result {
+ Ok(()) => {
+ tracing::info!(checkout_path = %create.checkout_path.display(), "git worktree add completed");
+ let path = create.checkout_path.clone();
+ let source_workspace_id = create.source_workspace_id.clone();
+ let source_checkout_path = create.source_checkout_path.clone();
+ let source_existing_membership = create.source_existing_membership.clone();
+ let repo_key = create.repo_key.clone();
+ let repo_name = create.repo_name.clone();
+ let source_repo_root = create.source_repo_root.clone();
+ self.state.worktree_create = None;
+ self.state.name_input.clear();
+ self.state.name_input_replace_on_type = false;
+ match self.create_workspace_with_options(path.clone(), true) {
+ Ok(ws_idx) => {
+ let source_membership = source_existing_membership.unwrap_or(
+ crate::workspace::WorktreeSpaceMembership {
+ key: repo_key.clone(),
+ label: repo_name.clone(),
+ repo_root: source_repo_root.clone(),
+ checkout_path: source_checkout_path,
+ is_linked_worktree: false,
+ },
+ );
+ if let Some(ws) = self
+ .state
+ .workspaces
+ .iter_mut()
+ .find(|ws| ws.id == source_workspace_id)
+ {
+ ws.worktree_space = Some(source_membership);
+ }
+ if let Some(ws) = self.state.workspaces.get_mut(ws_idx) {
+ ws.worktree_space = Some(crate::workspace::WorktreeSpaceMembership {
+ key: repo_key,
+ label: repo_name,
+ repo_root: source_repo_root,
+ checkout_path: path,
+ is_linked_worktree: true,
+ });
+ }
+ self.state.mark_session_dirty();
+ }
+ Err(err) => {
+ self.state.config_diagnostic = Some(format!(
+ "created worktree but failed to open workspace: {err}"
+ ));
+ self.state.mode = Mode::Navigate;
+ }
+ }
+ self.render_dirty.store(true, Ordering::Release);
+ self.render_notify.notify_one();
+ }
+ Err(message) => {
+ tracing::warn!(checkout_path = %create.checkout_path.display(), error = %message, "git worktree add failed");
+ create.creating = false;
+ create.error = Some(message);
+ self.render_dirty.store(true, Ordering::Release);
+ self.render_notify.notify_one();
+ }
+ }
+ }
+ pub(crate) fn handle_worktree_remove_finished(&mut self, result: WorktreeRemoveResult) {
+ let Some(remove) = &mut self.state.worktree_remove else {
+ return;
+ };
+ if remove.workspace_id != result.workspace_id || remove.path != result.path {
+ return;
+ }
+
+ match result.result {
+ Ok(()) => {
+ tracing::info!(workspace_id = %result.workspace_id, path = %result.path.display(), "git worktree remove completed");
+ self.state.worktree_remove = None;
+ if let Some(ws_idx) = self
+ .state
+ .workspaces
+ .iter()
+ .position(|ws| ws.id == result.workspace_id)
+ {
+ let still_same_linked_worktree = self.state.workspaces[ws_idx]
+ .worktree_space()
+ .is_some_and(|space| {
+ space.is_linked_worktree && space.checkout_path == result.path
+ });
+ if still_same_linked_worktree {
+ self.state.selected = ws_idx;
+ self.state.close_selected_workspace();
+ }
+ }
+ self.state.mode = if self.state.active.is_some() {
+ Mode::Terminal
+ } else {
+ Mode::Navigate
+ };
+ self.render_dirty.store(true, Ordering::Release);
+ self.render_notify.notify_one();
+ }
+ Err(message) => {
+ tracing::warn!(workspace_id = %result.workspace_id, path = %result.path.display(), error = %message, "git worktree remove failed");
+ remove.removing = false;
+ if !remove.force_confirmation
+ && crate::worktree::is_dirty_worktree_remove_error(&message)
+ {
+ remove.force_confirmation = true;
+ remove.error = None;
+ } else {
+ remove.error = Some(message);
+ }
+ self.render_dirty.store(true, Ordering::Release);
+ self.render_notify.notify_one();
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn unique_temp_path(name: &str) -> std::path::PathBuf {
+ let nanos = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| d.as_nanos())
+ .unwrap_or(0);
+ std::env::temp_dir().join(format!("herdr-{name}-{}-{nanos}", std::process::id()))
+ }
+
+ fn run_git(repo: &std::path::Path, args: &[&str]) {
+ let status = std::process::Command::new("git")
+ .arg("-C")
+ .arg(repo)
+ .args(args)
+ .status()
+ .unwrap();
+ assert!(
+ status.success(),
+ "git command failed: git -C {} {}",
+ repo.display(),
+ args.join(" ")
+ );
+ }
+
+ fn create_committed_repo(name: &str) -> std::path::PathBuf {
+ let repo = unique_temp_path(name);
+ std::fs::create_dir_all(&repo).unwrap();
+ run_git(&repo, &["init", "--quiet"]);
+ run_git(&repo, &["config", "user.email", "herdr@example.invalid"]);
+ run_git(&repo, &["config", "user.name", "Herdr Test"]);
+ std::fs::write(repo.join("README.md"), "test\n").unwrap();
+ run_git(&repo, &["add", "README.md"]);
+ run_git(&repo, &["commit", "--quiet", "-m", "initial"]);
+ repo
+ }
+
+ fn wait_for_worktree_event(app: &mut App) -> AppEvent {
+ let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
+ while std::time::Instant::now() < deadline {
+ if let Ok(event) = app.event_rx.try_recv() {
+ return event;
+ }
+ std::thread::sleep(std::time::Duration::from_millis(10));
+ }
+ panic!("timed out waiting for worktree event");
+ }
+
+ fn app_for_worktree_tests() -> App {
+ App::new(
+ &crate::config::Config::default(),
+ true,
+ None,
+ tokio::sync::mpsc::unbounded_channel().1,
+ crate::api::EventHub::default(),
+ )
+ }
+
+ #[test]
+ fn open_selected_existing_worktree_focuses_already_open_workspace() {
+ let mut app = app_for_worktree_tests();
+ app.state.workspaces = vec![
+ crate::workspace::Workspace::test_new("main"),
+ crate::workspace::Workspace::test_new("issue"),
+ ];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.worktree_open = Some(WorktreeOpenState {
+ source_workspace_id: app.state.workspaces[0].id.clone(),
+ source_existing_membership: None,
+ source_checkout_path: "/repo/herdr".into(),
+ source_repo_root: "/repo/herdr".into(),
+ repo_key: "repo-key".into(),
+ repo_name: "herdr".into(),
+ entries: vec![WorktreeOpenEntry {
+ path: "/repo/herdr-issue".into(),
+ branch: Some("worktree/issue".into()),
+ is_linked_worktree: true,
+ already_open_ws_idx: Some(1),
+ }],
+ selected: 0,
+ error: None,
+ });
+
+ app.open_selected_existing_worktree();
+
+ assert_eq!(app.state.active, Some(1));
+ assert_eq!(app.state.selected, 1);
+ assert!(app.state.worktree_open.is_none());
+ assert!(app.state.workspaces[0].worktree_space().is_some());
+ let target_membership = app.state.workspaces[1].worktree_space().unwrap();
+ assert_eq!(target_membership.key, "repo-key");
+ assert_eq!(
+ target_membership.checkout_path,
+ std::path::PathBuf::from("/repo/herdr-issue")
+ );
+ assert!(target_membership.is_linked_worktree);
+ }
+
+ #[test]
+ fn open_existing_worktree_detects_already_open_checkout_from_subdirectory() {
+ let repo = create_committed_repo("app-worktree-open-existing-repo");
+ let checkout = unique_temp_path("app-worktree-open-existing-checkout");
+ run_git(
+ &repo,
+ &[
+ "worktree",
+ "add",
+ "--quiet",
+ "-b",
+ "worktree/open-existing",
+ checkout.to_str().unwrap(),
+ "HEAD",
+ ],
+ );
+ let subdir = checkout.join("nested");
+ std::fs::create_dir_all(&subdir).unwrap();
+
+ let mut app = app_for_worktree_tests();
+ app.state.workspaces = vec![
+ crate::workspace::Workspace::test_new("main"),
+ crate::workspace::Workspace::test_new("nested"),
+ ];
+ app.state.workspaces[0].identity_cwd = repo;
+ app.state.workspaces[1].identity_cwd = subdir;
+
+ app.open_existing_worktree_dialog(0);
+
+ let open = app.state.worktree_open.as_ref().unwrap();
+ let checkout = crate::worktree::canonical_or_original(&checkout);
+ let entry = open
+ .entries
+ .iter()
+ .find(|entry| crate::worktree::canonical_or_original(&entry.path) == checkout)
+ .unwrap_or_else(|| panic!("missing checkout in entries: {:?}", open.entries));
+ assert_eq!(entry.already_open_ws_idx, Some(1));
+ }
+
+ #[test]
+ fn worktree_create_and_open_dialogs_reject_linked_child_source() {
+ let mut app = app_for_worktree_tests();
+ app.state.workspaces = vec![crate::workspace::Workspace::test_new("issue")];
+ app.state.mode = Mode::Navigate;
+ app.state.workspaces[0].worktree_space = Some(crate::workspace::WorktreeSpaceMembership {
+ key: "repo-key".into(),
+ label: "herdr".into(),
+ repo_root: "/repo/herdr".into(),
+ checkout_path: "/repo/herdr-issue".into(),
+ is_linked_worktree: true,
+ });
+
+ app.open_new_linked_worktree_dialog(0);
+
+ assert_eq!(app.state.mode, Mode::Navigate);
+ assert!(app.state.worktree_create.is_none());
+ assert_eq!(
+ app.state.config_diagnostic.as_deref(),
+ Some("New and open worktree actions start from the repo parent workspace.")
+ );
+
+ app.state.config_diagnostic = None;
+ app.open_existing_worktree_dialog(0);
+
+ assert!(app.state.worktree_open.is_none());
+ assert_eq!(
+ app.state.config_diagnostic.as_deref(),
+ Some("New and open worktree actions start from the repo parent workspace.")
+ );
+ }
+
+ #[test]
+ fn sync_worktree_branch_updates_derived_path() {
+ let mut app = app_for_worktree_tests();
+ app.state.worktree_directory = std::path::PathBuf::from("/w");
+ app.state.name_input = "issue/137".into();
+ app.state.worktree_create = Some(WorktreeCreateState {
+ source_workspace_id: "source".into(),
+ source_checkout_path: std::path::PathBuf::from("/repo/herdr"),
+ source_existing_membership: None,
+ source_repo_root: std::path::PathBuf::from("/repo/herdr"),
+ repo_key: "repo-key".into(),
+ repo_name: "herdr".into(),
+ branch: "old".into(),
+ checkout_path: std::path::PathBuf::from("/old"),
+ error: Some("old error".into()),
+ creating: false,
+ });
+
+ app.sync_worktree_branch_from_input();
+
+ let create = app.state.worktree_create.unwrap();
+ assert_eq!(create.branch, "issue/137");
+ assert_eq!(
+ create.checkout_path,
+ std::path::PathBuf::from("/w/herdr/issue-137")
+ );
+ assert_eq!(create.error, None);
+ }
+
+ #[test]
+ fn start_worktree_add_runs_git_on_worker_and_emits_result() {
+ let repo = create_committed_repo("app-worktree-add-repo");
+ let worktree_root = unique_temp_path("app-worktree-add-root");
+ let branch = "worktree/app-worker";
+ let checkout = crate::worktree::default_checkout_path(&worktree_root, "herdr", branch);
+ let mut app = app_for_worktree_tests();
+ app.state.worktree_directory = worktree_root.clone();
+ app.state.name_input = branch.into();
+ app.state.worktree_create = Some(WorktreeCreateState {
+ source_workspace_id: "source".into(),
+ source_checkout_path: repo.clone(),
+ source_existing_membership: None,
+ source_repo_root: repo.clone(),
+ repo_key: "repo-key".into(),
+ repo_name: "herdr".into(),
+ branch: branch.into(),
+ checkout_path: checkout.clone(),
+ error: None,
+ creating: false,
+ });
+
+ app.start_worktree_add();
+
+ assert!(app
+ .state
+ .worktree_create
+ .as_ref()
+ .is_some_and(|create| create.creating));
+ let event = wait_for_worktree_event(&mut app);
+ match event {
+ AppEvent::WorktreeAddFinished(result) => {
+ assert_eq!(result.path, checkout);
+ assert_eq!(result.result, Ok(()));
+ }
+ other => panic!("unexpected event: {other:?}"),
+ }
+ assert!(checkout.join("README.md").exists());
+
+ let remove = crate::worktree::build_worktree_remove_command(&repo, &checkout, false);
+ crate::worktree::run_worktree_command(&remove).unwrap();
+ let _ = std::fs::remove_dir_all(worktree_root);
+ let _ = std::fs::remove_dir_all(repo);
+ }
+
+ #[test]
+ fn start_worktree_add_uses_source_checkout_head_as_base() {
+ let repo = create_committed_repo("app-worktree-add-source-repo");
+ let source_checkout = unique_temp_path("app-worktree-add-source-checkout");
+ run_git(
+ &repo,
+ &[
+ "worktree",
+ "add",
+ "--quiet",
+ "-b",
+ "worktree/source-base",
+ source_checkout.to_str().unwrap(),
+ "HEAD",
+ ],
+ );
+ std::fs::write(source_checkout.join("SOURCE.md"), "source branch\n").unwrap();
+ run_git(&source_checkout, &["add", "SOURCE.md"]);
+ run_git(&source_checkout, &["commit", "--quiet", "-m", "source"]);
+
+ let worktree_root = unique_temp_path("app-worktree-add-from-source-root");
+ let branch = "worktree/from-source";
+ let checkout = crate::worktree::default_checkout_path(&worktree_root, "herdr", branch);
+ let mut app = app_for_worktree_tests();
+ app.state.worktree_directory = worktree_root.clone();
+ app.state.name_input = branch.into();
+ app.state.worktree_create = Some(WorktreeCreateState {
+ source_workspace_id: "source".into(),
+ source_checkout_path: source_checkout.clone(),
+ source_existing_membership: None,
+ source_repo_root: repo.clone(),
+ repo_key: "repo-key".into(),
+ repo_name: "herdr".into(),
+ branch: branch.into(),
+ checkout_path: checkout.clone(),
+ error: None,
+ creating: false,
+ });
+
+ app.start_worktree_add();
+
+ let event = wait_for_worktree_event(&mut app);
+ match event {
+ AppEvent::WorktreeAddFinished(result) => {
+ assert_eq!(result.path, checkout);
+ assert_eq!(result.result, Ok(()));
+ }
+ other => panic!("unexpected event: {other:?}"),
+ }
+ assert!(checkout.join("SOURCE.md").exists());
+
+ let remove_new = crate::worktree::build_worktree_remove_command(&repo, &checkout, false);
+ crate::worktree::run_worktree_command(&remove_new).unwrap();
+ let remove_source =
+ crate::worktree::build_worktree_remove_command(&repo, &source_checkout, false);
+ crate::worktree::run_worktree_command(&remove_source).unwrap();
+ let _ = std::fs::remove_dir_all(worktree_root);
+ let _ = std::fs::remove_dir_all(repo);
+ }
+
+ #[test]
+ fn dirty_worktree_remove_failure_requests_force_confirmation() {
+ let path = std::path::PathBuf::from("/w/herdr/dirty");
+ let mut app = app_for_worktree_tests();
+ app.state.worktree_remove = Some(WorktreeRemoveState {
+ workspace_id: "ws".into(),
+ repo_root: std::path::PathBuf::from("/repo/herdr"),
+ path: path.clone(),
+ error: None,
+ removing: true,
+ force_confirmation: false,
+ });
+
+ app.handle_worktree_remove_finished(WorktreeRemoveResult {
+ workspace_id: "ws".into(),
+ path,
+ result: Err(
+ "fatal: '/w/herdr/dirty' contains modified or untracked files, use --force to delete it"
+ .into(),
+ ),
+ });
+
+ let remove = app.state.worktree_remove.unwrap();
+ assert!(!remove.removing);
+ assert!(remove.force_confirmation);
+ assert_eq!(remove.error, None);
+ }
+
+ #[test]
+ fn non_dirty_worktree_remove_failure_keeps_error_message() {
+ let path = std::path::PathBuf::from("/w/herdr/missing");
+ let mut app = app_for_worktree_tests();
+ app.state.worktree_remove = Some(WorktreeRemoveState {
+ workspace_id: "ws".into(),
+ repo_root: std::path::PathBuf::from("/repo/herdr"),
+ path: path.clone(),
+ error: None,
+ removing: true,
+ force_confirmation: false,
+ });
+
+ app.handle_worktree_remove_finished(WorktreeRemoveResult {
+ workspace_id: "ws".into(),
+ path,
+ result: Err("fatal: '/w/herdr/missing' is not a working tree".into()),
+ });
+
+ let remove = app.state.worktree_remove.unwrap();
+ assert!(!remove.removing);
+ assert!(!remove.force_confirmation);
+ assert_eq!(
+ remove.error,
+ Some("fatal: '/w/herdr/missing' is not a working tree".into())
+ );
+ }
+
+ #[test]
+ fn dirty_worktree_remove_retries_with_force_and_closes_workspace() {
+ let repo = create_committed_repo("app-worktree-dirty-remove-repo");
+ let checkout = unique_temp_path("app-worktree-dirty-remove-checkout");
+ run_git(
+ &repo,
+ &[
+ "worktree",
+ "add",
+ "--quiet",
+ "-b",
+ "worktree/dirty-remove",
+ checkout.to_str().unwrap(),
+ "HEAD",
+ ],
+ );
+ std::fs::write(checkout.join("README.md"), "dirty\n").unwrap();
+
+ let mut app = app_for_worktree_tests();
+ app.state.workspaces = vec![crate::workspace::Workspace::test_new("issue")];
+ let workspace_id = app.state.workspaces[0].id.clone();
+ app.state.workspaces[0].worktree_space = Some(crate::workspace::WorktreeSpaceMembership {
+ key: "repo-key".into(),
+ label: "herdr".into(),
+ repo_root: repo.clone(),
+ checkout_path: checkout.clone(),
+ is_linked_worktree: true,
+ });
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.open_remove_linked_worktree_confirmation(0);
+
+ app.start_worktree_remove();
+ let safe_event = wait_for_worktree_event(&mut app);
+ match safe_event {
+ AppEvent::WorktreeRemoveFinished(result) => {
+ assert_eq!(result.workspace_id, workspace_id);
+ assert_eq!(result.path, checkout);
+ assert!(result.result.is_err());
+ app.handle_worktree_remove_finished(result);
+ }
+ other => panic!("unexpected event: {other:?}"),
+ }
+
+ let remove = app.state.worktree_remove.as_ref().unwrap();
+ assert!(!remove.removing);
+ assert!(remove.force_confirmation);
+ assert!(checkout.exists());
+
+ app.start_worktree_remove();
+ let force_event = wait_for_worktree_event(&mut app);
+ match force_event {
+ AppEvent::WorktreeRemoveFinished(result) => {
+ assert_eq!(result.workspace_id, workspace_id);
+ assert_eq!(result.path, checkout);
+ assert_eq!(result.result, Ok(()));
+ app.handle_worktree_remove_finished(result);
+ }
+ other => panic!("unexpected event: {other:?}"),
+ }
+
+ assert!(!checkout.exists());
+ assert!(app.state.worktree_remove.is_none());
+ assert!(app.state.workspaces.is_empty());
+
+ let _ = std::fs::remove_dir_all(repo);
+ }
+}