mirror of
https://github.com/openai/codex.git
synced 2026-11-08 20:40:33 +00:00
## Why Git root discovery is optional metadata work, but filesystem probes can block. They should not exhaust Tokio's blocking pool, delay runtime shutdown, or prevent later turns from observing repository changes. ## What changed - Add a shared `GitRootDiscovery` service that coalesces concurrent lookups for the same working directory and limits probes across directories. - Run probes on detached threads, retain in-flight work across caller cancellation, and discard completed results instead of caching them. - Use the service for turn and memory metadata enrichment, abort unused turn enrichment when its state is dropped, and limit memory metadata waits to one second. ## Testing Add coverage for probe sharing, capacity limits, cancellation, fresh discovery, runtime shutdown, memory timeouts, and repositories restored after startup prewarming. GitOrigin-RevId: bca46fc263e7a12a2f69146d8a0b3e7c7e0846cb
597 lines
20 KiB
Rust
597 lines
20 KiB
Rust
use anyhow::Context;
|
|
use anyhow::Result;
|
|
use anyhow::ensure;
|
|
use codex_core::StartThreadOptions;
|
|
use codex_core::TurnInputRequest;
|
|
#[cfg(not(target_os = "windows"))]
|
|
use codex_core::config::Constrained;
|
|
#[cfg(not(target_os = "windows"))]
|
|
use codex_core::sandboxing::SandboxPermissions;
|
|
#[cfg(not(target_os = "windows"))]
|
|
use codex_protocol::config_types::ApprovalsReviewer;
|
|
#[cfg(not(target_os = "windows"))]
|
|
use codex_protocol::protocol::AskForApproval;
|
|
use codex_protocol::protocol::EventMsg;
|
|
use codex_protocol::protocol::ThreadSource;
|
|
use codex_protocol::user_input::UserInput;
|
|
use core_test_support::PathBufExt;
|
|
use core_test_support::responses::assert_root_turn;
|
|
use core_test_support::responses::ev_assistant_message;
|
|
use core_test_support::responses::ev_completed;
|
|
use core_test_support::responses::ev_function_call;
|
|
use core_test_support::responses::ev_response_created;
|
|
use core_test_support::responses::start_websocket_server;
|
|
use core_test_support::skip_if_no_network;
|
|
use core_test_support::test_codex::test_codex;
|
|
use core_test_support::wait_for_event;
|
|
use pretty_assertions::assert_eq;
|
|
use serde_json::Value;
|
|
use serde_json::json;
|
|
use std::path::Path;
|
|
use std::process::Command;
|
|
use std::time::Duration;
|
|
use tempfile::TempDir;
|
|
use test_case::test_case;
|
|
|
|
const ORIGIN_URL: &str = "https://example.invalid/cxa5426/repo.git";
|
|
|
|
fn run_git(cwd: &Path, args: &[&str]) -> Result<String> {
|
|
let output = Command::new("git")
|
|
.args(args)
|
|
.current_dir(cwd)
|
|
.output()
|
|
.with_context(|| format!("run git {}", args.join(" ")))?;
|
|
ensure!(
|
|
output.status.success(),
|
|
"git {} failed: {}",
|
|
args.join(" "),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
Ok(String::from_utf8(output.stdout)?.trim().to_string())
|
|
}
|
|
|
|
fn create_git_repo() -> Result<(TempDir, String)> {
|
|
let repo = TempDir::new()?;
|
|
run_git(repo.path(), &["init", "-q", "-b", "main"])?;
|
|
std::fs::write(repo.path().join("README.md"), "initial\n")?;
|
|
run_git(repo.path(), &["add", "README.md"])?;
|
|
run_git(
|
|
repo.path(),
|
|
&[
|
|
"-c",
|
|
"user.name=CXA-5426",
|
|
"-c",
|
|
"user.email=cxa5426@example.invalid",
|
|
"commit",
|
|
"-q",
|
|
"-m",
|
|
"initial",
|
|
],
|
|
)?;
|
|
run_git(repo.path(), &["remote", "add", "origin", ORIGIN_URL])?;
|
|
let head = run_git(repo.path(), &["rev-parse", "HEAD"])?;
|
|
Ok((repo, head))
|
|
}
|
|
|
|
fn turn_metadata(request: &Value) -> Result<Value> {
|
|
serde_json::from_str(
|
|
request["client_metadata"]["x-codex-turn-metadata"]
|
|
.as_str()
|
|
.context("turn metadata")?,
|
|
)
|
|
.context("valid turn metadata")
|
|
}
|
|
|
|
fn expected_workspace(repo: &Path, head: &str, has_changes: bool) -> Value {
|
|
json!({
|
|
repo.to_string_lossy().as_ref(): {
|
|
"associated_remote_urls": {"origin": ORIGIN_URL},
|
|
"latest_git_commit_hash": head,
|
|
"has_changes": has_changes,
|
|
}
|
|
})
|
|
}
|
|
|
|
enum GitMetadataAtStartup {
|
|
Present,
|
|
RestoredAfterPrewarm,
|
|
}
|
|
|
|
#[test_case(GitMetadataAtStartup::Present; "git present at startup")]
|
|
#[test_case(GitMetadataAtStartup::RestoredAfterPrewarm; "git restored after prewarm")]
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn startup_prewarm_skips_git_enrichment_and_user_turn_observes_fresh_state(
|
|
git_metadata: GitMetadataAtStartup,
|
|
) -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let (repo, head) = create_git_repo()?;
|
|
let saved_git = TempDir::new()?;
|
|
if matches!(git_metadata, GitMetadataAtStartup::RestoredAfterPrewarm) {
|
|
std::fs::rename(repo.path().join(".git"), saved_git.path().join("git"))?;
|
|
}
|
|
let server = start_websocket_server(vec![vec![
|
|
vec![ev_response_created("warm-1"), ev_completed("warm-1")],
|
|
vec![
|
|
ev_response_created("resp-1"),
|
|
ev_function_call(
|
|
"wait-for-git",
|
|
"test_sync_tool",
|
|
r#"{"wait_for_git_enrichment":true}"#,
|
|
),
|
|
ev_completed("resp-1"),
|
|
],
|
|
vec![
|
|
ev_response_created("resp-2"),
|
|
ev_assistant_message("msg-2", "done"),
|
|
ev_completed("resp-2"),
|
|
],
|
|
]])
|
|
.await;
|
|
let cwd = repo.path().to_path_buf();
|
|
let mut builder = test_codex()
|
|
.with_model("test-gpt-5.1-codex")
|
|
.with_config(move |config| {
|
|
config.cwd = cwd.abs();
|
|
});
|
|
let test = builder.build_with_websocket_server(&server).await?;
|
|
|
|
let prewarm = tokio::time::timeout(
|
|
Duration::from_secs(5),
|
|
server.wait_for_request(/*connection_index*/ 0, /*request_index*/ 0),
|
|
)
|
|
.await?
|
|
.body_json();
|
|
assert_eq!(prewarm["generate"], json!(false));
|
|
assert!(turn_metadata(&prewarm)?.get("workspaces").is_none());
|
|
|
|
if matches!(git_metadata, GitMetadataAtStartup::RestoredAfterPrewarm) {
|
|
std::fs::rename(saved_git.path().join("git"), repo.path().join(".git"))?;
|
|
}
|
|
std::fs::write(repo.path().join("untracked.txt"), "dirty\n")?;
|
|
test.submit_turn("inspect the workspace").await?;
|
|
let turn = server
|
|
.single_connection()
|
|
.get(2)
|
|
.context("turn follow-up request")?
|
|
.body_json();
|
|
assert_eq!(
|
|
turn_metadata(&turn)?["workspaces"],
|
|
expected_workspace(repo.path(), &head, /*has_changes*/ true)
|
|
);
|
|
|
|
test.codex.shutdown_and_wait().await?;
|
|
server.shutdown().await;
|
|
Ok(())
|
|
}
|
|
|
|
/// Repository credentials must never cross the outbound model-request boundary.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn user_turn_git_enrichment_redacts_remote_credentials() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let (repo, head) = create_git_repo()?;
|
|
run_git(
|
|
repo.path(),
|
|
&[
|
|
"remote",
|
|
"set-url",
|
|
"origin",
|
|
"https://git-user:git-secret-token@example.invalid/cxa5426/repo.git",
|
|
],
|
|
)?;
|
|
let server = start_websocket_server(vec![vec![
|
|
vec![ev_response_created("warm-1"), ev_completed("warm-1")],
|
|
vec![
|
|
ev_response_created("resp-1"),
|
|
ev_function_call(
|
|
"wait-for-git",
|
|
"test_sync_tool",
|
|
r#"{"wait_for_git_enrichment":true}"#,
|
|
),
|
|
ev_completed("resp-1"),
|
|
],
|
|
vec![
|
|
ev_response_created("resp-2"),
|
|
ev_assistant_message("msg-2", "done"),
|
|
ev_completed("resp-2"),
|
|
],
|
|
]])
|
|
.await;
|
|
let cwd = repo.path().to_path_buf();
|
|
let mut builder = test_codex()
|
|
.with_model("test-gpt-5.1-codex")
|
|
.with_config(move |config| {
|
|
config.cwd = cwd.abs();
|
|
});
|
|
let test = builder.build_with_websocket_server(&server).await?;
|
|
|
|
test.submit_turn("inspect the workspace").await?;
|
|
let turn = server
|
|
.single_connection()
|
|
.get(2)
|
|
.context("turn follow-up request")?
|
|
.body_json();
|
|
let serialized_turn = serde_json::to_string(&turn)?;
|
|
assert!(!serialized_turn.contains("git-user"));
|
|
assert!(!serialized_turn.contains("git-secret-token"));
|
|
assert_eq!(
|
|
turn_metadata(&turn)?["workspaces"],
|
|
expected_workspace(repo.path(), &head, /*has_changes*/ false)
|
|
);
|
|
|
|
test.codex.shutdown_and_wait().await?;
|
|
server.shutdown().await;
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(not(target_os = "windows"))]
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn guardian_prewarm_and_review_skip_redundant_git_enrichment() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let (repo, head) = create_git_repo()?;
|
|
let tool_args = json!({
|
|
"cmd": "true",
|
|
"sandbox_permissions": SandboxPermissions::RequireEscalated,
|
|
"justification": "Exercise Guardian approval routing.",
|
|
})
|
|
.to_string();
|
|
let server = start_websocket_server(vec![
|
|
vec![vec![ev_response_created("warm-1"), ev_completed("warm-1")]],
|
|
vec![vec![ev_response_created("warm-2"), ev_completed("warm-2")]],
|
|
vec![vec![
|
|
ev_response_created("wait-for-parent-git"),
|
|
ev_function_call(
|
|
"wait-for-parent-git",
|
|
"test_sync_tool",
|
|
r#"{"wait_for_git_enrichment":true}"#,
|
|
),
|
|
ev_completed("wait-for-parent-git"),
|
|
]],
|
|
vec![vec![
|
|
ev_response_created("approval-request"),
|
|
ev_function_call("approval-call", "exec_command", &tool_args),
|
|
ev_completed("approval-request"),
|
|
]],
|
|
vec![vec![
|
|
ev_response_created("guardian-review"),
|
|
ev_completed("guardian-review"),
|
|
]],
|
|
])
|
|
.await;
|
|
let cwd = repo.path().to_path_buf();
|
|
let mut builder = test_codex()
|
|
.with_model("test-gpt-5.1-codex")
|
|
.with_config(move |config| {
|
|
config.cwd = cwd.abs();
|
|
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
|
config.approvals_reviewer = ApprovalsReviewer::AutoReview;
|
|
});
|
|
let test = builder.build_with_websocket_server(&server).await?;
|
|
|
|
let (first, second) = tokio::time::timeout(Duration::from_secs(5), async {
|
|
tokio::join!(
|
|
server.wait_for_request(/*connection_index*/ 0, /*request_index*/ 0),
|
|
server.wait_for_request(/*connection_index*/ 1, /*request_index*/ 0)
|
|
)
|
|
})
|
|
.await?;
|
|
for prewarm in [first.body_json(), second.body_json()] {
|
|
assert_eq!(prewarm["generate"].as_bool(), Some(false));
|
|
assert!(turn_metadata(&prewarm)?.get("workspaces").is_none());
|
|
}
|
|
|
|
std::fs::write(repo.path().join("untracked.txt"), "dirty\n")?;
|
|
test.codex
|
|
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
|
|
text: "run a command that requires Guardian review".into(),
|
|
text_elements: Vec::new(),
|
|
}]))
|
|
.await?;
|
|
let (initial_user_turn, user_turn, guardian_turn) =
|
|
tokio::time::timeout(Duration::from_secs(15), async {
|
|
tokio::join!(
|
|
server.wait_for_request(/*connection_index*/ 2, /*request_index*/ 0),
|
|
server.wait_for_request(/*connection_index*/ 3, /*request_index*/ 0),
|
|
server.wait_for_request(/*connection_index*/ 4, /*request_index*/ 0)
|
|
)
|
|
})
|
|
.await?;
|
|
let initial_user_turn = initial_user_turn.body_json();
|
|
let user_turn = user_turn.body_json();
|
|
let guardian_turn = guardian_turn.body_json();
|
|
let initial_user_turn_metadata = turn_metadata(&initial_user_turn)?;
|
|
assert_eq!(
|
|
turn_metadata(&user_turn)?["auto_review_enabled"].as_bool(),
|
|
Some(true)
|
|
);
|
|
if let Some(workspaces) = initial_user_turn_metadata.get("workspaces") {
|
|
assert_eq!(
|
|
workspaces,
|
|
&expected_workspace(repo.path(), &head, /*has_changes*/ true)
|
|
);
|
|
}
|
|
assert_eq!(
|
|
turn_metadata(&user_turn)?["workspaces"],
|
|
expected_workspace(repo.path(), &head, /*has_changes*/ true)
|
|
);
|
|
assert_eq!(
|
|
guardian_turn["client_metadata"]["x-openai-subagent"].as_str(),
|
|
Some("guardian")
|
|
);
|
|
assert!(turn_metadata(&guardian_turn)?.get("workspaces").is_none());
|
|
|
|
test.codex.shutdown_and_wait().await?;
|
|
server.shutdown().await;
|
|
Ok(())
|
|
}
|
|
|
|
#[test_case("system"; "system background thread")]
|
|
#[test_case("ambient_background"; "ambient background thread")]
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn ephemeral_system_thread_prewarm_skips_and_turn_observes_fresh_state(
|
|
thread_source: &str,
|
|
) -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let (repo, head) = create_git_repo()?;
|
|
let server = start_websocket_server(vec![
|
|
vec![vec![ev_response_created("warm-1"), ev_completed("warm-1")]],
|
|
vec![
|
|
vec![ev_response_created("warm-2"), ev_completed("warm-2")],
|
|
vec![
|
|
ev_response_created("resp-1"),
|
|
ev_function_call(
|
|
"wait-for-git",
|
|
"test_sync_tool",
|
|
r#"{"sleep_after_ms":1000}"#,
|
|
),
|
|
ev_completed("resp-1"),
|
|
],
|
|
vec![
|
|
ev_response_created("resp-2"),
|
|
ev_assistant_message("msg-2", "done"),
|
|
ev_completed("resp-2"),
|
|
],
|
|
],
|
|
])
|
|
.await;
|
|
let cwd = repo.path().to_path_buf();
|
|
let mut builder = test_codex()
|
|
.with_model("test-gpt-5.1-codex")
|
|
.with_config(move |config| {
|
|
config.cwd = cwd.abs();
|
|
});
|
|
let test = builder.build_with_websocket_server(&server).await?;
|
|
tokio::time::timeout(
|
|
Duration::from_secs(5),
|
|
server.wait_for_request(/*connection_index*/ 0, /*request_index*/ 0),
|
|
)
|
|
.await?;
|
|
|
|
let mut config = test.config.clone();
|
|
config.ephemeral = true;
|
|
let system_thread = test
|
|
.thread_manager
|
|
.start_thread(StartThreadOptions {
|
|
thread_source: Some(ThreadSource::Feature(thread_source.to_string())),
|
|
..StartThreadOptions::new(config)
|
|
})
|
|
.await?;
|
|
let prewarm = tokio::time::timeout(
|
|
Duration::from_secs(5),
|
|
server.wait_for_request(/*connection_index*/ 1, /*request_index*/ 0),
|
|
)
|
|
.await?
|
|
.body_json();
|
|
assert!(turn_metadata(&prewarm)?.get("workspaces").is_none());
|
|
|
|
std::fs::write(repo.path().join("untracked.txt"), "dirty\n")?;
|
|
system_thread
|
|
.thread
|
|
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
|
|
text: "generate a thread title".into(),
|
|
text_elements: Vec::new(),
|
|
}]))
|
|
.await?;
|
|
wait_for_event(system_thread.thread.as_ref(), |event| {
|
|
matches!(event, EventMsg::TurnComplete(_))
|
|
})
|
|
.await;
|
|
let turn = server
|
|
.connections()
|
|
.get(1)
|
|
.and_then(|connection| connection.get(2))
|
|
.context("system turn follow-up request")?
|
|
.body_json();
|
|
assert_root_turn(&turn, /*expected*/ None)?;
|
|
assert_eq!(
|
|
turn_metadata(&turn)?["workspaces"],
|
|
expected_workspace(repo.path(), &head, /*has_changes*/ true)
|
|
);
|
|
|
|
system_thread.thread.shutdown_and_wait().await?;
|
|
test.codex.shutdown_and_wait().await?;
|
|
server.shutdown().await;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn concurrent_turns_keep_distinct_worktree_and_repository_metadata() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let (repo, repo_head) = create_git_repo()?;
|
|
let worktree_parent = TempDir::new()?;
|
|
let worktree = worktree_parent.path().join("linked-worktree");
|
|
run_git(
|
|
repo.path(),
|
|
&[
|
|
"worktree",
|
|
"add",
|
|
"-q",
|
|
"-b",
|
|
"feature/cxa5426",
|
|
worktree.to_str().context("worktree path")?,
|
|
],
|
|
)?;
|
|
std::fs::write(worktree.join("branch.txt"), "branch commit\n")?;
|
|
run_git(&worktree, &["add", "branch.txt"])?;
|
|
run_git(
|
|
&worktree,
|
|
&[
|
|
"-c",
|
|
"user.name=CXA-5426",
|
|
"-c",
|
|
"user.email=cxa5426@example.invalid",
|
|
"commit",
|
|
"-q",
|
|
"-m",
|
|
"branch commit",
|
|
],
|
|
)?;
|
|
let worktree_head = run_git(&worktree, &["rev-parse", "HEAD"])?;
|
|
let (other_repo, other_head) = create_git_repo()?;
|
|
|
|
let initial_turn = |name: &str| {
|
|
vec![vec![
|
|
ev_response_created(&format!("turn-{name}")),
|
|
ev_function_call(
|
|
&format!("wait-for-git-{name}"),
|
|
"test_sync_tool",
|
|
r#"{"barrier":{"id":"concurrent-git-enrichment","participants":3,"timeout_ms":10000},"wait_for_git_enrichment":true}"#,
|
|
),
|
|
ev_completed(&format!("turn-{name}")),
|
|
]]
|
|
};
|
|
let follow_up_turn = |name: &str| {
|
|
vec![vec![
|
|
ev_response_created(&format!("follow-up-{name}")),
|
|
ev_assistant_message(&format!("msg-{name}"), "done"),
|
|
ev_completed(&format!("follow-up-{name}")),
|
|
]]
|
|
};
|
|
let server = start_websocket_server(vec![
|
|
vec![vec![ev_response_created("warm-1"), ev_completed("warm-1")]],
|
|
vec![vec![ev_response_created("warm-2"), ev_completed("warm-2")]],
|
|
vec![vec![ev_response_created("warm-3"), ev_completed("warm-3")]],
|
|
initial_turn("repo"),
|
|
initial_turn("worktree"),
|
|
initial_turn("other-repo"),
|
|
follow_up_turn("repo"),
|
|
follow_up_turn("worktree"),
|
|
follow_up_turn("other-repo"),
|
|
])
|
|
.await;
|
|
|
|
let repo_cwd = repo.path().to_path_buf();
|
|
let mut builder = test_codex()
|
|
.with_model("test-gpt-5.1-codex")
|
|
.with_config(move |config| {
|
|
config.cwd = repo_cwd.abs();
|
|
});
|
|
let test = builder.build_with_websocket_server(&server).await?;
|
|
|
|
let mut worktree_config = test.config.clone();
|
|
worktree_config.cwd = worktree.abs();
|
|
let worktree_thread = test
|
|
.thread_manager
|
|
.start_thread(StartThreadOptions {
|
|
thread_source: Some(ThreadSource::User),
|
|
..StartThreadOptions::new(worktree_config)
|
|
})
|
|
.await?;
|
|
|
|
let mut other_config = test.config.clone();
|
|
other_config.cwd = other_repo.path().to_path_buf().abs();
|
|
let other_thread = test
|
|
.thread_manager
|
|
.start_thread(StartThreadOptions {
|
|
thread_source: Some(ThreadSource::User),
|
|
..StartThreadOptions::new(other_config)
|
|
})
|
|
.await?;
|
|
|
|
tokio::time::timeout(Duration::from_secs(5), async {
|
|
tokio::join!(
|
|
server.wait_for_request(/*connection_index*/ 0, /*request_index*/ 0),
|
|
server.wait_for_request(/*connection_index*/ 1, /*request_index*/ 0),
|
|
server.wait_for_request(/*connection_index*/ 2, /*request_index*/ 0)
|
|
)
|
|
})
|
|
.await?;
|
|
|
|
std::fs::write(repo.path().join("untracked.txt"), "dirty\n")?;
|
|
std::fs::write(other_repo.path().join("untracked.txt"), "dirty\n")?;
|
|
let user_turn = |prompt: &str| {
|
|
TurnInputRequest::user_input(vec![UserInput::Text {
|
|
text: prompt.to_string(),
|
|
text_elements: Vec::new(),
|
|
}])
|
|
};
|
|
tokio::try_join!(
|
|
test.codex
|
|
.start_or_steer_turn(user_turn("inspect the main worktree")),
|
|
worktree_thread
|
|
.thread
|
|
.start_or_steer_turn(user_turn("inspect the linked worktree")),
|
|
other_thread
|
|
.thread
|
|
.start_or_steer_turn(user_turn("inspect the other repository"))
|
|
)?;
|
|
tokio::join!(
|
|
wait_for_event(test.codex.as_ref(), |event| matches!(
|
|
event,
|
|
EventMsg::TurnComplete(_)
|
|
)),
|
|
wait_for_event(worktree_thread.thread.as_ref(), |event| matches!(
|
|
event,
|
|
EventMsg::TurnComplete(_)
|
|
)),
|
|
wait_for_event(other_thread.thread.as_ref(), |event| matches!(
|
|
event,
|
|
EventMsg::TurnComplete(_)
|
|
))
|
|
);
|
|
|
|
let mut actual_workspaces = server
|
|
.connections()
|
|
.into_iter()
|
|
.skip(6)
|
|
.map(|connection| {
|
|
let request = connection
|
|
.first()
|
|
.context("synchronized turn follow-up request")?
|
|
.body_json();
|
|
let thread_id = request["client_metadata"]["thread_id"]
|
|
.as_str()
|
|
.context("follow-up thread id")?
|
|
.to_string();
|
|
Ok((thread_id, turn_metadata(&request)?["workspaces"].clone()))
|
|
})
|
|
.collect::<Result<Vec<_>>>()?;
|
|
let mut expected_workspaces = vec![
|
|
(
|
|
test.session_configured.thread_id.to_string(),
|
|
expected_workspace(repo.path(), &repo_head, /*has_changes*/ true),
|
|
),
|
|
(
|
|
worktree_thread.thread_id.to_string(),
|
|
expected_workspace(&worktree, &worktree_head, /*has_changes*/ false),
|
|
),
|
|
(
|
|
other_thread.thread_id.to_string(),
|
|
expected_workspace(other_repo.path(), &other_head, /*has_changes*/ true),
|
|
),
|
|
];
|
|
actual_workspaces.sort_by(|(left, _), (right, _)| left.cmp(right));
|
|
expected_workspaces.sort_by(|(left, _), (right, _)| left.cmp(right));
|
|
assert_eq!(actual_workspaces, expected_workspaces);
|
|
|
|
worktree_thread.thread.shutdown_and_wait().await?;
|
|
other_thread.thread.shutdown_and_wait().await?;
|
|
test.codex.shutdown_and_wait().await?;
|
|
server.shutdown().await;
|
|
Ok(())
|
|
}
|