aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-07 15:12:59 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-07 16:29:45 +0300
commit745ce4254c646922d667ec13dd21963656405c12 (patch)
tree96704b9884d95d810be16b1dad5cb48c07ccdcc8 /src
parentf61ed48eff622de4f970adff220603ea50292f07 (diff)
fix: handle windows worktree paths and removal
Diffstat (limited to 'src')
-rw-r--r--src/app/api/worktrees.rs16
-rw-r--r--src/app/worktrees.rs84
-rw-r--r--src/worktree.rs207
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"),