Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 114 additions & 12 deletions crates/engine/src/repos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<CheckoutIdentity, EngineError> {
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));
Expand Down Expand Up @@ -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<Vec<PathBuf>, 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<PathBuf> {
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)
Expand Down Expand Up @@ -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();
Expand Down
32 changes: 19 additions & 13 deletions crates/engine/src/workspace_files.rs
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ impl WorkspaceFiles {
&self,
target: &WorkspaceTarget,
) -> Result<ResolvedWorkspace, WorkspaceFilesError> {
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(),
Expand Down Expand Up @@ -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
Expand All @@ -354,22 +357,25 @@ 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
.ok_or_else(|| {
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,
Expand Down
Loading