diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-08-28 18:25:07 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-08-28 18:25:07 +0300 |
| commit | 095f1337d6502081658973ae3487dee8c6b34e1a (patch) | |
| tree | 4174cac82dd51dabcc1fcef57b45df2462ef1918 /src | |
| parent | 13a9f6402fb77a31499bf758b56ca42b79936706 (diff) | |
fix: trust worktree repositories per request (#3344)
refs #3044
Diffstat (limited to 'src')
| -rw-r--r-- | src/api/schema/tests.rs | 2 | ||||
| -rw-r--r-- | src/api/schema/worktrees.rs | 8 | ||||
| -rw-r--r-- | src/app/api/worktrees.rs | 101 | ||||
| -rw-r--r-- | src/app/api/worktrees/deferred.rs | 15 | ||||
| -rw-r--r-- | src/app/worktrees.rs | 35 | ||||
| -rw-r--r-- | src/cli/spec.rs | 12 | ||||
| -rw-r--r-- | src/cli/worktree.rs | 47 | ||||
| -rw-r--r-- | src/worktree.rs | 175 |
8 files changed, 276 insertions, 119 deletions
diff --git a/src/api/schema/tests.rs b/src/api/schema/tests.rs index 5199038d..663f3fc4 100644 --- a/src/api/schema/tests.rs +++ b/src/api/schema/tests.rs @@ -720,10 +720,12 @@ fn worktree_request_and_response_round_trip() { branch: Some("worktree/api".into()), base: Some("HEAD".into()), focus: true, + trust_repository: true, ..WorktreeCreateParams::default() }), }; let json = serde_json::to_string(&request).unwrap(); + assert!(json.contains("\"trust_repository\":true")); let restored: Request = serde_json::from_str(&json).unwrap(); assert_eq!(restored, request); diff --git a/src/api/schema/worktrees.rs b/src/api/schema/worktrees.rs index dc35128e..3b0d4b35 100644 --- a/src/api/schema/worktrees.rs +++ b/src/api/schema/worktrees.rs @@ -6,6 +6,8 @@ pub struct WorktreeListParams { pub workspace_id: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub cwd: Option<String>, + #[serde(default, skip_serializing_if = "super::is_false")] + pub trust_repository: bool, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema, Default)] @@ -24,6 +26,8 @@ pub struct WorktreeCreateParams { pub label: Option<String>, #[serde(default)] pub focus: bool, + #[serde(default, skip_serializing_if = "super::is_false")] + pub trust_repository: bool, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema, Default)] @@ -40,6 +44,8 @@ pub struct WorktreeOpenParams { pub label: Option<String>, #[serde(default)] pub focus: bool, + #[serde(default, skip_serializing_if = "super::is_false")] + pub trust_repository: bool, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] @@ -47,6 +53,8 @@ pub struct WorktreeRemoveParams { pub workspace_id: String, #[serde(default)] pub force: bool, + #[serde(default, skip_serializing_if = "super::is_false")] + pub trust_repository: bool, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] diff --git a/src/app/api/worktrees.rs b/src/app/api/worktrees.rs index 774b981a..4a3e23a8 100644 --- a/src/app/api/worktrees.rs +++ b/src/app/api/worktrees.rs @@ -50,11 +50,18 @@ impl App { id: String, params: WorktreeListParams, ) -> String { - let source = match self.resolve_worktree_list_source(params.workspace_id, params.cwd) { + let source = match self.resolve_worktree_list_source( + params.workspace_id, + params.cwd, + params.trust_repository, + ) { Ok(source) => source, Err(err) => return encode_error(id, err.code, err.message), }; - let entries = match crate::worktree::list_existing_worktrees(&source.source_repo_root) { + let entries = match crate::worktree::list_existing_worktrees( + &source.source_repo_root, + params.trust_repository, + ) { Ok(entries) => entries, Err(err) => return encode_error(id, "worktree_list_failed", err), }; @@ -88,7 +95,12 @@ impl App { Ok(source) => source, Err(err) => return encode_error(id, err.code, err.message), }; - let entry = match self.find_worktree_entry(&source, params.path, params.branch) { + let entry = match self.find_worktree_entry( + &source, + params.path, + params.branch, + params.trust_repository, + ) { Ok(entry) => entry, Err(err) => return encode_error(id, err.code, err.message), }; @@ -235,6 +247,7 @@ impl App { &mut self, workspace_id: Option<String>, cwd: Option<String>, + trust_repository: bool, ) -> Result<WorktreeSource, ApiFailure> { if workspace_id.is_some() && cwd.is_some() { return Err(ApiFailure::new( @@ -250,7 +263,7 @@ impl App { format!("workspace {workspace_id} not found"), )); }; - return self.worktree_list_source_from_workspace(ws_idx); + return self.worktree_list_source_from_workspace(ws_idx, trust_repository); } if let Some(cwd) = cwd { @@ -261,8 +274,13 @@ impl App { "Herdr worktree actions require a path inside a Git work tree", ) })?; - let workspace_idx = self.list_source_workspace_idx_for_space(&space); - return Ok(worktree_source_from_space(space, workspace_idx, true)); + let workspace_idx = self.list_source_workspace_idx_for_space(&space, trust_repository); + return Ok(worktree_source_from_space( + space, + workspace_idx, + true, + trust_repository, + )); } let Some(ws_idx) = self.state.active.or_else(|| { @@ -276,7 +294,7 @@ impl App { "workspace_id or cwd is required when no workspace is active", )); }; - self.worktree_list_source_from_workspace(ws_idx) + self.worktree_list_source_from_workspace(ws_idx, trust_repository) } fn worktree_source_from_workspace(&self, ws_idx: usize) -> Result<WorktreeSource, ApiFailure> { @@ -331,6 +349,7 @@ impl App { fn worktree_list_source_from_workspace( &self, ws_idx: usize, + trust_repository: bool, ) -> Result<WorktreeSource, ApiFailure> { let Some(ws) = self.state.workspaces.get(ws_idx) else { return Err(ApiFailure::new( @@ -370,11 +389,16 @@ impl App { )); }; let workspace_idx = if space.is_linked_worktree { - self.list_source_workspace_idx_for_space(&space) + self.list_source_workspace_idx_for_space(&space, trust_repository) } else { Some(ws_idx) }; - Ok(worktree_source_from_space(space, workspace_idx, true)) + Ok(worktree_source_from_space( + space, + workspace_idx, + true, + trust_repository, + )) } fn ensure_source_parent_membership( @@ -415,9 +439,10 @@ impl App { fn list_source_workspace_idx_for_space( &self, space: &crate::workspace::GitSpaceMetadata, + trust_repository: bool, ) -> Option<usize> { if space.is_linked_worktree { - let parent_checkout = parent_checkout_path_for_space(space); + let parent_checkout = parent_checkout_path_for_space(space, trust_repository); self.open_workspace_idx_for_checkout(&parent_checkout) } else { self.find_parent_workspace_for_space(space) @@ -475,9 +500,11 @@ impl App { source: &WorktreeSource, path: Option<String>, branch: Option<String>, + trust_repository: bool, ) -> Result<crate::worktree::ExistingWorktree, ApiFailure> { - let entries = crate::worktree::list_existing_worktrees(&source.source_repo_root) - .map_err(|err| ApiFailure::new("worktree_list_failed", err))?; + let entries = + crate::worktree::list_existing_worktrees(&source.source_repo_root, trust_repository) + .map_err(|err| ApiFailure::new("worktree_list_failed", err))?; if let Some(path) = path { let expected = absolute_user_path(&path)?; let expected = crate::worktree::canonical_or_original(&expected); @@ -667,9 +694,10 @@ fn worktree_source_from_space( space: crate::workspace::GitSpaceMetadata, workspace_idx: Option<usize>, allow_linked: bool, + trust_repository: bool, ) -> WorktreeSource { let source_checkout_path = if allow_linked { - parent_checkout_path_for_space(&space) + parent_checkout_path_for_space(&space, trust_repository) } else { space.repo_root.clone() }; @@ -682,12 +710,15 @@ fn worktree_source_from_space( } } -fn parent_checkout_path_for_space(space: &crate::workspace::GitSpaceMetadata) -> PathBuf { +fn parent_checkout_path_for_space( + space: &crate::workspace::GitSpaceMetadata, + trust_repository: bool, +) -> PathBuf { if !space.is_linked_worktree { return space.repo_root.clone(); } - crate::worktree::list_existing_worktrees(&space.repo_root) + crate::worktree::list_existing_worktrees(&space.repo_root, trust_repository) .ok() .and_then(|entries| { entries.into_iter().find_map(|entry| { @@ -957,8 +988,12 @@ mod tests { for (_, runtime) in app.terminal_runtimes.drain() { runtime.shutdown(); } - let remove = - crate::worktree::build_worktree_remove_command(&repo, Path::new(&worktree.path), false); + let remove = crate::worktree::build_worktree_remove_command( + &repo, + Path::new(&worktree.path), + false, + false, + ); crate::worktree::run_worktree_command(&remove).unwrap(); let _ = std::fs::remove_dir_all(worktree_root); let _ = std::fs::remove_dir_all(repo); @@ -1271,8 +1306,12 @@ mod tests { for (_, runtime) in app.terminal_runtimes.drain() { runtime.shutdown(); } - let remove = - crate::worktree::build_worktree_remove_command(&repo, Path::new(&worktree.path), false); + let remove = crate::worktree::build_worktree_remove_command( + &repo, + Path::new(&worktree.path), + false, + false, + ); crate::worktree::run_worktree_command(&remove).unwrap(); let _ = std::fs::remove_dir_all(worktree_root); let _ = std::fs::remove_dir_all(repo); @@ -1440,7 +1479,7 @@ mod tests { ) })); - let remove = crate::worktree::build_worktree_remove_command(&repo, &checkout, false); + let remove = crate::worktree::build_worktree_remove_command(&repo, &checkout, false, false); crate::worktree::run_worktree_command(&remove).unwrap(); let _ = std::fs::remove_dir_all(repo); } @@ -1518,7 +1557,7 @@ mod tests { ) })); - let remove = crate::worktree::build_worktree_remove_command(&repo, &checkout, false); + let remove = crate::worktree::build_worktree_remove_command(&repo, &checkout, false, false); crate::worktree::run_worktree_command(&remove).unwrap(); let _ = std::fs::remove_dir_all(repo); } @@ -1603,6 +1642,7 @@ mod tests { method: crate::api::schema::Method::WorktreeList(WorktreeListParams { workspace_id: Some(app.state.workspaces[0].id.clone()), cwd: None, + trust_repository: false, }), }); @@ -1620,7 +1660,7 @@ mod tests { ); assert!(entry.is_linked_worktree); - let remove = crate::worktree::build_worktree_remove_command(&repo, &checkout, false); + let remove = crate::worktree::build_worktree_remove_command(&repo, &checkout, false, false); crate::worktree::run_worktree_command(&remove).unwrap(); let _ = std::fs::remove_dir_all(repo); } @@ -1653,10 +1693,12 @@ mod tests { crate::api::schema::Method::WorktreeList(WorktreeListParams { workspace_id: Some(child_id), cwd: None, + trust_repository: false, }), crate::api::schema::Method::WorktreeList(WorktreeListParams { workspace_id: None, cwd: Some(checkout.display().to_string()), + trust_repository: false, }), ] { let response = app.handle_api_request(Request { @@ -1681,7 +1723,7 @@ mod tests { })); } - let remove = crate::worktree::build_worktree_remove_command(&repo, &checkout, false); + let remove = crate::worktree::build_worktree_remove_command(&repo, &checkout, false, false); crate::worktree::run_worktree_command(&remove).unwrap(); let _ = std::fs::remove_dir_all(repo); } @@ -1710,6 +1752,7 @@ mod tests { method: crate::api::schema::Method::WorktreeList(WorktreeListParams { workspace_id: Some(app.state.workspaces[0].id.clone()), cwd: None, + trust_repository: false, }), }); @@ -1767,6 +1810,7 @@ mod tests { method: crate::api::schema::Method::WorktreeRemove(WorktreeRemoveParams { workspace_id: child_id.clone(), force: false, + trust_repository: false, }), }, ); @@ -1782,6 +1826,7 @@ mod tests { method: crate::api::schema::Method::WorktreeRemove(WorktreeRemoveParams { workspace_id: child_id, force: true, + trust_repository: false, }), }, ); @@ -1838,6 +1883,7 @@ mod tests { method: crate::api::schema::Method::WorktreeRemove(WorktreeRemoveParams { workspace_id: child_id.clone(), force: false, + trust_repository: false, }), }, ); @@ -1923,6 +1969,7 @@ mod tests { method: crate::api::schema::Method::WorktreeRemove(WorktreeRemoveParams { workspace_id: child_id.clone(), force: false, + trust_repository: false, }), }, respond_to, @@ -1996,6 +2043,7 @@ mod tests { method: crate::api::schema::Method::WorktreeRemove(WorktreeRemoveParams { workspace_id: child_id.clone(), force: false, + trust_repository: false, }), }, first_tx, @@ -2006,6 +2054,7 @@ mod tests { method: crate::api::schema::Method::WorktreeRemove(WorktreeRemoveParams { workspace_id: child_id, force: false, + trust_repository: false, }), }, second_tx, @@ -2065,6 +2114,7 @@ mod tests { method: crate::api::schema::Method::WorktreeRemove(WorktreeRemoveParams { workspace_id: first_id, force: true, + trust_repository: false, }), }, first_tx, @@ -2075,6 +2125,7 @@ mod tests { method: crate::api::schema::Method::WorktreeRemove(WorktreeRemoveParams { workspace_id: second_id, force: true, + trust_repository: false, }), }, second_tx, @@ -2110,6 +2161,7 @@ mod tests { path: Some(checkout.display().to_string()), label: None, focus: false, + trust_repository: false, }), }, respond_to, @@ -2164,6 +2216,7 @@ mod tests { method: crate::api::schema::Method::WorktreeRemove(WorktreeRemoveParams { workspace_id: child_id, force: false, + trust_repository: false, }), }, respond_to, @@ -2175,7 +2228,7 @@ mod tests { let error: ErrorResponse = serde_json::from_str(&response).unwrap(); assert_eq!(error.error.code, "worktree_operation_in_progress"); assert!(app.event_rx.try_recv().is_err()); - let remove = crate::worktree::build_worktree_remove_command(&repo, &checkout, true); + let remove = crate::worktree::build_worktree_remove_command(&repo, &checkout, true, false); let _ = crate::worktree::run_worktree_command(&remove); let _ = std::fs::remove_dir_all(repo); } diff --git a/src/app/api/worktrees/deferred.rs b/src/app/api/worktrees/deferred.rs index 3cd5e9f1..4755b38d 100644 --- a/src/app/api/worktrees/deferred.rs +++ b/src/app/api/worktrees/deferred.rs @@ -199,6 +199,7 @@ impl App { &path, &branch, &base, + params.trust_repository, ) }); let _ = event_tx.blocking_send(AppEvent::WorktreeAddFinished(Box::new( @@ -259,7 +260,11 @@ impl App { #[cfg(windows)] { if !params.force - && crate::worktree::checkout_has_dirty_files(&space.checkout_path).unwrap_or(false) + && crate::worktree::checkout_has_dirty_files( + &space.checkout_path, + params.trust_repository, + ) + .unwrap_or(false) { Self::send_api_response( respond_to, @@ -311,6 +316,7 @@ impl App { &space.repo_root, &space.checkout_path, params.force, + params.trust_repository, ); let api_request = ApiWorktreeRemoveRequest { id, @@ -321,10 +327,15 @@ impl App { let repo_root = space.repo_root; let path = space.checkout_path; let force = params.force; + let trust_repository = params.trust_repository; let event_tx = self.event_tx.clone(); std::thread::spawn(move || { let result = crate::worktree::run_worktree_remove_command_with_recovery( - &command, &repo_root, &path, force, + &command, + &repo_root, + &path, + force, + trust_repository, ); let _ = event_tx.blocking_send(AppEvent::WorktreeRemoveFinished(Box::new( crate::events::WorktreeRemoveResult { diff --git a/src/app/worktrees.rs b/src/app/worktrees.rs index 414d74b5..9993a70e 100644 --- a/src/app/worktrees.rs +++ b/src/app/worktrees.rs @@ -161,7 +161,7 @@ impl App { } }; - let list = match crate::worktree::list_existing_worktrees(&space.repo_root) { + let list = match crate::worktree::list_existing_worktrees(&space.repo_root, false) { Ok(list) => list, Err(err) => { self.state.config_diagnostic = Some(err); @@ -551,6 +551,7 @@ impl App { &path, &branch, "HEAD", + false, ) }); let _ = event_tx.blocking_send(AppEvent::WorktreeAddFinished(Box::new( @@ -599,6 +600,7 @@ impl App { base: Some("HEAD".into()), focus: true, label: None, + trust_repository: false, }, ); if let Some(message) = immediate_api_error_message(immediate_response.as_deref()) { @@ -641,7 +643,8 @@ impl App { } #[cfg(windows)] if !remove.force_confirmation - && crate::worktree::checkout_has_dirty_files(&remove.path).unwrap_or(false) + && crate::worktree::checkout_has_dirty_files(&remove.path, false) + .unwrap_or(false) { remove.force_confirmation = true; remove.error = None; @@ -686,12 +689,13 @@ impl App { }) .unwrap_or((None, None)); - let command = crate::worktree::build_worktree_remove_command(&repo_root, &path, force); + let command = + crate::worktree::build_worktree_remove_command(&repo_root, &path, force, false); 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_remove_command_with_recovery( - &command, &repo_root, &path, force, + &command, &repo_root, &path, force, false, ); let _ = event_tx.blocking_send(AppEvent::WorktreeRemoveFinished(Box::new( WorktreeRemoveResult { @@ -728,6 +732,7 @@ impl App { branch: None, focus: true, label: None, + trust_repository: false, }, ); if serde_json::from_str::<crate::api::schema::SuccessResponse>(&response).is_ok() { @@ -751,7 +756,7 @@ impl App { } #[cfg(windows)] if !remove.force_confirmation - && crate::worktree::checkout_has_dirty_files(&remove.path).unwrap_or(false) + && crate::worktree::checkout_has_dirty_files(&remove.path, false).unwrap_or(false) { remove.force_confirmation = true; remove.error = None; @@ -767,6 +772,7 @@ impl App { crate::api::schema::WorktreeRemoveParams { workspace_id, force, + trust_repository: false, }, ); if let Some(message) = immediate_api_error_message(immediate_response.as_deref()) { @@ -1831,7 +1837,7 @@ mod tests { })); shutdown_test_runtimes(&mut app); - let remove = crate::worktree::build_worktree_remove_command(&repo, &checkout, false); + let remove = crate::worktree::build_worktree_remove_command(&repo, &checkout, false, false); crate::worktree::run_worktree_command(&remove).unwrap(); let _ = std::fs::remove_dir_all(worktree_root); let _ = std::fs::remove_dir_all(repo); @@ -1929,7 +1935,7 @@ mod tests { } assert!(checkout.join("README.md").exists()); - let remove = crate::worktree::build_worktree_remove_command(&repo, &checkout, false); + let remove = crate::worktree::build_worktree_remove_command(&repo, &checkout, false, false); crate::worktree::run_worktree_command(&remove).unwrap(); let _ = std::fs::remove_dir_all(worktree_root); let _ = std::fs::remove_dir_all(repo); @@ -1988,7 +1994,7 @@ mod tests { branch ); - let remove = crate::worktree::build_worktree_remove_command(&repo, &checkout, false); + let remove = crate::worktree::build_worktree_remove_command(&repo, &checkout, false, false); crate::worktree::run_worktree_command(&remove).unwrap(); let _ = std::fs::remove_dir_all(worktree_root); let _ = std::fs::remove_dir_all(repo); @@ -2036,8 +2042,12 @@ mod tests { } assert!(checkout.join("README.md").exists()); - let remove_new = - crate::worktree::build_worktree_remove_command(&source_checkout_path, &checkout, false); + let remove_new = crate::worktree::build_worktree_remove_command( + &source_checkout_path, + &checkout, + false, + false, + ); crate::worktree::run_worktree_command(&remove_new).unwrap(); let _ = std::fs::remove_dir_all(worktree_root); let _ = std::fs::remove_dir_all(source_checkout_path); @@ -2096,10 +2106,11 @@ mod tests { } assert!(checkout.join("SOURCE.md").exists()); - let remove_new = crate::worktree::build_worktree_remove_command(&repo, &checkout, false); + let remove_new = + crate::worktree::build_worktree_remove_command(&repo, &checkout, false, false); crate::worktree::run_worktree_command(&remove_new).unwrap(); let remove_source = - crate::worktree::build_worktree_remove_command(&repo, &source_checkout, false); + crate::worktree::build_worktree_remove_command(&repo, &source_checkout, false, false); crate::worktree::run_worktree_command(&remove_source).unwrap(); let _ = std::fs::remove_dir_all(worktree_root); let _ = std::fs::remove_dir_all(repo); diff --git a/src/cli/spec.rs b/src/cli/spec.rs index 134bf0bf..ed3c74a8 100644 --- a/src/cli/spec.rs +++ b/src/cli/spec.rs @@ -239,7 +239,8 @@ fn worktree_command() -> Command { Command::new("list") .about("List worktree workspaces") .arg(option("workspace", "ID")) - .arg(path_option("cwd", "PATH")), + .arg(path_option("cwd", "PATH")) + .arg(flag("trust-repository")), ) .subcommand( Command::new("create") @@ -251,7 +252,8 @@ fn worktree_command() -> Command { .arg(path_option("path", "PATH")) .arg(option("label", "TEXT")) .arg(flag("focus")) - .arg(flag("no-focus")), + .arg(flag("no-focus")) + .arg(flag("trust-repository")), ) .subcommand( Command::new("open") @@ -262,13 +264,15 @@ fn worktree_command() -> Command { .arg(option("branch", "NAME")) .arg(option("label", "TEXT")) .arg(flag("focus")) - .arg(flag("no-focus")), + .arg(flag("no-focus")) + .arg(flag("trust-repository")), ) .subcommand( Command::new("remove") .about("Remove a worktree checkout") .arg(option("workspace", "ID")) - .arg(flag("force")), + .arg(flag("force")) + .arg(flag("trust-repository")), ) } diff --git a/src/cli/worktree.rs b/src/cli/worktree.rs index 4a36876a..4003eb6a 100644 --- a/src/cli/worktree.rs +++ b/src/cli/worktree.rs @@ -28,6 +28,7 @@ pub(super) fn run_worktree_command(args: &[String]) -> std::io::Result<i32> { fn worktree_list(args: &[String]) -> std::io::Result<i32> { let mut workspace_id = None; let mut cwd = None; + let mut trust_repository = false; let mut index = 0; while index < args.len() { @@ -48,6 +49,10 @@ fn worktree_list(args: &[String]) -> std::io::Result<i32> { cwd = Some(normalize_path_arg(value)?); index += 2; } + "--trust-repository" => { + trust_repository = true; + index += 1; + } "--json" => index += 1, other => { eprintln!("unknown option: {other}"); @@ -56,11 +61,15 @@ fn worktree_list(args: &[String]) -> std::io::Result<i32> { } } if workspace_id.is_some() && cwd.is_some() { - eprintln!("usage: herdr worktree list [--workspace ID | --cwd PATH]"); + eprintln!("usage: herdr worktree list [--workspace ID | --cwd PATH] [--trust-repository]"); return Ok(2); } - super::runtime::worktree_list(WorktreeListParams { workspace_id, cwd }) + super::runtime::worktree_list(WorktreeListParams { + workspace_id, + cwd, + trust_repository, + }) } fn worktree_create(args: &[String]) -> std::io::Result<i32> { @@ -71,6 +80,7 @@ fn worktree_create(args: &[String]) -> std::io::Result<i32> { let mut path = None; let mut label = None; let mut focus = false; + let mut trust_repository = false; let mut index = 0; while index < args.len() { @@ -131,6 +141,10 @@ fn worktree_create(args: &[String]) -> std::io::Result<i32> { focus = false; index += 1; } + "--trust-repository" => { + trust_repository = true; + index += 1; + } "--json" => index += 1, other => { eprintln!("unknown option: {other}"); @@ -140,7 +154,7 @@ fn worktree_create(args: &[String]) -> std::io::Result<i32> { } if workspace_id.is_some() && cwd.is_some() { eprintln!( - "usage: herdr worktree create [--workspace ID | --cwd PATH] [--branch NAME] [--base REF] [--path PATH] [--label TEXT] [--focus] [--no-focus]" + "usage: herdr worktree create [--workspace ID | --cwd PATH] [--branch NAME] [--base REF] [--path PATH] [--label TEXT] [--focus] [--no-focus] [--trust-repository]" ); return Ok(2); } @@ -153,6 +167,7 @@ fn worktree_create(args: &[String]) -> std::io::Result<i32> { path, label, focus, + trust_repository, }) } @@ -163,6 +178,7 @@ fn worktree_open(args: &[String]) -> std::io::Result<i32> { let mut branch = None; let mut label = None; let mut focus = false; + let mut trust_repository = false; let mut index = 0; while index < args.len() { @@ -215,6 +231,10 @@ fn worktree_open(args: &[String]) -> std::io::Result<i32> { focus = false; index += 1; } + "--trust-repository" => { + trust_repository = true; + index += 1; + } "--json" => index += 1, other => { eprintln!("unknown option: {other}"); @@ -224,13 +244,13 @@ fn worktree_open(args: &[String]) -> std::io::Result<i32> { } if workspace_id.is_some() && cwd.is_some() { eprintln!( - "usage: herdr worktree open [--workspace ID | --cwd PATH] (--path PATH | --branch NAME) [--label TEXT] [--focus] [--no-focus]" + "usage: herdr worktree open [--workspace ID | --cwd PATH] (--path PATH | --branch NAME) [--label TEXT] [--focus] [--no-focus] [--trust-repository]" ); return Ok(2); } if path.is_some() == branch.is_some() { eprintln!( - "usage: herdr worktree open [--workspace ID | --cwd PATH] (--path PATH | --branch NAME) [--label TEXT] [--focus] [--no-focus]" + "usage: herdr worktree open [--workspace ID | --cwd PATH] (--path PATH | --branch NAME) [--label TEXT] [--focus] [--no-focus] [--trust-repository]" ); return Ok(2); } @@ -242,12 +262,14 @@ fn worktree_open(args: &[String]) -> std::io::Result<i32> { branch, label, focus, + trust_repository, }) } fn worktree_remove(args: &[String]) -> std::io::Result<i32> { let mut workspace_id = None; let mut force = false; + let mut trust_repository = false; let mut index = 0; while index < args.len() { @@ -264,6 +286,10 @@ fn worktree_remove(args: &[String]) -> std::io::Result<i32> { force = true; index += 1; } + "--trust-repository" => { + trust_repository = true; + index += 1; + } "--json" => index += 1, other => { eprintln!("unknown option: {other}"); @@ -273,26 +299,27 @@ fn worktree_remove(args: &[String]) -> std::io::Result<i32> { } let Some(workspace_id) = workspace_id else { - eprintln!("usage: herdr worktree remove --workspace ID [--force]"); + eprintln!("usage: herdr worktree remove --workspace ID [--force] [--trust-repository]"); return Ok(2); }; super::runtime::worktree_remove(WorktreeRemoveParams { workspace_id, force, + trust_repository, }) } fn print_worktree_help() { eprintln!("herdr worktree commands:"); - eprintln!(" herdr worktree list [--workspace ID | --cwd PATH]"); + eprintln!(" herdr worktree list [--workspace ID | --cwd PATH] [--trust-repository]"); eprintln!( - " herdr worktree create [--workspace ID | --cwd PATH] [--branch NAME] [--base REF] [--path PATH] [--label TEXT] [--focus] [--no-focus]" + " herdr worktree create [--workspace ID | --cwd PATH] [--branch NAME] [--base REF] [--path PATH] [--label TEXT] [--focus] [--no-focus] [--trust-repository]" ); eprintln!( - " herdr worktree open [--workspace ID | --cwd PATH] (--path PATH | --branch NAME) [--label TEXT] [--focus] [--no-focus]" + " herdr worktree open [--workspace ID | --cwd PATH] (--path PATH | --branch NAME) [--label TEXT] [--focus] [--no-focus] [--trust-repository]" ); - eprintln!(" herdr worktree remove --workspace ID [--force]"); + eprintln!(" herdr worktree remove --workspace ID [--force] [--trust-repository]"); } fn normalize_path_arg(value: &str) -> std::io::Result<String> { diff --git a/src/worktree.rs b/src/worktree.rs index 0bb091d7..523bc502 100644 --- a/src/worktree.rs +++ b/src/worktree.rs @@ -151,6 +151,23 @@ pub(crate) fn canonical_or_original(path: &Path) -> PathBuf { std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) } +fn repository_git_command(repo_root: &Path, trust_repository: bool) -> std::process::Command { + let mut command = crate::noninteractive_process::command("git"); + command.args(repository_git_args(repo_root, trust_repository)); + command +} + +fn repository_git_args(repo_root: &Path, trust_repository: bool) -> Vec<String> { + let mut args = Vec::new(); + if trust_repository { + args.push("-c".to_string()); + args.push(format!("safe.directory={}", repo_root.display())); + } + args.push("-C".to_string()); + args.push(repo_root.display().to_string()); + args +} + pub(crate) fn default_checkout_path(root: &Path, repo_name: &str, branch: &str) -> PathBuf { root.join(repo_name).join(branch_to_path_slug(branch)) } @@ -159,13 +176,10 @@ pub(crate) fn build_worktree_remove_command( repo_root: &Path, path: &Path, force: bool, + trust_repository: bool, ) -> WorktreeCommand { - let mut args = vec![ - "-C".to_string(), - repo_root.display().to_string(), - "worktree".to_string(), - "remove".to_string(), - ]; + let mut args = repository_git_args(repo_root, trust_repository); + args.extend(["worktree".to_string(), "remove".to_string()]); if force { args.push("--force".to_string()); } @@ -197,16 +211,12 @@ pub(crate) fn worktree_dirty_remove_message(path: &Path) -> String { } #[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 = crate::noninteractive_process::command("git") - .args([ - "-C", - &path_arg, - "status", - "--porcelain", - "--untracked-files=all", - ]) +pub(crate) fn checkout_has_dirty_files( + path: &Path, + trust_repository: bool, +) -> Result<bool, String> { + let output = repository_git_command(path, trust_repository) + .args(["status", "--porcelain", "--untracked-files=all"]) .output() .map_err(|err| err.to_string())?; @@ -230,19 +240,20 @@ pub(crate) fn build_worktree_add_new_branch_command( path: &Path, branch: &str, base: &str, + trust_repository: bool, ) -> WorktreeCommand { + let mut args = repository_git_args(repo_root, trust_repository); + args.extend([ + "worktree".to_string(), + "add".to_string(), + "-b".to_string(), + branch.to_string(), + path.display().to_string(), + base.to_string(), + ]); WorktreeCommand { program: "git".to_string(), - args: vec![ - "-C".to_string(), - repo_root.display().to_string(), - "worktree".to_string(), - "add".to_string(), - "-b".to_string(), - branch.to_string(), - path.display().to_string(), - base.to_string(), - ], + args, } } @@ -250,24 +261,27 @@ pub(crate) fn build_worktree_add_existing_branch_command( repo_root: &Path, path: &Path, branch: &str, + trust_repository: bool, ) -> WorktreeCommand { + let mut args = repository_git_args(repo_root, trust_repository); + args.extend([ + "worktree".to_string(), + "add".to_string(), + path.display().to_string(), + branch.to_string(), + ]); WorktreeCommand { program: "git".to_string(), - args: vec![ - "-C".to_string(), - repo_root.display().to_string(), - "worktree".to_string(), - "add".to_string(), - path.display().to_string(), - branch.to_string(), - ], + args, } } -pub(crate) fn local_branch_exists(repo_root: &Path, branch: &str) -> Result<bool, String> { - let output = crate::noninteractive_process::command("git") - .arg("-C") - .arg(repo_root) +fn local_branch_exists( + repo_root: &Path, + branch: &str, + trust_repository: bool, +) -> Result<bool, String> { + let output = repository_git_command(repo_root, trust_repository) .args(["show-ref", "--verify", "--quiet"]) .arg(format!("refs/heads/{branch}")) .output() @@ -296,11 +310,12 @@ pub(crate) fn run_worktree_add_command( path: &Path, branch: &str, base: &str, + trust_repository: bool, ) -> Result<(), String> { - let command = if local_branch_exists(repo_root, branch)? { - build_worktree_add_existing_branch_command(repo_root, path, branch) + let command = if local_branch_exists(repo_root, branch, trust_repository)? { + build_worktree_add_existing_branch_command(repo_root, path, branch, trust_repository) } else { - build_worktree_add_new_branch_command(repo_root, path, branch, base) + build_worktree_add_new_branch_command(repo_root, path, branch, base, trust_repository) }; run_worktree_command(&command) } @@ -330,15 +345,16 @@ pub(crate) fn run_worktree_remove_command_with_recovery( repo_root: &Path, path: &Path, force: bool, + trust_repository: bool, ) -> Result<(), String> { match run_worktree_command(command) { Ok(()) => Ok(()), Err(err) if force && is_not_working_tree_remove_error(&err) => { - if worktree_list_contains_path(repo_root, path)? { + 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) { + if !leftover_worktree_checkout_matches_repo(repo_root, path, trust_repository) { return Err(err); } std::fs::remove_dir_all(path).map_err(|remove_err| { @@ -354,7 +370,11 @@ pub(crate) fn run_worktree_remove_command_with_recovery( } } -fn leftover_worktree_checkout_matches_repo(repo_root: &Path, path: &Path) -> bool { +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; @@ -368,16 +388,14 @@ fn leftover_worktree_checkout_matches_repo(repo_root: &Path, path: &Path) -> boo } else { path.join(gitdir) }; - let Some(worktrees_dir) = git_common_worktrees_dir(repo_root) else { + 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 git_common_worktrees_dir(repo_root: &Path) -> Option<PathBuf> { - let output = crate::noninteractive_process::command("git") - .arg("-C") - .arg(repo_root) +fn git_common_worktrees_dir(repo_root: &Path, trust_repository: bool) -> Option<PathBuf> { + let output = repository_git_command(repo_root, trust_repository) .args(["rev-parse", "--git-common-dir"]) .output() .ok()?; @@ -470,10 +488,11 @@ pub(crate) fn parse_worktree_list_porcelain(output: &str) -> Vec<ExistingWorktre entries } -pub(crate) fn list_existing_worktrees(repo_root: &Path) -> Result<Vec<ExistingWorktree>, String> { - let output = crate::noninteractive_process::command("git") - .arg("-C") - .arg(repo_root) +pub(crate) fn list_existing_worktrees( + repo_root: &Path, + trust_repository: bool, +) -> Result<Vec<ExistingWorktree>, String> { + let output = repository_git_command(repo_root, trust_repository) .args(["worktree", "list", "--porcelain"]) .output() .map_err(|err| err.to_string())?; @@ -491,9 +510,13 @@ pub(crate) fn list_existing_worktrees(repo_root: &Path) -> Result<Vec<ExistingWo }) } -pub(crate) fn worktree_list_contains_path(repo_root: &Path, path: &Path) -> Result<bool, String> { +fn worktree_list_contains_path( + repo_root: &Path, + path: &Path, + trust_repository: bool, +) -> Result<bool, String> { let expected = canonical_or_original(path); - Ok(list_existing_worktrees(repo_root)? + Ok(list_existing_worktrees(repo_root, trust_repository)? .into_iter() .any(|entry| canonical_or_original(&entry.path) == expected)) } @@ -538,6 +561,18 @@ mod tests { } #[test] + fn trusted_repository_git_args_are_request_scoped() { + assert_eq!( + repository_git_args(Path::new("/repo/herdr"), false), + ["-C", "/repo/herdr"] + ); + assert_eq!( + repository_git_args(Path::new("/repo/herdr"), true), + ["-c", "safe.directory=/repo/herdr", "-C", "/repo/herdr",] + ); + } + + #[test] fn generated_branch_slug_is_worktree_namespaced_and_stable() { assert_eq!(generated_branch_slug(0), "worktree/brave-river-0000"); assert_eq!(generated_branch_slug(9), "worktree/calm-cloud-0009"); @@ -728,11 +763,11 @@ prunable stale ], ); - assert_eq!(checkout_has_dirty_files(&checkout), Ok(false)); + assert_eq!(checkout_has_dirty_files(&checkout, false), Ok(false)); std::fs::write(checkout.join("README.md"), "dirty\n").unwrap(); - assert_eq!(checkout_has_dirty_files(&checkout), Ok(true)); + assert_eq!(checkout_has_dirty_files(&checkout, false), Ok(true)); - let remove = build_worktree_remove_command(&repo, &checkout, true); + let remove = build_worktree_remove_command(&repo, &checkout, true, false); run_worktree_command(&remove).unwrap(); let _ = std::fs::remove_dir_all(repo); } @@ -743,6 +778,7 @@ prunable stale Path::new("/repo/herdr"), Path::new("/w/herdr/issue-137"), false, + false, ); assert_eq!(command.program, "git"); assert_eq!( @@ -763,6 +799,7 @@ prunable stale Path::new("/repo/herdr"), Path::new("/w/herdr/issue-137"), true, + false, ); assert_eq!( command.args, @@ -797,6 +834,7 @@ prunable stale Path::new("/w/herdr/worktree-brave-river"), "worktree/brave-river", "HEAD", + false, ); assert_eq!(command.program, "git"); assert_eq!( @@ -820,6 +858,7 @@ prunable stale Path::new("/repo/herdr"), Path::new("/w/herdr/worktree-brave-river"), "worktree/brave-river", + false, ); assert_eq!(command.program, "git"); assert_eq!( @@ -841,7 +880,7 @@ prunable stale let checkout = unique_temp_path("worktree-run-checkout"); let branch = "worktree/test-create-remove"; - let add = build_worktree_add_new_branch_command(&repo, &checkout, branch, "HEAD"); + let add = build_worktree_add_new_branch_command(&repo, &checkout, branch, "HEAD", false); run_worktree_command(&add).unwrap(); assert!(checkout.join("README.md").exists()); @@ -857,7 +896,7 @@ prunable stale branch ); - let remove = build_worktree_remove_command(&repo, &checkout, false); + let remove = build_worktree_remove_command(&repo, &checkout, false, false); run_worktree_command(&remove).unwrap(); assert!(!checkout.exists()); @@ -870,12 +909,14 @@ prunable stale let checkout = unique_temp_path("worktree-recovery-checkout"); let branch = "worktree/recovery"; - let add = build_worktree_add_new_branch_command(&repo, &checkout, branch, "HEAD"); + 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); + let remove = build_worktree_remove_command(&repo, &checkout, true, false); run_worktree_command(&remove).unwrap(); std::fs::create_dir_all(&checkout).unwrap(); - let stale_admin_dir = git_common_worktrees_dir(&repo).unwrap().join("stale"); + let stale_admin_dir = git_common_worktrees_dir(&repo, false) + .unwrap() + .join("stale"); std::fs::write( checkout.join(".git"), format!("gitdir: {}\n", stale_admin_dir.display()), @@ -883,7 +924,7 @@ prunable stale .unwrap(); std::fs::write(checkout.join("leftover"), "leftover\n").unwrap(); - run_worktree_remove_command_with_recovery(&remove, &repo, &checkout, true).unwrap(); + run_worktree_remove_command_with_recovery(&remove, &repo, &checkout, true, false).unwrap(); assert!(!checkout.exists()); let _ = std::fs::remove_dir_all(repo); @@ -895,14 +936,14 @@ prunable stale let checkout = unique_temp_path("worktree-recovery-unrelated-checkout"); let branch = "worktree/recovery-unrelated"; - let add = build_worktree_add_new_branch_command(&repo, &checkout, branch, "HEAD"); + 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); + 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("unrelated"), "do not delete\n").unwrap(); - let err = run_worktree_remove_command_with_recovery(&remove, &repo, &checkout, true) + let err = run_worktree_remove_command_with_recovery(&remove, &repo, &checkout, true, false) .expect_err("unrelated replacement directory should not be removed"); assert!(is_not_working_tree_remove_error(&err)); |
