mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
39aab9fc45
## Why When Codex uses a remote `ExecutorFileSystem`, every `get_metadata` call is an exec-server round trip. Upward discovery currently pays those round trips serially in two latency-sensitive places: - session startup, while locating the configured project root before loading `AGENTS.md`; and - Git-root discovery, which runs before per-turn Git diff enrichment. The goal is to remove the serial ancestor dependency without adding a new filesystem RPC, JSON-RPC batch method, Git executable dependency, or cache. ## Example Assume this layout, with `.git` as the configured project-root marker: ```text /workspace/repo/.git /workspace/repo/AGENTS.md /workspace/repo/crates/core/ <- cwd ``` The marker probes have this required precedence: ```text 1. /workspace/repo/crates/core/.git 2. /workspace/repo/crates/.git 3. /workspace/repo/.git 4. /workspace/.git 5. /.git ``` Previously, probe 2 was not sent until probe 1 returned, and probe 3 was not sent until probe 2 returned. With this change, the client lazily keeps up to eight ordinary `fs/getMetadata` requests in flight, but consumes their results in the order above. Codex must still learn that probes 1 and 2 are absent before accepting probe 3, so the nearest root always wins. Once probe 3 succeeds, the client has its answer and stops awaiting probes 4 and 5. Requests that were already sent may still finish on the worker. For the marker phase alone, with a 50 ms client-to-worker round trip and fast local metadata calls, finding the root at probe 3 changes from roughly three serialized round trips (150 ms) to one round trip plus worker processing. The later `AGENTS.md` candidate phase remains separate and ordered. Only after `/workspace/repo` is selected does `AGENTS.md` discovery check instruction candidates, in root-to-cwd order: ```text /workspace/repo/AGENTS.override.md /workspace/repo/AGENTS.md /workspace/repo/crates/AGENTS.override.md /workspace/repo/crates/AGENTS.md /workspace/repo/crates/core/AGENTS.override.md /workspace/repo/crates/core/AGENTS.md ``` The first configured candidate found in each directory wins. These checks remain ordered and no instruction candidate above `/workspace/repo` is issued. Git-root discovery uses the same bounded lookup with only `.git` as the marker. ## What changed - Added a client-side find-up helper that generates `ancestor x marker` probes lazily, nearest directory first and configured marker order within each directory. - Uses an ordered concurrency window of eight scalar metadata requests. This bounds executor load while preserving nearest-root and marker precedence. - Reuses the helper for both configured project-root discovery and remote Git-root discovery. - Keeps Git ancestor and marker construction in `AbsolutePathBuf`, converting only each complete `.git` probe to `PathUri`. This preserves native paths that require an opaque URI fallback, such as Windows namespace paths. - Preserves existing error behavior: `AGENTS.md` discovery propagates non-`NotFound` metadata errors, while Git discovery treats a failed marker probe as absent and continues upward. - Reads each discovered `AGENTS.md` directly instead of statting it a second time. No filesystem trait or exec-server protocol method is added. An empty `project_root_markers` list performs no ancestor-marker I/O and checks instruction candidates only in `cwd`. This change also deliberately does not cache roots across turns. ## Symlinks Upward traversal remains **lexical**. The helper does not canonicalize `cwd`; it appends marker names to the supplied path and walks that path's textual parents. The filesystem performs the actual metadata/read operation, and the current local and exec-server implementations follow live symlink targets. For example: ```text /tmp/pkg -> /workspace/repo/packages/pkg cwd = /tmp/pkg/src actual Git marker = /workspace/repo/.git ``` The lexical probes are `/tmp/pkg/src/.git`, `/tmp/pkg/.git`, `/tmp/.git`, and `/.git`. They do not jump from `/tmp/pkg` to the target's parent `/workspace/repo`, so this spelling of `cwd` does not discover `/workspace/repo/.git`. That is the existing behavior and is unchanged by this PR. Conversely, if `/tmp/repo -> /workspace/repo`, then probing `/tmp/repo/.git` follows the directory symlink and finds `/workspace/repo/.git`; the reported root remains the lexical path `/tmp/repo`. A live symlink used directly as `.git`, another configured marker, or `AGENTS.md` is also followed. A symlinked `AGENTS.md` is loaded when its target is a regular file, while a broken symlink behaves as `NotFound`.
858 lines
27 KiB
Rust
858 lines
27 KiB
Rust
use codex_exec_server::CopyOptions;
|
|
use codex_exec_server::CreateDirectoryOptions;
|
|
use codex_exec_server::ExecutorFileSystem;
|
|
use codex_exec_server::ExecutorFileSystemFuture;
|
|
use codex_exec_server::FileMetadata;
|
|
use codex_exec_server::FileSystemReadStream;
|
|
use codex_exec_server::FileSystemResult;
|
|
use codex_exec_server::FileSystemSandboxContext;
|
|
use codex_exec_server::LOCAL_FS;
|
|
use codex_exec_server::ReadDirectoryEntry;
|
|
use codex_exec_server::RemoveOptions;
|
|
use codex_git_utils::GitInfo;
|
|
use codex_git_utils::GitSha;
|
|
use codex_git_utils::collect_git_info;
|
|
use codex_git_utils::get_git_repo_root_with_fs;
|
|
use codex_git_utils::get_has_changes;
|
|
use codex_git_utils::git_diff_to_remote;
|
|
use codex_git_utils::recent_commits;
|
|
use codex_git_utils::resolve_root_git_project_for_trust;
|
|
use codex_utils_path::normalize_for_path_comparison;
|
|
use codex_utils_path_uri::PathUri;
|
|
use core_test_support::PathBufExt;
|
|
use core_test_support::PathExt;
|
|
use core_test_support::skip_if_sandbox;
|
|
use pretty_assertions::assert_eq;
|
|
use std::fs;
|
|
use std::io;
|
|
#[cfg(unix)]
|
|
use std::os::unix::fs::PermissionsExt;
|
|
use std::path::PathBuf;
|
|
use tempfile::TempDir;
|
|
use tokio::process::Command;
|
|
|
|
struct FailingMetadataFileSystem {
|
|
path: PathUri,
|
|
}
|
|
|
|
impl FailingMetadataFileSystem {
|
|
fn unsupported<T>() -> FileSystemResult<T> {
|
|
Err(io::Error::new(
|
|
io::ErrorKind::Unsupported,
|
|
"operation is not used by Git root discovery",
|
|
))
|
|
}
|
|
}
|
|
|
|
impl ExecutorFileSystem for FailingMetadataFileSystem {
|
|
fn canonicalize<'a>(
|
|
&'a self,
|
|
_path: &'a PathUri,
|
|
_sandbox: Option<&'a FileSystemSandboxContext>,
|
|
) -> ExecutorFileSystemFuture<'a, PathUri> {
|
|
Box::pin(async { Self::unsupported() })
|
|
}
|
|
|
|
fn read_file<'a>(
|
|
&'a self,
|
|
_path: &'a PathUri,
|
|
_sandbox: Option<&'a FileSystemSandboxContext>,
|
|
) -> ExecutorFileSystemFuture<'a, Vec<u8>> {
|
|
Box::pin(async { Self::unsupported() })
|
|
}
|
|
|
|
fn read_file_stream<'a>(
|
|
&'a self,
|
|
_path: &'a PathUri,
|
|
_sandbox: Option<&'a FileSystemSandboxContext>,
|
|
) -> ExecutorFileSystemFuture<'a, FileSystemReadStream> {
|
|
Box::pin(async { Self::unsupported() })
|
|
}
|
|
|
|
fn write_file<'a>(
|
|
&'a self,
|
|
_path: &'a PathUri,
|
|
_contents: Vec<u8>,
|
|
_sandbox: Option<&'a FileSystemSandboxContext>,
|
|
) -> ExecutorFileSystemFuture<'a, ()> {
|
|
Box::pin(async { Self::unsupported() })
|
|
}
|
|
|
|
fn create_directory<'a>(
|
|
&'a self,
|
|
_path: &'a PathUri,
|
|
_options: CreateDirectoryOptions,
|
|
_sandbox: Option<&'a FileSystemSandboxContext>,
|
|
) -> ExecutorFileSystemFuture<'a, ()> {
|
|
Box::pin(async { Self::unsupported() })
|
|
}
|
|
|
|
fn get_metadata<'a>(
|
|
&'a self,
|
|
path: &'a PathUri,
|
|
sandbox: Option<&'a FileSystemSandboxContext>,
|
|
) -> ExecutorFileSystemFuture<'a, FileMetadata> {
|
|
Box::pin(async move {
|
|
if path == &self.path {
|
|
Err(io::Error::new(
|
|
io::ErrorKind::PermissionDenied,
|
|
"injected metadata failure",
|
|
))
|
|
} else {
|
|
LOCAL_FS.get_metadata(path, sandbox).await
|
|
}
|
|
})
|
|
}
|
|
|
|
fn read_directory<'a>(
|
|
&'a self,
|
|
_path: &'a PathUri,
|
|
_sandbox: Option<&'a FileSystemSandboxContext>,
|
|
) -> ExecutorFileSystemFuture<'a, Vec<ReadDirectoryEntry>> {
|
|
Box::pin(async { Self::unsupported() })
|
|
}
|
|
|
|
fn remove<'a>(
|
|
&'a self,
|
|
_path: &'a PathUri,
|
|
_options: RemoveOptions,
|
|
_sandbox: Option<&'a FileSystemSandboxContext>,
|
|
) -> ExecutorFileSystemFuture<'a, ()> {
|
|
Box::pin(async { Self::unsupported() })
|
|
}
|
|
|
|
fn copy<'a>(
|
|
&'a self,
|
|
_source_path: &'a PathUri,
|
|
_destination_path: &'a PathUri,
|
|
_options: CopyOptions,
|
|
_sandbox: Option<&'a FileSystemSandboxContext>,
|
|
) -> ExecutorFileSystemFuture<'a, ()> {
|
|
Box::pin(async { Self::unsupported() })
|
|
}
|
|
}
|
|
|
|
// Helper function to create a test git repository
|
|
async fn create_test_git_repo(temp_dir: &TempDir) -> PathBuf {
|
|
let repo_path = temp_dir.path().join("repo");
|
|
fs::create_dir(&repo_path).expect("Failed to create repo dir");
|
|
let envs = vec![
|
|
("GIT_CONFIG_GLOBAL", "/dev/null"),
|
|
("GIT_CONFIG_NOSYSTEM", "1"),
|
|
];
|
|
|
|
// Initialize git repo
|
|
Command::new("git")
|
|
.envs(envs.clone())
|
|
.args(["init"])
|
|
.current_dir(&repo_path)
|
|
.output()
|
|
.await
|
|
.expect("Failed to init git repo");
|
|
|
|
// Configure git user (required for commits)
|
|
Command::new("git")
|
|
.envs(envs.clone())
|
|
.args(["config", "user.name", "Test User"])
|
|
.current_dir(&repo_path)
|
|
.output()
|
|
.await
|
|
.expect("Failed to set git user name");
|
|
|
|
Command::new("git")
|
|
.envs(envs.clone())
|
|
.args(["config", "user.email", "test@example.com"])
|
|
.current_dir(&repo_path)
|
|
.output()
|
|
.await
|
|
.expect("Failed to set git user email");
|
|
|
|
// Create a test file and commit it
|
|
let test_file = repo_path.join("test.txt");
|
|
fs::write(&test_file, "test content").expect("Failed to write test file");
|
|
|
|
Command::new("git")
|
|
.envs(envs.clone())
|
|
.args(["add", "."])
|
|
.current_dir(&repo_path)
|
|
.output()
|
|
.await
|
|
.expect("Failed to add files");
|
|
|
|
Command::new("git")
|
|
.envs(envs.clone())
|
|
.args(["commit", "-m", "Initial commit"])
|
|
.current_dir(&repo_path)
|
|
.output()
|
|
.await
|
|
.expect("Failed to commit");
|
|
|
|
repo_path
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_recent_commits_non_git_directory_returns_empty() {
|
|
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
|
let entries = recent_commits(temp_dir.path(), /*limit*/ 10).await;
|
|
assert!(entries.is_empty(), "expected no commits outside a git repo");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_recent_commits_orders_and_limits() {
|
|
skip_if_sandbox!();
|
|
use tokio::time::Duration;
|
|
use tokio::time::sleep;
|
|
|
|
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
|
let repo_path = create_test_git_repo(&temp_dir).await;
|
|
|
|
// Make three distinct commits with small delays to ensure ordering by timestamp.
|
|
fs::write(repo_path.join("file.txt"), "one").unwrap();
|
|
Command::new("git")
|
|
.args(["add", "file.txt"])
|
|
.current_dir(&repo_path)
|
|
.output()
|
|
.await
|
|
.expect("git add");
|
|
Command::new("git")
|
|
.args(["commit", "-m", "first change"])
|
|
.current_dir(&repo_path)
|
|
.output()
|
|
.await
|
|
.expect("git commit 1");
|
|
|
|
sleep(Duration::from_millis(1100)).await;
|
|
|
|
fs::write(repo_path.join("file.txt"), "two").unwrap();
|
|
Command::new("git")
|
|
.args(["add", "file.txt"])
|
|
.current_dir(&repo_path)
|
|
.output()
|
|
.await
|
|
.expect("git add 2");
|
|
Command::new("git")
|
|
.args(["commit", "-m", "second change"])
|
|
.current_dir(&repo_path)
|
|
.output()
|
|
.await
|
|
.expect("git commit 2");
|
|
|
|
sleep(Duration::from_millis(1100)).await;
|
|
|
|
fs::write(repo_path.join("file.txt"), "three").unwrap();
|
|
Command::new("git")
|
|
.args(["add", "file.txt"])
|
|
.current_dir(&repo_path)
|
|
.output()
|
|
.await
|
|
.expect("git add 3");
|
|
Command::new("git")
|
|
.args(["commit", "-m", "third change"])
|
|
.current_dir(&repo_path)
|
|
.output()
|
|
.await
|
|
.expect("git commit 3");
|
|
|
|
// Request the latest 3 commits; should be our three changes in reverse time order.
|
|
let entries = recent_commits(&repo_path, /*limit*/ 3).await;
|
|
assert_eq!(entries.len(), 3);
|
|
assert_eq!(entries[0].subject, "third change");
|
|
assert_eq!(entries[1].subject, "second change");
|
|
assert_eq!(entries[2].subject, "first change");
|
|
// Basic sanity on SHA formatting
|
|
for e in entries {
|
|
assert!(e.sha.len() >= 7 && e.sha.chars().all(|c| c.is_ascii_hexdigit()));
|
|
}
|
|
}
|
|
|
|
async fn create_test_git_repo_with_remote(temp_dir: &TempDir) -> (PathBuf, String) {
|
|
let repo_path = create_test_git_repo(temp_dir).await;
|
|
let remote_path = temp_dir.path().join("remote.git");
|
|
|
|
Command::new("git")
|
|
.args(["init", "--bare", remote_path.to_str().unwrap()])
|
|
.output()
|
|
.await
|
|
.expect("Failed to init bare remote");
|
|
|
|
Command::new("git")
|
|
.args(["remote", "add", "origin", remote_path.to_str().unwrap()])
|
|
.current_dir(&repo_path)
|
|
.output()
|
|
.await
|
|
.expect("Failed to add remote");
|
|
|
|
let output = Command::new("git")
|
|
.args(["rev-parse", "--abbrev-ref", "HEAD"])
|
|
.current_dir(&repo_path)
|
|
.output()
|
|
.await
|
|
.expect("Failed to get branch");
|
|
let branch = String::from_utf8(output.stdout).unwrap().trim().to_string();
|
|
|
|
Command::new("git")
|
|
.args(["push", "-u", "origin", &branch])
|
|
.current_dir(&repo_path)
|
|
.output()
|
|
.await
|
|
.expect("Failed to push initial commit");
|
|
|
|
(repo_path, branch)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_collect_git_info_non_git_directory() {
|
|
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
|
let result = collect_git_info(temp_dir.path()).await;
|
|
assert!(result.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_collect_git_info_git_repository() {
|
|
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
|
let repo_path = create_test_git_repo(&temp_dir).await;
|
|
|
|
let git_info = collect_git_info(&repo_path)
|
|
.await
|
|
.expect("Should collect git info from repo");
|
|
|
|
// Should have commit hash
|
|
assert!(git_info.commit_hash.is_some());
|
|
let commit_hash = git_info.commit_hash.unwrap().0;
|
|
assert_eq!(commit_hash.len(), 40); // SHA-1 hash should be 40 characters
|
|
assert!(commit_hash.chars().all(|c| c.is_ascii_hexdigit()));
|
|
|
|
// Should have branch (likely "main" or "master")
|
|
assert!(git_info.branch.is_some());
|
|
let branch = git_info.branch.unwrap();
|
|
assert!(branch == "main" || branch == "master");
|
|
|
|
// Repository URL might be None for local repos without remote
|
|
// This is acceptable behavior
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_collect_git_info_with_remote() {
|
|
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
|
let repo_path = create_test_git_repo(&temp_dir).await;
|
|
|
|
// Add a remote origin
|
|
Command::new("git")
|
|
.args([
|
|
"remote",
|
|
"add",
|
|
"origin",
|
|
"https://github.com/example/repo.git",
|
|
])
|
|
.current_dir(&repo_path)
|
|
.output()
|
|
.await
|
|
.expect("Failed to add remote");
|
|
|
|
let git_info = collect_git_info(&repo_path)
|
|
.await
|
|
.expect("Should collect git info from repo");
|
|
|
|
let remote_url_output = Command::new("git")
|
|
.args(["remote", "get-url", "origin"])
|
|
.current_dir(&repo_path)
|
|
.output()
|
|
.await
|
|
.expect("Failed to read remote url");
|
|
// Some dev environments rewrite remotes (e.g., force SSH), so compare against
|
|
// whatever URL Git reports instead of a fixed placeholder.
|
|
let expected_remote = String::from_utf8(remote_url_output.stdout)
|
|
.unwrap()
|
|
.trim()
|
|
.to_string();
|
|
|
|
// Should have repository URL
|
|
assert_eq!(git_info.repository_url, Some(expected_remote));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_collect_git_info_detached_head() {
|
|
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
|
let repo_path = create_test_git_repo(&temp_dir).await;
|
|
|
|
// Get the current commit hash
|
|
let output = Command::new("git")
|
|
.args(["rev-parse", "HEAD"])
|
|
.current_dir(&repo_path)
|
|
.output()
|
|
.await
|
|
.expect("Failed to get HEAD");
|
|
let commit_hash = String::from_utf8(output.stdout).unwrap().trim().to_string();
|
|
|
|
// Checkout the commit directly (detached HEAD)
|
|
Command::new("git")
|
|
.args(["checkout", &commit_hash])
|
|
.current_dir(&repo_path)
|
|
.output()
|
|
.await
|
|
.expect("Failed to checkout commit");
|
|
|
|
let git_info = collect_git_info(&repo_path)
|
|
.await
|
|
.expect("Should collect git info from repo");
|
|
|
|
// Should have commit hash
|
|
assert!(git_info.commit_hash.is_some());
|
|
// Branch should be None for detached HEAD (since rev-parse --abbrev-ref HEAD returns "HEAD")
|
|
assert!(git_info.branch.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_collect_git_info_with_branch() {
|
|
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
|
let repo_path = create_test_git_repo(&temp_dir).await;
|
|
|
|
// Create and checkout a new branch
|
|
Command::new("git")
|
|
.args(["checkout", "-b", "feature-branch"])
|
|
.current_dir(&repo_path)
|
|
.output()
|
|
.await
|
|
.expect("Failed to create branch");
|
|
|
|
let git_info = collect_git_info(&repo_path)
|
|
.await
|
|
.expect("Should collect git info from repo");
|
|
|
|
// Should have the new branch name
|
|
assert_eq!(git_info.branch, Some("feature-branch".to_string()));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_get_has_changes_non_git_directory_returns_none() {
|
|
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
|
assert_eq!(get_has_changes(temp_dir.path()).await, None);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_get_has_changes_clean_repo_returns_false() {
|
|
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
|
let repo_path = create_test_git_repo(&temp_dir).await;
|
|
assert_eq!(get_has_changes(&repo_path).await, Some(false));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_get_has_changes_with_tracked_change_returns_true() {
|
|
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
|
let repo_path = create_test_git_repo(&temp_dir).await;
|
|
|
|
fs::write(repo_path.join("test.txt"), "updated tracked file").expect("write tracked file");
|
|
assert_eq!(get_has_changes(&repo_path).await, Some(true));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_get_has_changes_with_untracked_change_returns_true() {
|
|
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
|
let repo_path = create_test_git_repo(&temp_dir).await;
|
|
|
|
fs::write(repo_path.join("new_file.txt"), "untracked").expect("write untracked file");
|
|
assert_eq!(get_has_changes(&repo_path).await, Some(true));
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn test_get_has_changes_ignores_configured_hooks_path() {
|
|
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
|
let repo_path = create_test_git_repo(&temp_dir).await;
|
|
let hooks_dir = repo_path.join(".git/hooks-path-test");
|
|
let hook_path = hooks_dir.join("post-index-change");
|
|
let marker_path = repo_path.join("hook-ran");
|
|
|
|
fs::create_dir_all(&hooks_dir).expect("create hook dir");
|
|
fs::write(
|
|
&hook_path,
|
|
format!(
|
|
"#!/bin/sh\nprintf ran > \"{}\"\n",
|
|
marker_path.to_string_lossy()
|
|
),
|
|
)
|
|
.expect("write post-index-change hook");
|
|
let mut permissions = fs::metadata(&hook_path)
|
|
.expect("read hook metadata")
|
|
.permissions();
|
|
permissions.set_mode(0o755);
|
|
fs::set_permissions(&hook_path, permissions).expect("mark hook executable");
|
|
|
|
Command::new("git")
|
|
.args([
|
|
"config",
|
|
"core.hooksPath",
|
|
hooks_dir.to_string_lossy().as_ref(),
|
|
])
|
|
.current_dir(&repo_path)
|
|
.output()
|
|
.await
|
|
.expect("configure hooks path");
|
|
|
|
fs::write(repo_path.join("test.txt"), "test content").expect("refresh tracked file");
|
|
|
|
assert_eq!(get_has_changes(&repo_path).await, Some(false));
|
|
assert!(
|
|
!marker_path.exists(),
|
|
"metadata collection should not invoke configured hook directories"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_get_git_working_tree_state_clean_repo() {
|
|
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
|
let (repo_path, branch) = create_test_git_repo_with_remote(&temp_dir).await;
|
|
|
|
let remote_sha = Command::new("git")
|
|
.args(["rev-parse", &format!("origin/{branch}")])
|
|
.current_dir(&repo_path)
|
|
.output()
|
|
.await
|
|
.expect("Failed to rev-parse remote");
|
|
let remote_sha = String::from_utf8(remote_sha.stdout)
|
|
.unwrap()
|
|
.trim()
|
|
.to_string();
|
|
|
|
let state = git_diff_to_remote(&repo_path)
|
|
.await
|
|
.expect("Should collect working tree state");
|
|
assert_eq!(state.sha, GitSha::new(&remote_sha));
|
|
assert!(state.diff.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_get_git_working_tree_state_with_changes() {
|
|
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
|
let (repo_path, branch) = create_test_git_repo_with_remote(&temp_dir).await;
|
|
|
|
let tracked = repo_path.join("test.txt");
|
|
fs::write(&tracked, "modified").unwrap();
|
|
fs::write(repo_path.join("untracked.txt"), "new").unwrap();
|
|
|
|
let remote_sha = Command::new("git")
|
|
.args(["rev-parse", &format!("origin/{branch}")])
|
|
.current_dir(&repo_path)
|
|
.output()
|
|
.await
|
|
.expect("Failed to rev-parse remote");
|
|
let remote_sha = String::from_utf8(remote_sha.stdout)
|
|
.unwrap()
|
|
.trim()
|
|
.to_string();
|
|
|
|
let state = git_diff_to_remote(&repo_path)
|
|
.await
|
|
.expect("Should collect working tree state");
|
|
assert_eq!(state.sha, GitSha::new(&remote_sha));
|
|
assert!(state.diff.contains("test.txt"));
|
|
assert!(state.diff.contains("untracked.txt"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_get_git_working_tree_state_branch_fallback() {
|
|
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
|
let (repo_path, _branch) = create_test_git_repo_with_remote(&temp_dir).await;
|
|
|
|
Command::new("git")
|
|
.args(["checkout", "-b", "feature"])
|
|
.current_dir(&repo_path)
|
|
.output()
|
|
.await
|
|
.expect("Failed to create feature branch");
|
|
Command::new("git")
|
|
.args(["push", "-u", "origin", "feature"])
|
|
.current_dir(&repo_path)
|
|
.output()
|
|
.await
|
|
.expect("Failed to push feature branch");
|
|
|
|
Command::new("git")
|
|
.args(["checkout", "-b", "local-branch"])
|
|
.current_dir(&repo_path)
|
|
.output()
|
|
.await
|
|
.expect("Failed to create local branch");
|
|
|
|
let remote_sha = Command::new("git")
|
|
.args(["rev-parse", "origin/feature"])
|
|
.current_dir(&repo_path)
|
|
.output()
|
|
.await
|
|
.expect("Failed to rev-parse remote");
|
|
let remote_sha = String::from_utf8(remote_sha.stdout)
|
|
.unwrap()
|
|
.trim()
|
|
.to_string();
|
|
|
|
let state = git_diff_to_remote(&repo_path)
|
|
.await
|
|
.expect("Should collect working tree state");
|
|
assert_eq!(state.sha, GitSha::new(&remote_sha));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resolve_root_git_project_for_trust_returns_none_outside_repo() {
|
|
let tmp = TempDir::new().expect("tempdir");
|
|
assert!(
|
|
resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &tmp.path().abs())
|
|
.await
|
|
.is_none()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_git_repo_root_with_fs_detects_gitdir_pointer() {
|
|
let tmp = TempDir::new().expect("tempdir");
|
|
let proj = tmp.path().join("proj");
|
|
let nested = proj.join("nested");
|
|
std::fs::create_dir_all(&nested).unwrap();
|
|
std::fs::write(proj.join(".git"), "gitdir: /tmp/fake-worktree\n").unwrap();
|
|
|
|
assert_eq!(
|
|
get_git_repo_root_with_fs(LOCAL_FS.as_ref(), &nested.abs()).await,
|
|
Some(proj.abs())
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_git_repo_root_with_fs_starts_at_parent_for_file() {
|
|
let tmp = TempDir::new().expect("tempdir");
|
|
let proj = tmp.path().join("proj");
|
|
let nested = proj.join("nested");
|
|
std::fs::create_dir_all(proj.join(".git")).unwrap();
|
|
std::fs::create_dir_all(&nested).unwrap();
|
|
let file = nested.join("file.txt");
|
|
std::fs::write(&file, "contents").unwrap();
|
|
|
|
assert_eq!(
|
|
get_git_repo_root_with_fs(LOCAL_FS.as_ref(), &file.abs()).await,
|
|
Some(proj.abs())
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_git_repo_root_with_fs_ignores_metadata_errors() {
|
|
let tmp = TempDir::new().expect("tempdir");
|
|
let proj = tmp.path().join("proj");
|
|
let nested = proj.join("nested");
|
|
std::fs::create_dir_all(proj.join(".git")).unwrap();
|
|
std::fs::create_dir_all(&nested).unwrap();
|
|
let fs = FailingMetadataFileSystem {
|
|
path: PathUri::from_abs_path(&nested.join(".git").abs()),
|
|
};
|
|
|
|
assert_eq!(
|
|
get_git_repo_root_with_fs(&fs, &nested.abs()).await,
|
|
Some(proj.abs())
|
|
);
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
#[tokio::test]
|
|
async fn get_git_repo_root_with_fs_supports_windows_namespace_paths() {
|
|
let tmp = TempDir::new().expect("tempdir");
|
|
let repo = tmp.path().join("repo");
|
|
std::fs::create_dir_all(repo.join(".git")).unwrap();
|
|
std::fs::create_dir_all(repo.join("nested")).unwrap();
|
|
|
|
let namespace_repo = PathBuf::from(format!(r"\\?\{}", repo.display()));
|
|
let namespace_nested = namespace_repo.join("nested");
|
|
|
|
assert_eq!(
|
|
get_git_repo_root_with_fs(LOCAL_FS.as_ref(), &namespace_nested.abs()).await,
|
|
Some(namespace_repo.abs())
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resolve_root_git_project_for_trust_regular_repo_returns_repo_root() {
|
|
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
|
let repo_path = create_test_git_repo(&temp_dir).await.abs();
|
|
|
|
assert_eq!(
|
|
resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &repo_path).await,
|
|
Some(repo_path.clone())
|
|
);
|
|
let nested = repo_path.join("sub/dir");
|
|
std::fs::create_dir_all(nested.as_path()).unwrap();
|
|
assert_eq!(
|
|
resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &nested).await,
|
|
Some(repo_path)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resolve_root_git_project_for_trust_detects_worktree_and_returns_main_root() {
|
|
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
|
let repo_path = create_test_git_repo(&temp_dir).await;
|
|
|
|
// Create a linked worktree
|
|
let wt_root = temp_dir.path().join("wt");
|
|
let _ = std::process::Command::new("git")
|
|
.args([
|
|
"worktree",
|
|
"add",
|
|
wt_root.to_str().unwrap(),
|
|
"-b",
|
|
"feature/x",
|
|
])
|
|
.current_dir(&repo_path)
|
|
.output()
|
|
.expect("git worktree add");
|
|
|
|
let expected = normalize_for_path_comparison(&repo_path).unwrap();
|
|
let wt_root = wt_root.abs();
|
|
let got = resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &wt_root).await;
|
|
assert_eq!(
|
|
got.as_ref()
|
|
.map(normalize_for_path_comparison)
|
|
.transpose()
|
|
.unwrap(),
|
|
Some(expected.clone())
|
|
);
|
|
let nested = wt_root.join("nested/sub");
|
|
std::fs::create_dir_all(nested.as_path()).unwrap();
|
|
let got_nested = resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &nested).await;
|
|
assert_eq!(
|
|
got_nested
|
|
.as_ref()
|
|
.map(normalize_for_path_comparison)
|
|
.transpose()
|
|
.unwrap(),
|
|
Some(expected)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resolve_root_git_project_for_trust_detects_worktree_pointer_without_git_command() {
|
|
let tmp = TempDir::new().expect("tempdir");
|
|
let repo_root = tmp.path().join("repo");
|
|
let common_dir = repo_root.join(".git");
|
|
let worktree_git_dir = common_dir.join("worktrees").join("feature-x");
|
|
let worktree_root = tmp.path().join("wt");
|
|
std::fs::create_dir_all(&worktree_git_dir).unwrap();
|
|
std::fs::create_dir_all(&worktree_root).unwrap();
|
|
std::fs::create_dir_all(worktree_root.join("nested")).unwrap();
|
|
std::fs::write(
|
|
worktree_root.join(".git"),
|
|
format!("gitdir: {}\n", worktree_git_dir.display()),
|
|
)
|
|
.unwrap();
|
|
|
|
let expected = repo_root.abs();
|
|
let worktree_root = worktree_root.abs();
|
|
assert_eq!(
|
|
resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &worktree_root).await,
|
|
Some(expected.clone())
|
|
);
|
|
let nested = worktree_root.join("nested");
|
|
assert_eq!(
|
|
resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &nested).await,
|
|
Some(expected)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resolve_root_git_project_for_trust_non_worktrees_gitdir_returns_none() {
|
|
let tmp = TempDir::new().expect("tempdir");
|
|
let proj = tmp.path().join("proj");
|
|
std::fs::create_dir_all(proj.join("nested")).unwrap();
|
|
|
|
// `.git` is a file but does not point to a worktrees path
|
|
std::fs::write(
|
|
proj.join(".git"),
|
|
format!(
|
|
"gitdir: {}\n",
|
|
tmp.path().join("some/other/location").display()
|
|
),
|
|
)
|
|
.unwrap();
|
|
|
|
let proj = proj.abs();
|
|
assert!(
|
|
resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &proj)
|
|
.await
|
|
.is_none()
|
|
);
|
|
let nested = proj.join("nested");
|
|
assert!(
|
|
resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &nested)
|
|
.await
|
|
.is_none()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_get_git_working_tree_state_unpushed_commit() {
|
|
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
|
let (repo_path, branch) = create_test_git_repo_with_remote(&temp_dir).await;
|
|
|
|
let remote_sha = Command::new("git")
|
|
.args(["rev-parse", &format!("origin/{branch}")])
|
|
.current_dir(&repo_path)
|
|
.output()
|
|
.await
|
|
.expect("Failed to rev-parse remote");
|
|
let remote_sha = String::from_utf8(remote_sha.stdout)
|
|
.unwrap()
|
|
.trim()
|
|
.to_string();
|
|
|
|
fs::write(repo_path.join("test.txt"), "updated").unwrap();
|
|
Command::new("git")
|
|
.args(["add", "test.txt"])
|
|
.current_dir(&repo_path)
|
|
.output()
|
|
.await
|
|
.expect("Failed to add file");
|
|
Command::new("git")
|
|
.args(["commit", "-m", "local change"])
|
|
.current_dir(&repo_path)
|
|
.output()
|
|
.await
|
|
.expect("Failed to commit");
|
|
|
|
let state = git_diff_to_remote(&repo_path)
|
|
.await
|
|
.expect("Should collect working tree state");
|
|
assert_eq!(state.sha, GitSha::new(&remote_sha));
|
|
assert!(state.diff.contains("updated"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_git_info_serialization() {
|
|
let git_info = GitInfo {
|
|
commit_hash: Some(GitSha::new("abc123def456")),
|
|
branch: Some("main".to_string()),
|
|
repository_url: Some("https://github.com/example/repo.git".to_string()),
|
|
};
|
|
|
|
let json = serde_json::to_string(&git_info).expect("Should serialize GitInfo");
|
|
let parsed: serde_json::Value = serde_json::from_str(&json).expect("Should parse JSON");
|
|
|
|
assert_eq!(parsed["commit_hash"], "abc123def456");
|
|
assert_eq!(parsed["branch"], "main");
|
|
assert_eq!(
|
|
parsed["repository_url"],
|
|
"https://github.com/example/repo.git"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_git_info_serialization_with_nones() {
|
|
let git_info = GitInfo {
|
|
commit_hash: None,
|
|
branch: None,
|
|
repository_url: None,
|
|
};
|
|
|
|
let json = serde_json::to_string(&git_info).expect("Should serialize GitInfo");
|
|
let parsed: serde_json::Value = serde_json::from_str(&json).expect("Should parse JSON");
|
|
|
|
// Fields with None values should be omitted due to skip_serializing_if
|
|
assert!(!parsed.as_object().unwrap().contains_key("commit_hash"));
|
|
assert!(!parsed.as_object().unwrap().contains_key("branch"));
|
|
assert!(!parsed.as_object().unwrap().contains_key("repository_url"));
|
|
}
|