diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-07 15:12:59 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-07 16:29:45 +0300 |
| commit | 745ce4254c646922d667ec13dd21963656405c12 (patch) | |
| tree | 96704b9884d95d810be16b1dad5cb48c07ccdcc8 /src | |
| parent | f61ed48eff622de4f970adff220603ea50292f07 (diff) | |
fix: handle windows worktree paths and removal
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/api/worktrees.rs | 16 | ||||
| -rw-r--r-- | src/app/worktrees.rs | 84 | ||||
| -rw-r--r-- | src/worktree.rs | 207 |
3 files changed, 282 insertions, 25 deletions
diff --git a/src/app/api/worktrees.rs b/src/app/api/worktrees.rs index d6a269ca..573771e2 100644 --- a/src/app/api/worktrees.rs +++ b/src/app/api/worktrees.rs @@ -292,6 +292,22 @@ impl App { ); } + #[cfg(windows)] + { + if !params.force + && crate::worktree::checkout_has_dirty_files(&space.checkout_path).unwrap_or(false) + { + return encode_error( + id, + "dirty_worktree_requires_force", + crate::worktree::worktree_dirty_remove_message(&space.checkout_path), + ); + } + } + + #[cfg(windows)] + self.shutdown_workspace_terminal_runtimes_for_worktree_remove(ws_idx); + let command = crate::worktree::build_worktree_remove_command( &space.repo_root, &space.checkout_path, diff --git a/src/app/worktrees.rs b/src/app/worktrees.rs index f7d906ee..f6635c23 100644 --- a/src/app/worktrees.rs +++ b/src/app/worktrees.rs @@ -548,21 +548,44 @@ impl App { } pub(crate) fn start_worktree_remove(&mut self) { - let Some(remove) = &mut self.state.worktree_remove else { + let Some((workspace_id, repo_root, path, force)) = + self.state.worktree_remove.as_mut().and_then(|remove| { + if remove.removing { + return None; + } + #[cfg(windows)] + if !remove.force_confirmation + && crate::worktree::checkout_has_dirty_files(&remove.path).unwrap_or(false) + { + remove.force_confirmation = true; + remove.error = None; + return None; + } + remove.removing = true; + remove.error = None; + Some(( + remove.workspace_id.clone(), + remove.repo_root.clone(), + remove.path.clone(), + remove.force_confirmation, + )) + }) + else { return; }; - if remove.removing { - return; + + #[cfg(windows)] + if let Some(ws_idx) = self + .state + .workspaces + .iter() + .position(|ws| ws.id == workspace_id) + { + self.shutdown_workspace_terminal_runtimes_for_worktree_remove(ws_idx); } - 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 command = crate::worktree::build_worktree_remove_command(&repo_root, &path, force); + tracing::info!(workspace_id = %workspace_id, path = %path.display(), force, "starting git worktree remove"); let event_tx = self.event_tx.clone(); std::thread::spawn(move || { let result = crate::worktree::run_worktree_command(&command); @@ -697,6 +720,23 @@ impl App { } } } + + #[cfg(windows)] + pub(crate) fn shutdown_workspace_terminal_runtimes_for_worktree_remove( + &mut self, + ws_idx: usize, + ) { + for terminal_id in self.state.terminal_ids_for_workspace(ws_idx) { + if let Some(runtime) = self.terminal_runtimes.remove(&terminal_id) { + tracing::debug!( + workspace_index = ws_idx, + terminal_id = %terminal_id, + "shutting down terminal runtime before Windows worktree removal" + ); + runtime.shutdown(); + } + } + } } #[cfg(test)] @@ -1212,15 +1252,19 @@ mod tests { 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); + + #[cfg(not(windows))] + { + 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:?}"), } - other => panic!("unexpected event: {other:?}"), } let remove = app.state.worktree_remove.as_ref().unwrap(); diff --git a/src/worktree.rs b/src/worktree.rs index 8c1d40bb..29eb7647 100644 --- a/src/worktree.rs +++ b/src/worktree.rs @@ -1,3 +1,4 @@ +use std::ffi::OsString; use std::path::{Path, PathBuf}; const DEFAULT_WORKTREE_PREFIX: &str = "worktree"; @@ -54,20 +55,70 @@ pub(crate) fn branch_to_path_slug(branch: &str) -> String { pub(crate) fn expand_tilde_path(path: &str) -> PathBuf { if path == "~" { - return std::env::var("HOME") - .map(PathBuf::from) + return home_dir_from_env(cfg!(windows), |key| std::env::var_os(key)) .unwrap_or_else(|_| PathBuf::from(path)); } - if let Some(rest) = path.strip_prefix("~/") { - return std::env::var("HOME") - .map(|home| PathBuf::from(home).join(rest)) + let tilde_rest = path.strip_prefix("~/").or_else(|| { + if cfg!(windows) { + path.strip_prefix("~\\") + } else { + None + } + }); + if let Some(rest) = tilde_rest { + return home_dir_from_env(cfg!(windows), |key| std::env::var_os(key)) + .map(|home| home.join(rest)) .unwrap_or_else(|_| PathBuf::from(path)); } PathBuf::from(path) } +fn home_dir_from_env( + is_windows: bool, + env: impl Fn(&str) -> Option<OsString>, +) -> Result<PathBuf, ()> { + if !is_windows { + return env("HOME").map(PathBuf::from).ok_or(()); + } + + if let Some(path) = usable_home_path(env("USERPROFILE")) { + return Ok(path); + } + if let (Some(drive), Some(path)) = ( + usable_home_component(env("HOMEDRIVE")), + usable_home_component(env("HOMEPATH")), + ) { + let path = path.to_string_lossy(); + if !path.starts_with(['\\', '/']) { + return usable_home_path(env("HOME")).ok_or(()); + } + let combined = format!("{}{}", drive.to_string_lossy(), path); + if let Some(path) = usable_home_path(Some(OsString::from(combined))) { + return Ok(path); + } + } + + usable_home_path(env("HOME")).ok_or(()) +} + +fn usable_home_path(value: Option<OsString>) -> Option<PathBuf> { + let value = value?; + if value.is_empty() || value == "~" { + return None; + } + Some(PathBuf::from(value)) +} + +fn usable_home_component(value: Option<OsString>) -> Option<OsString> { + let value = value?; + if value.is_empty() || value == "~" { + return None; + } + Some(value) +} + pub(crate) fn expand_tilde_absolute_path(path: &str) -> PathBuf { let path = expand_tilde_path(path); if path.is_absolute() { @@ -115,6 +166,43 @@ pub(crate) fn is_dirty_worktree_remove_error(message: &str) -> bool { && lower.contains("use --force to delete it") } +#[cfg(windows)] +pub(crate) fn worktree_dirty_remove_message(path: &Path) -> String { + format!( + "fatal: '{}' contains modified or untracked files, use --force to delete it", + path.display() + ) +} + +#[cfg(any(windows, test))] +pub(crate) fn checkout_has_dirty_files(path: &Path) -> Result<bool, String> { + let path_arg = path.display().to_string(); + let output = std::process::Command::new("git") + .args([ + "-C", + &path_arg, + "status", + "--porcelain", + "--untracked-files=all", + ]) + .output() + .map_err(|err| err.to_string())?; + + if output.status.success() { + return Ok(!output.stdout.is_empty()); + } + + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !stderr.is_empty() { + Err(stderr) + } else if !stdout.is_empty() { + Err(stdout) + } else { + Err(format!("git status failed with status {}", output.status)) + } +} + pub(crate) fn build_worktree_add_new_branch_command( repo_root: &Path, path: &Path, @@ -370,6 +458,89 @@ prunable stale } #[test] + fn home_dir_uses_windows_profile_before_literal_home() { + assert_eq!( + home_dir_from_env(true, |key| match key { + "HOME" => Some("~".into()), + "USERPROFILE" => Some(r"C:\Users\herdr".into()), + _ => None, + }), + Ok(PathBuf::from(r"C:\Users\herdr")) + ); + } + + #[test] + fn home_dir_uses_windows_drive_and_path_when_profile_is_missing() { + assert_eq!( + home_dir_from_env(true, |key| match key { + "HOMEDRIVE" => Some("C:".into()), + "HOMEPATH" => Some(r"\Users\herdr".into()), + _ => None, + }), + Ok(PathBuf::from(r"C:\Users\herdr")) + ); + } + + #[test] + fn home_dir_rejects_incomplete_windows_drive_and_path() { + assert_eq!( + home_dir_from_env(true, |key| match key { + "HOMEDRIVE" => Some("C:".into()), + "HOMEPATH" => Some("".into()), + _ => None, + }), + Err(()) + ); + assert_eq!( + home_dir_from_env(true, |key| match key { + "HOMEDRIVE" => Some("C:".into()), + "HOMEPATH" => Some("Users\\herdr".into()), + _ => None, + }), + Err(()) + ); + } + + #[cfg(not(windows))] + #[test] + fn non_windows_tilde_expansion_keeps_windows_separator_literal() { + let old_home = std::env::var_os("HOME"); + std::env::set_var("HOME", "/home/me"); + assert_eq!( + expand_tilde_path(r"~\.herdr\worktrees"), + PathBuf::from(r"~\.herdr\worktrees") + ); + if let Some(home) = old_home { + std::env::set_var("HOME", home); + } else { + std::env::remove_var("HOME"); + } + } + + #[cfg(windows)] + #[test] + fn windows_tilde_expansion_accepts_windows_separator() { + let old_home = std::env::var_os("HOME"); + let old_userprofile = std::env::var_os("USERPROFILE"); + std::env::set_var("HOME", "~"); + std::env::set_var("USERPROFILE", r"C:\Users\herdr"); + assert_eq!( + expand_tilde_path(r"~\.herdr\worktrees"), + PathBuf::from(r"C:\Users\herdr\.herdr\worktrees") + ); + if let Some(home) = old_home { + std::env::set_var("HOME", home); + } else { + std::env::remove_var("HOME"); + } + if let Some(userprofile) = old_userprofile { + std::env::set_var("USERPROFILE", userprofile); + } else { + std::env::remove_var("USERPROFILE"); + } + } + + #[test] fn default_checkout_path_appends_repo_and_branch_slug() { assert_eq!( default_checkout_path( @@ -382,6 +553,32 @@ prunable stale } #[test] + fn checkout_dirty_detection_reports_clean_and_dirty_worktrees() { + let repo = create_committed_repo("worktree-dirty-detection-repo"); + let checkout = unique_temp_path("worktree-dirty-detection-checkout"); + run_git( + &repo, + &[ + "worktree", + "add", + "--quiet", + "-b", + "worktree/dirty-detection", + checkout.to_str().unwrap(), + "HEAD", + ], + ); + + assert_eq!(checkout_has_dirty_files(&checkout), Ok(false)); + std::fs::write(checkout.join("README.md"), "dirty\n").unwrap(); + assert_eq!(checkout_has_dirty_files(&checkout), Ok(true)); + + let remove = build_worktree_remove_command(&repo, &checkout, true); + run_worktree_command(&remove).unwrap(); + let _ = std::fs::remove_dir_all(repo); + } + + #[test] fn worktree_remove_command_preserves_branch_by_not_deleting_it() { let command = build_worktree_remove_command( Path::new("/repo/herdr"), |
