mirror of
https://github.com/herdrdev/herdr.git
synced 2026-11-06 00:06:18 +00:00
fix: clarify worktree removal failures and recover partial removals
Report initialized submodules as their own force-required cause and avoid shutting down panes before Git can refuse an unforced removal. Forced removals now clean up a checkout that Git unregistered but could not delete. refs #5478
This commit is contained in:
@@ -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 `<worktrees.directory>/<repo>/<branch-slug>`.
|
||||
|
||||
`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.
|
||||
|
||||
|
||||
@@ -179,7 +179,7 @@ herdr worktree remove --workspace ID [--force] [--trust-repository]
|
||||
|
||||
worktree は、Git チェックアウトの出自情報を持つ通常の Herdr ワークスペースです。`worktree create` は Git worktree のチェックアウトを作成し、ワークスペースとして開き、親リポジトリのワークスペースとグループ化します。`--branch` が既存のローカルブランチを指す場合はそれをチェックアウトし、そうでなければ `--base` または `HEAD` からブランチを作成します。`--path` がない場合、チェックアウトは `<worktrees.directory>/<repo>/<branch-slug>` の下に作成されます。
|
||||
|
||||
`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 設定を変更しません。
|
||||
|
||||
|
||||
@@ -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 を作成する:
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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 在 `<worktrees.directory>/<repo>/<branch-slug>` 下创建检出。
|
||||
|
||||
`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 配置。
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+119
-38
@@ -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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user