diff --git a/crates/engine/src/repos.rs b/crates/engine/src/repos.rs index be5691f81c..555c25d3bd 100644 --- a/crates/engine/src/repos.rs +++ b/crates/engine/src/repos.rs @@ -348,15 +348,31 @@ impl Repos { /// Canonical identity shared by every chat operating in this exact worktree: /// `sha256(deviceId ‖ NUL ‖ canonical git dir)`. pub async fn checkout_identity(&self, path: &Path) -> Result { - let root = self - .git(&["rev-parse", "--show-toplevel"], Some(path)) - .await?; - let git_dir = self + let out = self .git( - &["rev-parse", "--path-format=absolute", "--git-dir"], + &[ + "rev-parse", + "--show-toplevel", + "--path-format=absolute", + "--git-dir", + ], Some(path), ) .await?; + let mut lines = out.lines(); + let (root, git_dir) = match (lines.next(), lines.next(), lines.next()) { + (Some(root), Some(git_dir), None) => (root.to_owned(), git_dir.to_owned()), + // Fallback for a path with a `\n` in its name. + _ => ( + self.git(&["rev-parse", "--show-toplevel"], Some(path)) + .await?, + self.git( + &["rev-parse", "--path-format=absolute", "--git-dir"], + Some(path), + ) + .await?, + ), + }; let canonical_root = std::fs::canonicalize(&root).unwrap_or_else(|_| PathBuf::from(&root)); let canonical_git_dir = std::fs::canonicalize(&git_dir).unwrap_or_else(|_| PathBuf::from(&git_dir)); @@ -1166,19 +1182,34 @@ impl Repos { Some(path.to_string_lossy().into_owned()) } + /// Every checkout of the repository at `repo_path`, main one first. + /// NUL-terminated so a `\n` in a worktree's path can't forge another entry. + async fn worktree_paths(&self, repo_path: &Path) -> Result, EngineError> { + let out = match self + .git(&["worktree", "list", "--porcelain", "-z"], Some(repo_path)) + .await + { + Ok(out) => out, + // Git before 2.36 has no `-z`. + Err(_) => self + .git(&["worktree", "list", "--porcelain"], Some(repo_path)) + .await? + .replace('\n', "\0"), + }; + Ok(out + .split('\0') + .filter_map(|line| line.strip_prefix("worktree ")) + .map(PathBuf::from) + .collect()) + } + /// Whether `candidate` is the repository root or one of its linked /// worktrees. Filesystem resolution happens on a disposable thread because /// user-selected paths may be dead mounts. pub async fn workspace_checkout(&self, repo_path: &Path, candidate: &Path) -> Option { let repo_path = repo_path.to_path_buf(); let candidate = candidate.to_path_buf(); - let worktrees: Vec<_> = self - .refs(&repo_path) - .await - .unwrap_or_default() - .into_iter() - .filter_map(|row| row.worktree_path.map(PathBuf::from)) - .collect(); + let worktrees = self.worktree_paths(&repo_path).await.unwrap_or_default(); disposable_worker("checkout-auth", move || { let candidate = std::fs::canonicalize(candidate).ok()?; std::iter::once(repo_path) @@ -2796,6 +2827,77 @@ tmpfs /run tmpfs rw 0 0 ); } + async fn git(cwd: &Path, args: &[&str]) { + let output = tokio::process::Command::new("git") + .args(args) + .current_dir(cwd) + .env("GIT_AUTHOR_NAME", "test") + .env("GIT_AUTHOR_EMAIL", "test@test") + .env("GIT_COMMITTER_NAME", "test") + .env("GIT_COMMITTER_EMAIL", "test@test") + .output() + .await + .expect("git spawns"); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + #[tokio::test] + async fn workspace_checkout_accepts_every_linked_worktree() { + let tmp = tempfile::tempdir().expect("tempdir"); + let root = tmp.path().join("repo"); + let branch = tmp.path().join("branch"); + let detached = tmp.path().join("detached"); + let elsewhere = tmp.path().join("elsewhere"); + std::fs::create_dir_all(&root).expect("repo dir"); + std::fs::create_dir_all(&elsewhere).expect("unrelated dir"); + git(&root, &["init", "-b", "main"]).await; + git(&root, &["commit", "--allow-empty", "-m", "initial"]).await; + git( + &root, + &[ + "worktree", + "add", + "-b", + "feature", + &branch.to_string_lossy(), + ], + ) + .await; + git( + &root, + &["worktree", "add", "--detach", &detached.to_string_lossy()], + ) + .await; + let data = tmp.path().join("data"); + let repos = Repos::with_worktrees_root(&data, "device", data.join("worktrees")); + + for checkout in [&root, &branch, &detached] { + assert_eq!( + repos.workspace_checkout(&root, checkout).await, + std::fs::canonicalize(checkout).ok(), + "{}", + checkout.display() + ); + } + // A newline in a worktree's path must not smuggle in another entry. + #[cfg(unix)] + { + let forged = tmp + .path() + .join(format!("forged\nworktree {}", elsewhere.display())); + git( + &root, + &["worktree", "add", "--detach", &forged.to_string_lossy()], + ) + .await; + } + assert!(repos.workspace_checkout(&root, &elsewhere).await.is_none()); + } + #[tokio::test] async fn concurrent_searches_of_one_checkout_do_not_cancel_each_other() { let data = tempfile::tempdir().unwrap(); diff --git a/crates/engine/src/workspace_files.rs b/crates/engine/src/workspace_files.rs index 82edac4b5a..3b9d4c6baa 100644 --- a/crates/engine/src/workspace_files.rs +++ b/crates/engine/src/workspace_files.rs @@ -263,7 +263,7 @@ impl WorkspaceFiles { &self, target: &WorkspaceTarget, ) -> Result { - let root = match (&target.chat_id, &target.space_id) { + let (root, identity) = match (&target.chat_id, &target.space_id) { (Some(_), Some(_)) | (None, None) => { return Err(WorkspaceFilesError::BadParams( "workspace target needs exactly one of chatId or spaceId".into(), @@ -327,15 +327,18 @@ impl WorkspaceFiles { "chat space belongs to another device".into(), )); } - self.inner - .repos - .workspace_checkout(Path::new(&space.path), &cwd) - .await - .ok_or_else(|| { - WorkspaceFilesError::Authorization( - "chat folder is not a workspace checkout".into(), - ) - })? + let (checkout, identity) = tokio::join!( + self.inner + .repos + .workspace_checkout(Path::new(&space.path), &cwd), + self.inner.repos.checkout_identity(&cwd), + ); + let root = checkout.ok_or_else(|| { + WorkspaceFilesError::Authorization( + "chat folder is not a workspace checkout".into(), + ) + })?; + (root, identity) } (None, Some(space_id)) => { let space = self @@ -354,7 +357,8 @@ impl WorkspaceFiles { .checkout_path .as_deref() .map_or_else(|| space_path.clone(), PathBuf::from); - self.inner + let root = self + .inner .repos .workspace_checkout(&space_path, &requested) .await @@ -362,14 +366,16 @@ impl WorkspaceFiles { WorkspaceFilesError::BadParams( "checkoutPath is not a workspace checkout".into(), ) - })? + })?; + let identity = self.inner.repos.checkout_identity(&root).await; + (root, identity) } }; // Spaces also support plain folders. Git checkouts use their canonical // git-dir identity; plain roots get a device-scoped stable key so the // existing SearchFiles behavior and shared watcher semantics remain intact. - match self.inner.repos.checkout_identity(&root).await { + match identity { Ok(identity) => Ok(ResolvedWorkspace { checkout_id: identity.id, root: identity.root,