diff --git a/docs/next/website/src/content/docs/cli-reference.mdx b/docs/next/website/src/content/docs/cli-reference.mdx index 7390243de..666b946c0 100644 --- a/docs/next/website/src/content/docs/cli-reference.mdx +++ b/docs/next/website/src/content/docs/cli-reference.mdx @@ -183,7 +183,7 @@ herdr worktree remove --workspace ID [--force] [--trust-repository] Worktrees are normal Herdr workspaces with Git checkout provenance. `worktree create` creates a Git worktree checkout, opens it as a workspace, and groups it with the parent repo workspace. If `--branch` names an existing local branch, Herdr checks it out; otherwise it creates the branch from `--base` or `HEAD`. Without `--path`, Herdr creates the checkout under `//`. -`workspace close` closes only Herdr state. Closing a primary workspace while linked-worktree workspaces are open requires `--group`; without it, the command leaves the group open and returns `workspace_group_close_required`. To delete the checkout, run `worktree remove`. It runs `git worktree remove`, never deletes the branch, and requires `--force` when Git refuses a dirty checkout. +`workspace close` closes only Herdr state. Closing a primary workspace while linked-worktree workspaces are open requires `--group`; without it, the command leaves the group open and returns `workspace_group_close_required`. To delete the checkout, run `worktree remove`. It runs `git worktree remove`, never deletes the branch, and requires `--force` when Git refuses a dirty checkout or a checkout with initialized submodules. Git rejects repositories owned by another user by default. If you have independently verified the repository, pass `--trust-repository` to trust its resolved path for that command only. Herdr does not change your Git configuration. diff --git a/docs/next/website/src/content/docs/ja/cli-reference.mdx b/docs/next/website/src/content/docs/ja/cli-reference.mdx index 08e35f441..40f8398f5 100644 --- a/docs/next/website/src/content/docs/ja/cli-reference.mdx +++ b/docs/next/website/src/content/docs/ja/cli-reference.mdx @@ -179,7 +179,7 @@ herdr worktree remove --workspace ID [--force] [--trust-repository] worktree は、Git チェックアウトの出自情報を持つ通常の Herdr ワークスペースです。`worktree create` は Git worktree のチェックアウトを作成し、ワークスペースとして開き、親リポジトリのワークスペースとグループ化します。`--branch` が既存のローカルブランチを指す場合はそれをチェックアウトし、そうでなければ `--base` または `HEAD` からブランチを作成します。`--path` がない場合、チェックアウトは `//` の下に作成されます。 -`workspace close` は Herdr の状態だけを閉じます。リンクされた worktree ワークスペースが開いている間に親ワークスペースを閉じるには `--group` が必要です。指定しない場合、グループは開いたままになり `workspace_group_close_required` が返されます。`worktree remove` が明示的なチェックアウト削除の経路です。`git worktree remove` を実行し、ブランチは決して削除せず、Git がダーティなチェックアウトを拒否する場合は `--force` が必要です。 +`workspace close` は Herdr の状態だけを閉じます。リンクされた worktree ワークスペースが開いている間に親ワークスペースを閉じるには `--group` が必要です。指定しない場合、グループは開いたままになり `workspace_group_close_required` が返されます。`worktree remove` が明示的なチェックアウト削除の経路です。`git worktree remove` を実行し、ブランチは決して削除せず、Git がダーティなチェックアウト、または初期化済みサブモジュールを含むチェックアウトを拒否する場合は `--force` が必要です。 Git はデフォルトで、別のユーザーが所有するリポジトリを拒否します。リポジトリを別途確認済みの場合は `--trust-repository` を渡すと、解決されたパスをそのコマンドでのみ信頼します。Herdr は Git 設定を変更しません。 diff --git a/docs/next/website/src/content/docs/ja/socket-api.mdx b/docs/next/website/src/content/docs/ja/socket-api.mdx index eb693a51f..f02ea9504 100644 --- a/docs/next/website/src/content/docs/ja/socket-api.mdx +++ b/docs/next/website/src/content/docs/ja/socket-api.mdx @@ -266,7 +266,7 @@ reason の候補は `shown`、`disabled`、`rate_limited`、`no_foreground_clien `client.window_title.clear` はタイトルを `ui.window_title` に戻します。レスポンスは `type: "client_window_title"` で、`changed` と、`set`、`cleared`、`no_foreground_client` のいずれかの reason を含みます。 -Worktree メソッドは Git チェックアウトを Herdr ワークスペースとして管理します。`worktree.create` はチェックアウトを作成し、新しい `workspace`、`tab`、`root_pane`、`worktree` のレコードを返します。要求されたブランチがローカルに既存ならそれをチェックアウトし、なければ要求されたベースまたは `HEAD` からブランチを作成します。`worktree.open` は既存のチェックアウトを開くか、すでに開いているワークスペースを返します。`worktree.remove` はリンクされた子ワークスペースに対して `git worktree remove` を実行し、ブランチは決して削除しません。 +Worktree メソッドは Git チェックアウトを Herdr ワークスペースとして管理します。`worktree.create` はチェックアウトを作成し、新しい `workspace`、`tab`、`root_pane`、`worktree` のレコードを返します。要求されたブランチがローカルに既存ならそれをチェックアウトし、なければ要求されたベースまたは `HEAD` からブランチを作成します。`worktree.open` は既存のチェックアウトを開くか、すでに開いているワークスペースを返します。`worktree.remove` はリンクされた子ワークスペースに対して `git worktree remove` を実行し、ブランチは決して削除しません。Git が安全な削除を拒否した場合は `dirty_worktree_requires_force` または `submodule_worktree_requires_force` を返します。失われる作業を確認したうえで `force: true` で再試行してください。 元のワークスペースから worktree を作成する: diff --git a/docs/next/website/src/content/docs/socket-api.mdx b/docs/next/website/src/content/docs/socket-api.mdx index 547415a60..33cd2699e 100644 --- a/docs/next/website/src/content/docs/socket-api.mdx +++ b/docs/next/website/src/content/docs/socket-api.mdx @@ -341,7 +341,7 @@ Set or clear the foreground client's outer terminal window title: `type: "client_window_title"` with `changed` and reason `set`, `cleared`, or `no_foreground_client`. -Worktree methods manage Git checkouts as Herdr workspaces. `worktree.create` creates a checkout and returns the new `workspace`, `tab`, `root_pane`, and `worktree` records. If the requested branch already exists locally, it checks out that branch; otherwise it creates the branch from the requested base or `HEAD`. `worktree.open` opens an existing checkout or returns the already-open workspace. `worktree.remove` runs `git worktree remove` against a linked child workspace and never deletes the branch. +Worktree methods manage Git checkouts as Herdr workspaces. `worktree.create` creates a checkout and returns the new `workspace`, `tab`, `root_pane`, and `worktree` records. If the requested branch already exists locally, it checks out that branch; otherwise it creates the branch from the requested base or `HEAD`. `worktree.open` opens an existing checkout or returns the already-open workspace. `worktree.remove` runs `git worktree remove` against a linked child workspace and never deletes the branch. It returns `dirty_worktree_requires_force` or `submodule_worktree_requires_force` when Git refuses a safe removal; check for work that would be lost before retrying with `force: true`. Create a worktree from a source workspace: diff --git a/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx b/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx index 122a14cbf..ce1d2842b 100644 --- a/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx +++ b/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx @@ -179,7 +179,7 @@ herdr worktree remove --workspace ID [--force] [--trust-repository] worktree 是带有 Git 检出来源信息的普通 Herdr 工作区。`worktree create` 创建一个 Git worktree 检出,作为工作区打开,并与父仓库工作区分到一组。如果 `--branch` 指向已有的本地分支,Herdr 检出它;否则从 `--base` 或 `HEAD` 创建分支。没有 `--path` 时,Herdr 在 `//` 下创建检出。 -`workspace close` 只关闭 Herdr 状态。当关联的 worktree 工作区仍然打开时,关闭父工作区需要 `--group`;否则命令会保留整个组并返回 `workspace_group_close_required`。`worktree remove` 是显式的检出删除路径;它运行 `git worktree remove`,从不删除分支,并在 Git 拒绝脏检出时要求 `--force`。 +`workspace close` 只关闭 Herdr 状态。当关联的 worktree 工作区仍然打开时,关闭父工作区需要 `--group`;否则命令会保留整个组并返回 `workspace_group_close_required`。`worktree remove` 是显式的检出删除路径;它运行 `git worktree remove`,从不删除分支,并在 Git 拒绝脏检出或包含已初始化子模块的检出时要求 `--force`。 Git 默认拒绝由其他用户拥有的仓库。如果你已独立验证该仓库,可传入 `--trust-repository`,仅在本次命令中信任解析后的仓库路径。Herdr 不会修改你的 Git 配置。 diff --git a/docs/next/website/src/content/docs/zh-cn/socket-api.mdx b/docs/next/website/src/content/docs/zh-cn/socket-api.mdx index 1e54f14e2..fddad0eca 100644 --- a/docs/next/website/src/content/docs/zh-cn/socket-api.mdx +++ b/docs/next/website/src/content/docs/zh-cn/socket-api.mdx @@ -266,7 +266,7 @@ herdr notification show "build failed" --body "api workspace" --position top-lef `client.window_title.clear` 把标题交还给 `ui.window_title`。响应是 `type: "client_window_title"`,带 `changed` 和 `set`、`cleared` 或 `no_foreground_client` 之一的 reason。 -Worktree 方法把 Git 检出作为 Herdr 工作区管理。`worktree.create` 创建检出,并返回新的 `workspace`、`tab`、`root_pane` 和 `worktree` 记录。请求的分支已在本地存在时检出它;否则从请求的 base 或 `HEAD` 创建分支。`worktree.open` 打开已有检出,或返回已打开的工作区。`worktree.remove` 对关联的子工作区运行 `git worktree remove`,从不删除分支。 +Worktree 方法把 Git 检出作为 Herdr 工作区管理。`worktree.create` 创建检出,并返回新的 `workspace`、`tab`、`root_pane` 和 `worktree` 记录。请求的分支已在本地存在时检出它;否则从请求的 base 或 `HEAD` 创建分支。`worktree.open` 打开已有检出,或返回已打开的工作区。`worktree.remove` 对关联的子工作区运行 `git worktree remove`,从不删除分支。Git 拒绝安全删除时,它会返回 `dirty_worktree_requires_force` 或 `submodule_worktree_requires_force`;确认可能丢失的工作后,使用 `force: true` 重试。 从来源工作区创建 worktree: diff --git a/src/app/api/worktrees.rs b/src/app/api/worktrees.rs index be282c1c7..f213c2b18 100644 --- a/src/app/api/worktrees.rs +++ b/src/app/api/worktrees.rs @@ -2123,6 +2123,107 @@ mod tests { let _ = std::fs::remove_dir_all(repo); } + #[tokio::test] + async fn api_worktree_remove_reports_submodule_force_requirement_without_shutting_down_panes() { + let repo = create_committed_repo("api-worktree-remove-submodule-repo"); + let submodule = create_committed_repo("api-worktree-remove-submodule-source"); + let checkout = unique_temp_path("api-worktree-remove-submodule-checkout"); + run_git( + &repo, + &[ + "-c", + "protocol.file.allow=always", + "submodule", + "add", + "--quiet", + submodule.to_str().unwrap(), + "sub", + ], + ); + run_git(&repo, &["commit", "--quiet", "-am", "add submodule"]); + run_git( + &repo, + &[ + "worktree", + "add", + "--quiet", + "-b", + "worktree/api-remove-submodule", + checkout.to_str().unwrap(), + "HEAD", + ], + ); + run_git( + &checkout, + &[ + "-c", + "protocol.file.allow=always", + "submodule", + "update", + "--init", + "--quiet", + ], + ); + + let mut app = app_with_parent(&repo); + let mut child = Workspace::test_new("child"); + child.identity_cwd = checkout.clone(); + child.worktree_space = Some(crate::workspace::WorktreeSpaceMembership { + key: crate::workspace::git_space_metadata(&repo).unwrap().key, + label: "api-worktree-remove-submodule-repo".into(), + repo_root: repo.clone(), + checkout_path: checkout.clone(), + is_linked_worktree: true, + }); + let child_id = child.id.clone(); + let pane_id = child.tabs[0].root_pane; + let terminal_id = child.terminal_id(pane_id).cloned().unwrap(); + app.state.workspaces.push(child); + app.state.ensure_test_terminals(); + let (runtime, _input_rx) = crate::terminal::TerminalRuntime::test_with_channel(80, 24); + app.terminal_runtimes.insert(terminal_id.clone(), runtime); + + let response = run_deferred_api_request( + &mut app, + Request { + id: "req".into(), + method: crate::api::schema::Method::WorktreeRemove(WorktreeRemoveParams { + workspace_id: child_id.clone(), + force: false, + trust_repository: false, + }), + }, + ); + let error: ErrorResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(error.error.code, "submodule_worktree_requires_force"); + assert!(checkout.exists()); + assert!(app.terminal_runtimes.get(&terminal_id).is_some()); + assert!(app.pending_worktree_remove_runtime_exits.is_empty()); + + let response = run_deferred_api_request( + &mut app, + Request { + id: "req".into(), + method: crate::api::schema::Method::WorktreeRemove(WorktreeRemoveParams { + workspace_id: child_id, + force: true, + trust_repository: false, + }), + }, + ); + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + let ResponseResult::WorktreeRemoved { forced, path, .. } = success.result else { + panic!("expected worktree_removed response"); + }; + assert!(forced); + assert_eq!(path, checkout.display().to_string()); + assert!(!checkout.exists()); + assert_eq!(app.state.workspaces.len(), 1); + + let _ = std::fs::remove_dir_all(repo); + let _ = std::fs::remove_dir_all(submodule); + } + #[test] fn api_worktree_remove_emits_close_event_and_drains_runtime_shutdowns() { let repo = create_committed_repo("api-worktree-remove-event-repo"); diff --git a/src/app/api/worktrees/deferred.rs b/src/app/api/worktrees/deferred.rs index c20aaec4c..eb4d2a904 100644 --- a/src/app/api/worktrees/deferred.rs +++ b/src/app/api/worktrees/deferred.rs @@ -293,22 +293,37 @@ impl App { #[cfg(windows)] { - if !params.force - && crate::worktree::checkout_has_dirty_files( + if !params.force { + if crate::worktree::checkout_has_dirty_files( &space.checkout_path, params.trust_repository, ) .unwrap_or(false) - { - Self::send_api_response( - respond_to, - encode_error( - id, - "dirty_worktree_requires_force", - crate::worktree::worktree_dirty_remove_message(&space.checkout_path), - ), - ); - return; + { + Self::send_api_response( + respond_to, + encode_error( + id, + "dirty_worktree_requires_force", + crate::worktree::worktree_dirty_remove_message(&space.checkout_path), + ), + ); + return; + } + if crate::worktree::checkout_has_initialized_submodules( + &space.checkout_path, + params.trust_repository, + ) { + Self::send_api_response( + respond_to, + encode_error( + id, + "submodule_worktree_requires_force", + crate::worktree::SUBMODULE_WORKTREE_REMOVE_ERROR, + ), + ); + return; + } } } @@ -386,12 +401,16 @@ impl App { "herdr-worktree-remove", finished, move |mut finished| { + // The workspace membership was verified before spawning this + // thread, so a forced request may clean a checkout that Git has + // already unregistered but could not delete. finished.result = crate::worktree::run_worktree_remove_command_with_recovery( &command, &repo_root, &finished.path, finished.forced, trust_repository, + finished.forced, ); let _ = event_tx.blocking_send(AppEvent::WorktreeRemoveFinished(Box::new(finished))); @@ -554,12 +573,15 @@ impl App { api.operation_id, &result.path, ); - let code = - if !result.forced && crate::worktree::is_dirty_worktree_remove_error(&message) { - "dirty_worktree_requires_force" - } else { - "worktree_remove_failed" - }; + let code = if !result.forced + && crate::worktree::is_submodule_worktree_remove_error(&message) + { + "submodule_worktree_requires_force" + } else if !result.forced && crate::worktree::is_dirty_worktree_remove_error(&message) { + "dirty_worktree_requires_force" + } else { + "worktree_remove_failed" + }; Self::send_api_response(api.respond_to, encode_error(api.id, code, message)); return pane_updates; } diff --git a/src/client/shell/tests/agents_worktrees_notifications.rs b/src/client/shell/tests/agents_worktrees_notifications.rs index df09f464a..78f8ce1e1 100644 --- a/src/client/shell/tests/agents_worktrees_notifications.rs +++ b/src/client/shell/tests/agents_worktrees_notifications.rs @@ -1422,6 +1422,11 @@ fn worktree_open_filters_and_clicks_a_stable_public_entry() { fn worktree_remove_escalates_recoverable_failure_to_force_confirmation() { for (code, message, expect_force) in [ ("dirty_worktree_requires_force", "dirty worktree", true), + ( + "submodule_worktree_requires_force", + "submodule worktree", + true, + ), ( "worktree_remove_failed", "fatal: '/repo-feature' is not a working tree", diff --git a/src/client/shell/worktree_overlays.rs b/src/client/shell/worktree_overlays.rs index 318c40190..f9b3fda72 100644 --- a/src/client/shell/worktree_overlays.rs +++ b/src/client/shell/worktree_overlays.rs @@ -353,7 +353,7 @@ pub(super) fn render_worktree_remove_overlay( inner.x, inner.y + 4, inner.width, - " Dirty or untracked files will be permanently deleted.", + " Dirty files and unpushed submodule work will be permanently deleted.", Style::default().fg(p.red).bg(p.panel_bg), ); } diff --git a/src/client/shell/worktrees.rs b/src/client/shell/worktrees.rs index 56c554c8f..c55ccef89 100644 --- a/src/client/shell/worktrees.rs +++ b/src/client/shell/worktrees.rs @@ -497,9 +497,11 @@ impl ClientShellState { true } (PendingEndpointKind::WorktreeRemove { forced: false }, Err(error)) - if error.code.as_deref() == Some("dirty_worktree_requires_force") - || (error.code.as_deref() == Some("worktree_remove_failed") - && crate::worktree::is_not_working_tree_remove_error(&error.message)) => + if matches!( + error.code.as_deref(), + Some("dirty_worktree_requires_force" | "submodule_worktree_requires_force") + ) || (error.code.as_deref() == Some("worktree_remove_failed") + && crate::worktree::is_not_working_tree_remove_error(&error.message)) => { if let Some(ClientShellOverlay::WorktreeRemove(remove)) = self.overlay.as_mut() { remove.removing = false; diff --git a/src/worktree.rs b/src/worktree.rs index 17f560a43..d30d8da16 100644 --- a/src/worktree.rs +++ b/src/worktree.rs @@ -191,11 +191,20 @@ pub(crate) fn build_worktree_remove_command( } } +#[cfg(windows)] +pub(crate) const SUBMODULE_WORKTREE_REMOVE_ERROR: &str = + "fatal: working trees containing submodules cannot be moved or removed"; + pub(crate) fn is_dirty_worktree_remove_error(message: &str) -> bool { let lower = message.to_ascii_lowercase(); - (lower.contains("contains modified or untracked files") - && lower.contains("use --force to delete it")) - || lower.contains("working trees containing submodules cannot be moved or removed") + lower.contains("contains modified or untracked files") + && lower.contains("use --force to delete it") +} + +pub(crate) fn is_submodule_worktree_remove_error(message: &str) -> bool { + message + .to_ascii_lowercase() + .contains("working trees containing submodules cannot be moved or removed") } pub(crate) fn is_not_working_tree_remove_error(message: &str) -> bool { @@ -236,6 +245,26 @@ pub(crate) fn checkout_has_dirty_files( } } +#[cfg(any(windows, test))] +pub(crate) fn checkout_has_initialized_submodules(path: &Path, trust_repository: bool) -> bool { + if !path.join(".gitmodules").is_file() { + return false; + } + let Ok(output) = repository_git_command(path, trust_repository) + .args(["submodule", "status", "--recursive"]) + .output() + else { + return false; + }; + if !output.status.success() { + return false; + } + // `git submodule status` prefixes uninitialized entries with `-`. + String::from_utf8_lossy(&output.stdout) + .lines() + .any(|line| matches!(line.as_bytes().first(), Some(b' ' | b'+' | b'U'))) +} + pub(crate) fn build_worktree_add_new_branch_command( repo_root: &Path, path: &Path, @@ -349,52 +378,69 @@ pub(crate) fn run_worktree_remove_command_with_recovery( path: &Path, force: bool, trust_repository: bool, + allow_unregistered_leftover: bool, ) -> Result<(), String> { + // Capture the checkout identity before Git removes `.git`. A forced removal + // can unregister the worktree and then fail to delete the directory, at + // which point the checkout no longer proves it belongs to this repository. + let checkout_gitdir = force.then(|| checkout_gitdir(path)).flatten(); match run_worktree_command(command) { Ok(()) => Ok(()), - Err(err) if force && is_not_working_tree_remove_error(&err) => { + Err(err) if force => { if worktree_list_contains_path(repo_root, path, trust_repository)? { return Err(err); } - if path.exists() { - if !leftover_worktree_checkout_matches_repo(repo_root, path, trust_repository) { - return Err(err); - } - std::fs::remove_dir_all(path).map_err(|remove_err| { - format!( - "{err}; failed to remove leftover checkout {}: {remove_err}", - path.display() - ) - })?; + if !path.exists() { + return Ok(()); } + let known_checkout = checkout_gitdir + .as_deref() + .is_some_and(|gitdir| gitdir_belongs_to_repo(gitdir, repo_root, trust_repository)) + || leftover_worktree_checkout_matches_repo(repo_root, path, trust_repository) + || allow_unregistered_leftover; + if !known_checkout { + return Err(err); + } + std::fs::remove_dir_all(path).map_err(|remove_err| { + format!( + "{err}; failed to remove leftover checkout {}: {remove_err}", + path.display() + ) + })?; Ok(()) } Err(err) => Err(err), } } +fn checkout_gitdir(path: &Path) -> Option { + let content = std::fs::read_to_string(path.join(".git")).ok()?; + let gitdir = content.trim().strip_prefix("gitdir:")?.trim(); + if gitdir.is_empty() { + return None; + } + let gitdir = PathBuf::from(gitdir); + Some(if gitdir.is_absolute() { + gitdir + } else { + path.join(gitdir) + }) +} + +fn gitdir_belongs_to_repo(gitdir: &Path, repo_root: &Path, trust_repository: bool) -> bool { + let Some(worktrees_dir) = git_common_worktrees_dir(repo_root, trust_repository) else { + return false; + }; + canonical_or_original(gitdir).starts_with(canonical_or_original(&worktrees_dir)) +} + fn leftover_worktree_checkout_matches_repo( repo_root: &Path, path: &Path, trust_repository: bool, ) -> bool { - let git_file = path.join(".git"); - let Ok(content) = std::fs::read_to_string(&git_file) else { - return false; - }; - let Some(gitdir) = content.trim().strip_prefix("gitdir:") else { - return false; - }; - let gitdir = PathBuf::from(gitdir.trim()); - let gitdir = if gitdir.is_absolute() { - gitdir - } else { - path.join(gitdir) - }; - let Some(worktrees_dir) = git_common_worktrees_dir(repo_root, trust_repository) else { - return false; - }; - canonical_or_original(&gitdir).starts_with(canonical_or_original(&worktrees_dir)) + checkout_gitdir(path) + .is_some_and(|gitdir| gitdir_belongs_to_repo(&gitdir, repo_root, trust_repository)) } fn git_common_worktrees_dir(repo_root: &Path, trust_repository: bool) -> Option { @@ -871,13 +917,19 @@ prunable stale assert!(!is_dirty_worktree_remove_error( "fatal: '/w/herdr' contains a locked worktree, use --force only if you know why" )); + assert!(!is_dirty_worktree_remove_error( + "fatal: working trees containing submodules cannot be moved or removed" + )); } #[test] fn submodule_remove_error_requires_force_confirmation() { - assert!(is_dirty_worktree_remove_error( + assert!(is_submodule_worktree_remove_error( "fatal: working trees containing submodules cannot be moved or removed" )); + assert!(!is_submodule_worktree_remove_error( + "fatal: '/w/herdr' contains modified or untracked files, use --force to delete it" + )); } #[test] @@ -906,6 +958,7 @@ prunable stale false, ); run_worktree_command(&add).unwrap(); + assert!(!checkout_has_initialized_submodules(&checkout, false)); run_git( &checkout, &[ @@ -917,20 +970,22 @@ prunable stale "--quiet", ], ); + assert!(checkout_has_initialized_submodules(&checkout, false)); assert!(!checkout_has_dirty_files(&checkout, false).unwrap()); let remove = build_worktree_remove_command(&repo, &checkout, false, false); let error = run_worktree_command(&remove).unwrap_err(); - assert!(is_dirty_worktree_remove_error(&error), "{error}"); + assert!(is_submodule_worktree_remove_error(&error), "{error}"); assert!(checkout.join("sub/README.md").exists()); std::fs::write(checkout.join("sub/README.md"), "uncommitted change\n").unwrap(); let error = run_worktree_command(&remove).unwrap_err(); - assert!(is_dirty_worktree_remove_error(&error), "{error}"); + assert!(is_submodule_worktree_remove_error(&error), "{error}"); assert!(checkout.join("sub/README.md").exists()); let forced = build_worktree_remove_command(&repo, &checkout, true, false); - run_worktree_remove_command_with_recovery(&forced, &repo, &checkout, true, false).unwrap(); + run_worktree_remove_command_with_recovery(&forced, &repo, &checkout, true, false, false) + .unwrap(); assert!(!checkout.exists()); assert!(!worktree_list_contains_path(&repo, &checkout, false).unwrap()); assert!(repo.join("sub/README.md").exists()); @@ -1036,7 +1091,31 @@ prunable stale .unwrap(); std::fs::write(checkout.join("leftover"), "leftover\n").unwrap(); - run_worktree_remove_command_with_recovery(&remove, &repo, &checkout, true, false).unwrap(); + run_worktree_remove_command_with_recovery(&remove, &repo, &checkout, true, false, false) + .unwrap(); + + assert!(!checkout.exists()); + let _ = std::fs::remove_dir_all(repo); + } + + #[test] + fn forced_worktree_remove_recovery_cleans_managed_unregistered_checkout() { + let repo = create_committed_repo("worktree-recovery-managed-repo"); + let checkout = unique_temp_path("worktree-recovery-managed-checkout"); + let branch = "worktree/recovery-managed"; + + let add = build_worktree_add_new_branch_command(&repo, &checkout, branch, "HEAD", false); + run_worktree_command(&add).unwrap(); + let remove = build_worktree_remove_command(&repo, &checkout, true, false); + run_worktree_command(&remove).unwrap(); + std::fs::create_dir_all(&checkout).unwrap(); + std::fs::write(checkout.join("leftover"), "leftover\n").unwrap(); + + // Git unregistered the worktree and deleted `.git` before it failed to + // remove the directory. A confirmed forced removal of the workspace's + // checkout may still clean up the leftover. + run_worktree_remove_command_with_recovery(&remove, &repo, &checkout, true, false, true) + .unwrap(); assert!(!checkout.exists()); let _ = std::fs::remove_dir_all(repo); @@ -1055,8 +1134,10 @@ prunable stale std::fs::create_dir_all(&checkout).unwrap(); std::fs::write(checkout.join("unrelated"), "do not delete\n").unwrap(); - let err = run_worktree_remove_command_with_recovery(&remove, &repo, &checkout, true, false) - .expect_err("unrelated replacement directory should not be removed"); + let err = run_worktree_remove_command_with_recovery( + &remove, &repo, &checkout, true, false, false, + ) + .expect_err("unrelated replacement directory should not be removed"); assert!(is_not_working_tree_remove_error(&err)); assert!(checkout.join("unrelated").exists());