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::GetMetadataOptions; use codex_exec_server::LOCAL_FS; use codex_exec_server::ReadDirectoryEntry; use codex_exec_server::ReadFileOptions; use codex_exec_server::RemoveOptions; use codex_exec_server::WalkOptions; use codex_exec_server::WalkOutcome; use codex_exec_server::WriteFileOptions; use codex_git_utils::GitInfo; use codex_git_utils::GitSha; use codex_git_utils::SanitizedGitUrl; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_git_utils::get_has_changes_in_repo; use codex_git_utils::git_diff_to_remote; use codex_git_utils::recent_commits; use codex_git_utils::resolve_root_git_project_uri_for_trust; // Existing trust fixtures check both entry points through this shared wrapper. use self::resolve_native_and_portable_trust_roots as resolve_root_git_project_for_trust; use codex_utils_absolute_path::AbsolutePathBuf; 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::collections::HashMap; use std::fs; use std::io; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; use std::path::Path; use std::path::PathBuf; use tempfile::TempDir; use tokio::process::Command; #[derive(Default)] struct MetadataOverrideFileSystem { path: Option, replacement: Option, canonical_overrides: Vec<(PathUri, PathUri)>, // Optional virtual filesystem; entries without contents are directories. entries: Option>>, } impl MetadataOverrideFileSystem { fn with_entries(entries: impl IntoIterator)>) -> Self { Self { entries: Some( entries .into_iter() .map(|(path, contents)| (path.to_string(), contents)) .collect(), ), ..Default::default() } } fn entry(&self, path: &PathUri) -> Option>> { self.entries.as_ref().map(|entries| { entries .get(path.to_url().as_str()) .ok_or_else(|| io::ErrorKind::NotFound.into()) }) } fn unsupported() -> FileSystemResult { Err(io::Error::new( io::ErrorKind::Unsupported, "operation is not used by Git root discovery", )) } } impl ExecutorFileSystem for MetadataOverrideFileSystem { fn canonicalize<'a>( &'a self, path: &'a PathUri, sandbox: Option<&'a FileSystemSandboxContext>, ) -> ExecutorFileSystemFuture<'a, PathUri> { Box::pin(async move { if let Some(entry) = self.entry(path) { return entry.map(|_| path.clone()); } if let Some((_, canonical)) = self .canonical_overrides .iter() .find(|(source, _)| source.to_url() == path.to_url()) { return Ok(canonical.clone()); } LOCAL_FS.canonicalize(path, sandbox).await }) } fn read_file<'a>( &'a self, path: &'a PathUri, options: ReadFileOptions, sandbox: Option<&'a FileSystemSandboxContext>, ) -> ExecutorFileSystemFuture<'a, Vec> { Box::pin(async move { if let Some(entry) = self.entry(path) { return entry? .as_ref() .map(|contents| contents.as_bytes().to_vec()) .ok_or_else(|| io::ErrorKind::IsADirectory.into()); } #[cfg(unix)] if Some(path) == self.path.as_ref() && let Some(replacement) = &self.replacement { let local_path = path.to_abs_path()?; fs::remove_file(local_path.as_path())?; std::os::unix::fs::symlink(replacement, local_path.as_path())?; } LOCAL_FS.read_file(path, options, sandbox).await }) } 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, _options: WriteFileOptions, _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, options: GetMetadataOptions, sandbox: Option<&'a FileSystemSandboxContext>, ) -> ExecutorFileSystemFuture<'a, FileMetadata> { Box::pin(async move { if let Some(entry) = self.entry(path) { let contents = entry?; return Ok(FileMetadata { is_directory: contents.is_none(), is_file: contents.is_some(), is_symlink: false, size: contents .as_ref() .map_or(/*default*/ 0, |contents| contents.len() as u64), created_at_ms: 0, modified_at_ms: 0, }); } if Some(path) == self.path.as_ref() && self.replacement.is_none() { Err(io::Error::new( io::ErrorKind::PermissionDenied, "injected metadata failure", )) } else { LOCAL_FS.get_metadata(path, options, sandbox).await } }) } fn read_directory<'a>( &'a self, _path: &'a PathUri, _sandbox: Option<&'a FileSystemSandboxContext>, ) -> ExecutorFileSystemFuture<'a, Vec> { Box::pin(async { Self::unsupported() }) } fn walk<'a>( &'a self, _path: &'a PathUri, _options: WalkOptions, _sandbox: Option<&'a FileSystemSandboxContext>, ) -> ExecutorFileSystemFuture<'a, WalkOutcome> { 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() }) } } async fn resolve_native_and_portable_trust_roots( fs: &dyn ExecutorFileSystem, cwd: &AbsolutePathBuf, ) -> Option { let native = codex_git_utils::resolve_root_git_project_for_trust(fs, cwd).await; let cwd_uri = PathUri::from_abs_path(cwd); let portable = resolve_root_git_project_uri_for_trust(fs, &cwd_uri).await; if cwd_uri.is_opaque() { // Opaque URI components cannot provide ancestry, but native lookup still can. assert_eq!(portable, None); } else { assert_eq!( portable.map(|path| path.to_url()), native .as_ref() .map(|path| PathUri::from_abs_path(path).to_url()), ); } native } // 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(SanitizedGitUrl::try_from(expected_remote.as_str()).unwrap()) ); } #[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_in_repo(temp_dir.path(), 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_in_repo(&repo_path, &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_in_repo(&repo_path, &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_in_repo(&repo_path, &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_in_repo(&repo_path, &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 resolve_root_git_project_for_trust_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::write(proj.join(".git/HEAD"), "ref: refs/heads/main\n").unwrap(); std::fs::create_dir_all(&nested).unwrap(); let file = nested.join("file.txt"); std::fs::write(&file, "contents").unwrap(); assert_eq!( resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &file.abs()).await, Some(proj.abs()) ); } #[tokio::test] async fn resolve_root_git_project_for_trust_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::write(proj.join(".git/HEAD"), "ref: refs/heads/main\n").unwrap(); std::fs::create_dir_all(&nested).unwrap(); let fs = MetadataOverrideFileSystem { path: Some(PathUri::from_abs_path(&nested.join(".git").abs())), ..Default::default() }; assert_eq!( resolve_root_git_project_for_trust(&fs, &nested.abs()).await, Some(proj.abs()) ); } #[cfg(windows)] #[tokio::test] async fn resolve_root_git_project_for_trust_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::write(repo.join(".git/HEAD"), "ref: refs/heads/main\n").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!( resolve_root_git_project_for_trust(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.join(".git")).unwrap(); assert_eq!( get_git_repo_root(nested.as_path()), Some(repo_path.as_path().to_path_buf()) ); assert_eq!( resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &nested).await, Some(repo_path) ); } fn write_linked_worktree_metadata(repo_root: &Path, worktree_root: &Path) -> PathBuf { let worktree_git_dir = repo_root.join(".git/worktrees/feature-x"); std::fs::create_dir_all(&worktree_git_dir).unwrap(); std::fs::create_dir_all(worktree_root).unwrap(); std::fs::write( worktree_root.join(".git"), format!("gitdir: {}\n", worktree_git_dir.display()), ) .unwrap(); std::fs::write( worktree_git_dir.join("gitdir"), format!("{}\n", worktree_root.join(".git").display()), ) .unwrap(); std::fs::write(worktree_git_dir.join("commondir"), "../..\n").unwrap(); worktree_git_dir } #[path = "worktree_trust_tests.rs"] mod worktree_trust_tests; #[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 worktree_root = tmp.path().join("wt"); std::fs::create_dir_all(worktree_root.join("nested")).unwrap(); write_linked_worktree_metadata(&repo_root, &worktree_root); 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_rejects_missing_worktree_metadata() { let tmp = TempDir::new().expect("tempdir"); let trusted_root = tmp.path().join("trusted"); let attacker_root = tmp.path().join("attacker"); std::fs::create_dir_all(trusted_root.join(".git")).unwrap(); std::fs::create_dir_all(&attacker_root).unwrap(); std::fs::write( attacker_root.join(".git"), format!( "gitdir: {}\n", trusted_root.join(".git/worktrees/missing").display() ), ) .unwrap(); assert_eq!( resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &attacker_root.abs()).await, None ); } #[tokio::test] async fn resolve_root_git_project_for_trust_rejects_mismatched_worktree_backlink() { let tmp = TempDir::new().expect("tempdir"); let repo_root = tmp.path().join("repo"); let worktree_root = tmp.path().join("worktree"); let other_root = tmp.path().join("other"); let worktree_git_dir = write_linked_worktree_metadata(&repo_root, &worktree_root); std::fs::create_dir_all(&other_root).unwrap(); std::fs::write( worktree_git_dir.join("gitdir"), format!("{}\n", other_root.join(".git").display()), ) .unwrap(); assert_eq!( resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &worktree_root.abs()).await, None ); } #[tokio::test] async fn resolve_root_git_project_for_trust_rejects_mismatched_common_dir() { let tmp = TempDir::new().expect("tempdir"); let repo_root = tmp.path().join("repo"); let worktree_root = tmp.path().join("worktree"); let other_common_dir = tmp.path().join("other/.git"); let worktree_git_dir = write_linked_worktree_metadata(&repo_root, &worktree_root); std::fs::create_dir_all(&other_common_dir).unwrap(); std::fs::write( worktree_git_dir.join("commondir"), format!("{}\n", other_common_dir.display()), ) .unwrap(); assert_eq!( resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &worktree_root.abs()).await, None ); } #[cfg(unix)] #[tokio::test] async fn resolve_root_git_project_for_trust_rejects_symlinked_dot_git_file() { let tmp = TempDir::new().expect("tempdir"); let repo_root = tmp.path().join("repo"); let worktree_root = tmp.path().join("worktree"); let attacker_root = tmp.path().join("attacker"); write_linked_worktree_metadata(&repo_root, &worktree_root); std::fs::create_dir_all(&attacker_root).unwrap(); std::os::unix::fs::symlink(worktree_root.join(".git"), attacker_root.join(".git")).unwrap(); assert_eq!( resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &attacker_root.abs()).await, None ); } #[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( SanitizedGitUrl::try_from("https://github.com/example/repo.git").unwrap(), ), }; 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")); }