Files
codex/codex-rs/worktree/src/metadata.rs
Benjamin Carlsson 23cedf4802 Add thread ownership metadata for managed worktrees (#40716)
## What changed

- Add `WorktreeManager` APIs to bind a managed linked worktree to a thread and read its owner.
- Store the versioned `codex-thread.json` record in Git metadata with atomic, no-clobber writes, making repeat bindings idempotent while rejecting conflicting owners.
- Validate the managed worktree layout and reject primary, nested, and unmanaged checkouts before accessing ownership metadata.
- Run Git metadata queries with repository-selecting environment variables removed and hooks, filesystem monitoring, attributes, and LFS smudging disabled.

## Testing

- Add integration coverage for rejecting primary and unmanaged worktrees and for writing and reading the expected ownership schema.

GitOrigin-RevId: afec9a0ab711fe4017074bcec5807d860e9c88d0
2026-08-25 21:44:43 +00:00

92 lines
3.0 KiB
Rust

//! Reads and binds Desktop-compatible thread ownership for managed worktrees.
//!
//! Stores a versioned `codex-thread.json` record in each worktree's Git metadata.
//! Binding publishes the record atomically without replacing another owner;
//! binding the same thread again leaves the existing record unchanged.
use crate::git::git_path;
use anyhow::Context;
use anyhow::Result;
use anyhow::bail;
use serde::Deserialize;
use serde::Serialize;
use std::fs;
use std::io::Write;
use std::path::Path;
use std::path::PathBuf;
use tempfile::NamedTempFile;
const OWNER_FILENAME: &str = "codex-thread.json";
const OWNER_VERSION: u8 = 1;
#[derive(Debug, Deserialize, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct OwnerRecord {
version: u8,
owner_thread_id: String,
}
pub(crate) fn owner(checkout: &Path) -> Result<Option<String>> {
let path = metadata_path(checkout)?;
let contents = match fs::read(&path) {
Ok(contents) => contents,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => {
return Err(error)
.with_context(|| format!("cannot read worktree owner at {}", path.display()));
}
};
let record: OwnerRecord = serde_json::from_slice(&contents)
.with_context(|| format!("invalid worktree owner at {}", path.display()))?;
if record.version != OWNER_VERSION || record.owner_thread_id.is_empty() {
bail!("invalid worktree owner at {}", path.display());
}
Ok(Some(record.owner_thread_id))
}
pub(crate) fn bind_thread(checkout: &Path, thread_id: &str) -> Result<()> {
if thread_id.is_empty() {
bail!("worktree owner thread ID cannot be empty");
}
if let Some(existing) = owner(checkout)? {
if existing == thread_id {
return Ok(());
}
bail!("worktree already belongs to thread {existing}");
}
let path = metadata_path(checkout)?;
let directory = path.parent().context("worktree metadata has no parent")?;
let mut temporary = NamedTempFile::new_in(directory)
.with_context(|| format!("cannot create worktree metadata in {}", directory.display()))?;
serde_json::to_writer(
&mut temporary,
&OwnerRecord {
version: OWNER_VERSION,
owner_thread_id: thread_id.to_owned(),
},
)?;
temporary.flush()?;
match temporary.persist_noclobber(&path) {
Ok(_) => Ok(()),
Err(error) if error.error.kind() == std::io::ErrorKind::AlreadyExists => {
if owner(checkout)?.as_deref() == Some(thread_id) {
Ok(())
} else {
bail!("worktree was concurrently assigned to another thread")
}
}
Err(error) => Err(error.error)
.with_context(|| format!("cannot write worktree owner at {}", path.display())),
}
}
fn metadata_path(checkout: &Path) -> Result<PathBuf> {
let path = git_path(checkout, ["rev-parse", "--git-path", OWNER_FILENAME])?;
Ok(checkout.join(path))
}