From ad76cd061a3998fff99f327a38ad07e95fc96972 Mon Sep 17 00:00:00 2001 From: Rene Leonhardt <65483435+reneleonhardt@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:52:26 +0200 Subject: [PATCH] fix(list): Keep merge analysis sandbox-safe Run existing object-writing list probes in a temporary object directory so conflict and integration analysis remains available with a read-only object database. Keep generated objects and derived cache entries out of persistent repository state; when temporary storage is unavailable, skip only merge analysis. Co-Authored-By: GPT-5.6 Sol --- src/commands/list/collect/mod.rs | 22 ++- src/commands/list/collect/tasks.rs | 2 +- src/commands/list/collect/types.rs | 8 + src/git/repository/diff.rs | 2 +- src/git/repository/integration.rs | 84 ++++++++- src/git/repository/mod.rs | 109 +++++++++++- src/git/repository/tests.rs | 20 +++ src/git/repository/working_tree.rs | 21 ++- tests/integration_tests/list.rs | 263 +++++++++++++++++++++++++++++ 9 files changed, 516 insertions(+), 15 deletions(-) diff --git a/src/commands/list/collect/mod.rs b/src/commands/list/collect/mod.rs index d923744384..16775fbdfe 100644 --- a/src/commands/list/collect/mod.rs +++ b/src/commands/list/collect/mod.rs @@ -800,6 +800,15 @@ pub fn collect( show_config: ShowConfig, render_target: RenderTarget, ) -> anyhow::Result> { + let (temporary_object_repo, temporary_object_error) = + match repo.with_temporary_object_directory() { + Ok(repo) => (Some(repo), None), + Err(error) => { + log::debug!("Failed to prepare temporary object directory for list: {error:#}"); + (None, Some(error)) + } + }; + let repo = temporary_object_repo.as_ref().unwrap_or(repo); let show_progress = matches!(render_target, RenderTarget::Table { progressive: true }); let render_table = matches!(render_target, RenderTarget::Table { .. }); worktrunk::trace::instant("List collect started"); @@ -1295,7 +1304,7 @@ pub fn collect( }; let prune_to_selection = render_table && progressive_handler.is_none() && !selected_columns.is_empty(); - let tasks = if prune_to_selection { + let mut tasks = if prune_to_selection { listed_plan() } else { // Picker and JSON: the full set plus the selection's forced-on @@ -1305,6 +1314,17 @@ pub fn collect( tasks }; + if temporary_object_error.is_some() { + let previous_len = tasks.len(); + tasks.retain(|task| !task.requires_object_store()); + if tasks.len() != previous_len && progressive_handler.is_none() { + eprintln!( + "{}", + warning_message("Temporary object storage unavailable; merge analysis was skipped") + ); + } + } + // The picker primes its CI cells from the local cache so the column paints // instantly, then the live `CiStatus` task (which the picker keeps — see // `handle_picker`) overwrites each cell as results stream in. Uncached rows diff --git a/src/commands/list/collect/tasks.rs b/src/commands/list/collect/tasks.rs index 31e452d1e5..a775a7e0dd 100644 --- a/src/commands/list/collect/tasks.rs +++ b/src/commands/list/collect/tasks.rs @@ -753,7 +753,7 @@ impl Task for WorkingTreeConflictsTask { .map_err(|e| ctx.error(Self::KIND, &e))?; String::from_utf8_lossy(&output.stdout).trim().to_string() } else { - wt.run_command(&["write-tree"]) + wt.run_object_store_command(&["write-tree"]) .map(|s| s.trim().to_string()) .map_err(|e| ctx.error(Self::KIND, &e))? }; diff --git a/src/commands/list/collect/types.rs b/src/commands/list/collect/types.rs index ecf4fc0536..89e081dfa9 100644 --- a/src/commands/list/collect/types.rs +++ b/src/commands/list/collect/types.rs @@ -144,6 +144,14 @@ impl TaskResult { } impl TaskKind { + /// Whether this task runs Git plumbing that may create temporary objects. + pub fn requires_object_store(self) -> bool { + matches!( + self, + TaskKind::WouldMergeAdd | TaskKind::MergeTreeConflicts | TaskKind::WorkingTreeConflicts + ) + } + /// Whether this task requires network access. /// /// Network tasks are sorted to run last to avoid blocking local tasks. diff --git a/src/git/repository/diff.rs b/src/git/repository/diff.rs index c170afb08c..d1d8346562 100644 --- a/src/git/repository/diff.rs +++ b/src/git/repository/diff.rs @@ -271,7 +271,7 @@ impl Repository { // Exit codes: 0 = found, 1 = no common ancestor, 128+ = invalid ref let args = ["merge-base", sha1, sha2]; - let output = self.run_command_output(&args)?; + let output = self.run_object_store_command_output(&args)?; let result = if output.status.success() { Some(String::from_utf8_lossy(&output.stdout).trim().to_owned()) diff --git a/src/git/repository/integration.rs b/src/git/repository/integration.rs index 48d0a04a42..1c22beab2b 100644 --- a/src/git/repository/integration.rs +++ b/src/git/repository/integration.rs @@ -250,8 +250,14 @@ impl Repository { // Cache miss — create an ephemeral commit so merge-tree can resolve // the merge-base. The commit is unreferenced and will be GC'd. - let head_commit = - self.run_command(&["commit-tree", tree_sha, "-p", branch_head_sha, "-m", ""])?; + let head_commit = self.run_object_store_command(&[ + "commit-tree", + tree_sha, + "-p", + branch_head_sha, + "-m", + "", + ])?; let head_commit = head_commit.trim(); self.run_merge_tree(base_sha, head_commit, base_sha, &cache_head) @@ -277,7 +283,11 @@ impl Repository { // `(target, branch)`, so they already share a key, and the cached // outcome (a conflict flag, or the order-independent clean-merge tree) // doesn't depend on argument order — a symmetric key would buy nothing. - match self.cache.merge_tree.entry((a.to_string(), b.to_string())) { + match self.cache.merge_tree.entry(( + a.to_string(), + b.to_string(), + self.object_store_cache_id(), + )) { Entry::Occupied(e) => Ok(e.get().clone()), Entry::Vacant(e) => { let outcome = self.compute_merge_tree_outcome(a, b)?; @@ -295,7 +305,7 @@ impl Repository { // unresolvable args — callers pass pre-resolved commit SHAs, see // `run_merge_tree`), anything else = error (corrupt repo, bad usage) let args = ["merge-tree", "--write-tree", a, b]; - let output = self.run_command_output(&args)?; + let output = self.run_object_store_command_output(&args)?; if output.status.code() == Some(1) { return Ok(MergeTreeOutcome::Conflict); @@ -1166,7 +1176,7 @@ mod merge_tree_cache_tests { /// integration column) both run `git merge-tree --write-tree /// ` for the same row but extract different answers — the conflict /// bit versus the resulting tree. They must key the in-memory cache - /// identically, in `(target, branch)` order, so the subprocess fires once + /// identically, in `(target, branch, object-store)` order, so the subprocess fires once /// per row rather than once per probe. An argument-order regression on /// either call site would split this into two entries (two spawns); this /// pins it at one. @@ -1201,15 +1211,73 @@ mod merge_tree_cache_tests { assert!(!probe.is_patch_id_match); // Both probes resolved through a single shared merge-tree entry, keyed - // (target, branch) = (main, feature). + // (target, branch, persistent) = (main, feature, 0). assert_eq!( repo.cache.merge_tree.len(), 1, "conflict + integration probes must share one merge-tree cache entry" ); assert!( - repo.cache.merge_tree.contains_key(&(main_sha, feature_sha)), - "the shared entry must be keyed (target, branch)" + repo.cache + .merge_tree + .contains_key(&(main_sha, feature_sha, 0)), + "the shared entry must be keyed (target, branch, object-store)" + ); + } + + #[test] + fn temporary_object_stores_and_persistent_store_do_not_share_merge_tree_entries() { + let test = TestRepo::with_initial_commit(); + + test.run_git(&["checkout", "-b", "feature"]); + std::fs::write(test.root_path().join("feature.txt"), "feature\n").unwrap(); + test.run_git(&["add", "feature.txt"]); + test.run_git(&["commit", "-m", "feature"]); + test.run_git(&["checkout", "main"]); + std::fs::write(test.root_path().join("main.txt"), "main\n").unwrap(); + test.run_git(&["add", "main.txt"]); + test.run_git(&["commit", "-m", "main"]); + + let main_sha = test.git_output(&["rev-parse", "main"]); + let feature_sha = test.git_output(&["rev-parse", "feature"]); + let repo = Repository::at(test.root_path()).unwrap(); + let temporary = repo.with_temporary_object_directory().unwrap(); + let clean_tree = |outcome| match outcome { + MergeTreeOutcome::Clean { tree } => tree, + MergeTreeOutcome::Conflict => panic!("test topology should merge cleanly"), + }; + + let temporary_tree = clean_tree( + temporary + .merge_tree_outcome(&main_sha, &feature_sha) + .unwrap(), + ); + assert!( + !repo + .run_command_check(&["cat-file", "-e", &temporary_tree]) + .unwrap(), + "temporary merge tree must not be written to the real object database" + ); + + let second_temporary = repo.with_temporary_object_directory().unwrap(); + let second_temporary_tree = clean_tree( + second_temporary + .merge_tree_outcome(&main_sha, &feature_sha) + .unwrap(), + ); + assert_eq!(second_temporary_tree, temporary_tree); + + let persistent_tree = clean_tree(repo.merge_tree_outcome(&main_sha, &feature_sha).unwrap()); + assert_eq!(persistent_tree, temporary_tree); + assert!( + repo.run_command_check(&["cat-file", "-e", &persistent_tree]) + .unwrap(), + "persistent merge must materialize its tree in the real object database" + ); + assert_eq!( + repo.cache.merge_tree.len(), + 3, + "each temporary store and the persistent store need separate cache entries" ); } } diff --git a/src/git/repository/mod.rs b/src/git/repository/mod.rs index 49a9650dcb..5c9947cee3 100644 --- a/src/git/repository/mod.rs +++ b/src/git/repository/mod.rs @@ -108,8 +108,10 @@ //! The picker also maintains a `PreviewCache` (`Arc` in `commands/picker/items.rs`) //! for rendered preview output, scoped to a single picker session. +use std::ffi::{OsStr, OsString}; use std::path::{Path, PathBuf}; use std::process::Stdio; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, LazyLock, OnceLock}; use crate::shell_exec::Cmd; @@ -260,7 +262,7 @@ pub(super) struct RepoCache { /// branch row peels the same default-branch tip to its tree. Resolved via /// [`Repository::commit_to_tree_sha`]. pub(super) commit_tree: DashMap, - /// In-memory merge-tree outcome cache: (a_sha, b_sha) -> outcome. + /// In-memory merge-tree outcome cache: (a_sha, b_sha, object-store-id) -> outcome. /// `git merge-tree --write-tree` is the costliest op in `wt list`, and the /// conflict probe (`has_merge_conflicts_by_sha`) and the integration probe /// (`merge_integration_probe_by_sha` via `would_merge_add_to_target`) run @@ -270,7 +272,7 @@ pub(super) struct RepoCache { /// subprocess twice; this front collapses both to one via the entry-lock /// pattern (same as [`Self::merge_base`]). Keys are commit SHAs in call /// order — see `Repository::merge_tree_outcome`. - pub(super) merge_tree: DashMap<(String, String), integration::MergeTreeOutcome>, + pub(super) merge_tree: DashMap<(String, String, u64), integration::MergeTreeOutcome>, /// Effective remote URLs: remote_name -> effective URL (with `url.insteadOf` applied). /// Separate from `all_config` because `git remote get-url` applies /// `url.insteadOf` rewrites that aren't visible in raw config. @@ -489,8 +491,19 @@ pub struct Repository { git_common_dir: PathBuf, /// Cached data for this repository. Shared across clones via Arc. pub(super) cache: Arc, + /// Temporary object database used by observational Git plumbing. + temporary_object_directory: Option>, } +#[derive(Debug)] +struct TemporaryObjectDirectory { + cache_id: u64, + directory: tempfile::TempDir, + alternates: OsString, +} + +static NEXT_TEMPORARY_OBJECT_STORE_ID: AtomicU64 = AtomicU64::new(1); + impl Repository { /// Discover the repository from the current directory. /// @@ -545,9 +558,101 @@ impl Repository { discovery_path, git_common_dir, cache: Arc::new(cache), + temporary_object_directory: None, }) } + /// Clone this repository with object-writing plumbing redirected to a + /// temporary object database that reads the real database as an alternate. + /// + /// This mode is for observational commands such as `wt list`. Commands + /// that retain generated object IDs must use the normal persistent mode. + pub fn with_temporary_object_directory(&self) -> anyhow::Result { + let original = if std::env::var_os("GIT_OBJECT_DIRECTORY").is_some() { + let path = self.run_command(&["rev-parse", "--git-path", "objects"])?; + let path = PathBuf::from(path.trim()); + self.discovery_path.join(path) + } else { + self.git_common_dir.join("objects") + }; + let original = canonicalize(&original).unwrap_or(original); + + let mut alternates = vec![original]; + if let Some(inherited) = std::env::var_os("GIT_ALTERNATE_OBJECT_DIRECTORIES") { + alternates.extend(std::env::split_paths(&inherited)); + } + let alternates = std::env::join_paths(alternates) + .context("Failed to encode Git alternate object directories")?; + let directory = tempfile::Builder::new() + .prefix("worktrunk-list-objects-") + .tempdir() + .context("Failed to create temporary Git object directory")?; + + let mut clone = self.clone(); + clone.temporary_object_directory = Some(Arc::new(TemporaryObjectDirectory { + cache_id: NEXT_TEMPORARY_OBJECT_STORE_ID.fetch_add(1, Ordering::Relaxed), + directory, + alternates, + })); + Ok(clone) + } + + pub(super) fn object_store_environment(&self) -> Option<(&Path, &OsStr)> { + self.temporary_object_directory + .as_ref() + .map(|temporary| (temporary.directory.path(), temporary.alternates.as_os_str())) + } + + pub(super) fn object_store_cache_id(&self) -> u64 { + self.temporary_object_directory + .as_ref() + .map_or(0, |temporary| temporary.cache_id) + } + + fn object_store_command_at(&self, path: &Path, args: &[&str]) -> Cmd { + let command = Cmd::new("git") + .args(args.iter().copied()) + .current_dir(path) + .context(path_to_logging_context(path)); + if let Some((directory, alternates)) = self.object_store_environment() { + command + .env("GIT_OBJECT_DIRECTORY", directory) + .env("GIT_ALTERNATE_OBJECT_DIRECTORIES", alternates) + } else { + command + } + } + + pub(super) fn run_object_store_command(&self, args: &[&str]) -> anyhow::Result { + self.run_object_store_command_at(&self.discovery_path, args) + } + + pub(super) fn run_object_store_command_at( + &self, + path: &Path, + args: &[&str], + ) -> anyhow::Result { + let output = self + .object_store_command_at(path, args) + .run() + .with_context(|| format!("Failed to execute: git {}", args.join(" ")))?; + if !output.status.success() { + return Err( + super::error::CommandError::from_failed_output("git", args, &output).into(), + ); + } + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) + } + + pub(super) fn run_object_store_command_output( + &self, + args: &[&str], + ) -> anyhow::Result { + self.object_store_command_at(&self.discovery_path, args) + .run() + .with_context(|| format!("Failed to execute: git {}", args.join(" "))) + } + /// Eagerly populate the process-wide git-discovery caches /// (`GIT_COMMON_DIR_CACHE`, `WORKTREE_ROOTS`, `GIT_DIRS`, /// `CURRENT_BRANCHES`), the git bulk-config preload diff --git a/src/git/repository/tests.rs b/src/git/repository/tests.rs index 0fc3ec6772..a62100d6ef 100644 --- a/src/git/repository/tests.rs +++ b/src/git/repository/tests.rs @@ -389,6 +389,7 @@ fn repo_path_error_when_is_bare_fails() { discovery_path: PathBuf::from("/nonexistent/repo"), git_common_dir: PathBuf::from("/nonexistent/.git"), cache: Arc::new(RepoCache::default()), + temporary_object_directory: None, }; let err = repo.repo_path().unwrap_err(); @@ -431,6 +432,7 @@ fn repo_path_ignores_non_local_core_worktree() { discovery_path: tmp.path().to_path_buf(), git_common_dir: git_dir.clone(), cache: Arc::new(cache), + temporary_object_directory: None, }; // Should fall through to parent(git_common_dir), ignoring the bulk value. @@ -633,6 +635,23 @@ fn extract_failed_command_from_command_error() { assert_eq!(cmd.exit_info, "exit code 128"); } +#[test] +fn object_store_command_preserves_failed_git_command() { + use crate::git::CommandError; + use crate::testing::TestRepo; + + let test = TestRepo::with_initial_commit(); + let args = ["rev-parse", "--verify", "refs/heads/missing"]; + let err = test.repo.run_object_store_command(&args).unwrap_err(); + let command = err + .downcast_ref::() + .expect("a failed git command should retain its structured error"); + + assert_eq!(command.program, "git"); + assert_eq!(command.args, args); + assert!(command.exit_code.is_some()); +} + #[test] fn is_builtin_fsmonitor_enabled_variants() { use super::RepoCache; @@ -650,6 +669,7 @@ fn is_builtin_fsmonitor_enabled_variants() { discovery_path: PathBuf::from("/nonexistent/repo"), git_common_dir: PathBuf::from("/nonexistent/.git"), cache: Arc::new(cache), + temporary_object_directory: None, } } diff --git a/src/git/repository/working_tree.rs b/src/git/repository/working_tree.rs index 627b09fbdd..0505edf496 100644 --- a/src/git/repository/working_tree.rs +++ b/src/git/repository/working_tree.rs @@ -143,6 +143,12 @@ impl<'a> WorkingTree<'a> { .with_context(|| format!("Failed to execute: git {}", args.join(" "))) } + /// Run Git plumbing that may create objects, honoring the repository's + /// opt-in temporary object database. + pub fn run_object_store_command(&self, args: &[&str]) -> anyhow::Result { + self.repo.run_object_store_command_at(&self.path, args) + } + // ========================================================================= // Worktree-specific methods // ========================================================================= @@ -567,6 +573,9 @@ impl<'a> WorkingTree<'a> { temp, worktree_root, log_ctx, + object_store_environment: self.repo.object_store_environment().map( + |(directory, alternates)| (directory.to_path_buf(), alternates.to_os_string()), + ), }) } @@ -675,6 +684,7 @@ pub struct TempIndex { temp: tempfile::TempPath, worktree_root: PathBuf, log_ctx: String, + object_store_environment: Option<(PathBuf, std::ffi::OsString)>, } impl TempIndex { @@ -693,11 +703,18 @@ impl TempIndex { I: IntoIterator, S: Into, { - Cmd::new("git") + let command = Cmd::new("git") .args(args) .current_dir(&self.worktree_root) .context(self.log_ctx.clone()) - .env("GIT_INDEX_FILE", self.path()) + .env("GIT_INDEX_FILE", self.path()); + if let Some((directory, alternates)) = &self.object_store_environment { + command + .env("GIT_OBJECT_DIRECTORY", directory) + .env("GIT_ALTERNATE_OBJECT_DIRECTORIES", alternates) + } else { + command + } } } diff --git a/tests/integration_tests/list.rs b/tests/integration_tests/list.rs index edb99fb2c6..9cb9584a90 100644 --- a/tests/integration_tests/list.rs +++ b/tests/integration_tests/list.rs @@ -5,6 +5,82 @@ use crate::common::{ use insta_cmd::assert_cmd_snapshot; use rstest::rstest; +#[cfg(unix)] +struct ReadOnlyObjectDirectory { + directories: Vec<(std::path::PathBuf, u32)>, +} + +#[cfg(unix)] +impl ReadOnlyObjectDirectory { + fn new(repo: &TestRepo) -> Self { + use std::os::unix::fs::PermissionsExt; + + fn collect_directories(path: &std::path::Path, output: &mut Vec) { + output.push(path.to_path_buf()); + for entry in std::fs::read_dir(path).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + collect_directories(&path, output); + } + } + } + + let mut paths = Vec::new(); + collect_directories(&repo.root_path().join(".git/objects"), &mut paths); + let directories = paths + .into_iter() + .map(|path| { + let metadata = std::fs::metadata(&path).unwrap(); + let original_mode = metadata.permissions().mode(); + let mut permissions = metadata.permissions(); + permissions.set_mode(original_mode & !0o222); + std::fs::set_permissions(&path, permissions).unwrap(); + (path, original_mode) + }) + .collect(); + Self { directories } + } +} + +#[cfg(unix)] +impl Drop for ReadOnlyObjectDirectory { + fn drop(&mut self) { + use std::os::unix::fs::PermissionsExt; + + for (path, original_mode) in self.directories.iter().rev() { + let mut permissions = std::fs::metadata(path).unwrap().permissions(); + permissions.set_mode(*original_mode); + std::fs::set_permissions(path, permissions).unwrap(); + } + } +} + +#[cfg(unix)] +fn list_feature_merge_conflicts(repo: &TestRepo, branch: &str) -> (bool, String) { + repo.write_test_config("[list]\njson-schema = 2\n"); + let output = repo + .wt_command() + .args(["list", "--full", "--format=json"]) + .output() + .unwrap(); + assert!(output.status.success(), "wt list should succeed"); + + let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + let item = json["items"] + .as_array() + .unwrap() + .iter() + .find(|item| item["branch"] == branch) + .unwrap_or_else(|| panic!("{branch} should appear in wt list output")); + let conflicts = item["default_branch"]["merge_conflicts"] + .as_bool() + .unwrap_or_else(|| panic!("merge conflict result should be resolved: {item}")); + ( + conflicts, + String::from_utf8_lossy(&output.stderr).into_owned(), + ) +} + /// Creates worktrees with specific timestamps for ordering tests. /// Returns the path to feature-current (the worktree to run tests from). /// @@ -3306,6 +3382,193 @@ fn test_list_full_clean_working_tree_uses_commit_conflicts(mut repo: TestRepo) { }); } +#[cfg(unix)] +#[rstest] +fn test_list_committed_conflict_with_read_only_object_database(mut repo: TestRepo) { + std::fs::write(repo.root_path().join("shared.txt"), "original").unwrap(); + repo.commit("Initial commit"); + + let feature = repo.add_worktree("feature"); + std::fs::write(feature.join("shared.txt"), "feature").unwrap(); + repo.run_git_in(&feature, &["add", "shared.txt"]); + repo.run_git_in(&feature, &["commit", "-m", "Feature changes shared.txt"]); + + std::fs::write(repo.root_path().join("shared.txt"), "main").unwrap(); + repo.commit("Main changes shared.txt"); + + let _read_only = ReadOnlyObjectDirectory::new(&repo); + let (conflicts, stderr) = list_feature_merge_conflicts(&repo, "feature"); + + assert!(conflicts, "committed conflict should remain available"); + assert!( + !stderr.contains("merge-conflict check") + && !stderr.contains("unable to create temporary file"), + "object writes should not fail in the real object database: {stderr}" + ); +} + +#[cfg(unix)] +#[rstest] +fn test_list_dirty_conflict_with_read_only_object_database(mut repo: TestRepo) { + std::fs::write(repo.root_path().join("shared.txt"), "original").unwrap(); + repo.commit("Initial commit"); + + let feature = repo.add_worktree("feature"); + std::fs::write(repo.root_path().join("shared.txt"), "main").unwrap(); + repo.commit("Main changes shared.txt"); + std::fs::write(feature.join("shared.txt"), "dirty feature").unwrap(); + repo.run_git_in(&feature, &["add", "shared.txt"]); + + let _read_only = ReadOnlyObjectDirectory::new(&repo); + let (conflicts, stderr) = list_feature_merge_conflicts(&repo, "feature"); + + assert!( + conflicts, + "dirty worktree conflict should remain available: {stderr}" + ); + assert!( + !stderr.contains("working-tree conflict check") + && !stderr.contains("unable to create temporary file"), + "object writes should not fail in the real object database: {stderr}" + ); +} + +#[rstest] +fn test_list_temporary_object_store_preserves_inherited_object_locations(mut repo: TestRepo) { + std::fs::write(repo.root_path().join("shared.txt"), "original").unwrap(); + repo.commit("Initial commit"); + + let feature = repo.add_worktree("feature"); + std::fs::write(feature.join("shared.txt"), "feature").unwrap(); + repo.run_git_in(&feature, &["add", "shared.txt"]); + repo.run_git_in(&feature, &["commit", "-m", "Feature changes shared.txt"]); + + std::fs::write(repo.root_path().join("shared.txt"), "main").unwrap(); + repo.commit("Main changes shared.txt"); + repo.write_test_config("[list]\njson-schema = 2\n"); + + let empty_objects = "override-objects"; + std::fs::create_dir(repo.root_path().join(empty_objects)).unwrap(); + let inherited_objects = std::env::join_paths([repo.root_path().join(".git/objects")]).unwrap(); + let output = repo + .wt_command() + .env("GIT_OBJECT_DIRECTORY", empty_objects) + .env("GIT_ALTERNATE_OBJECT_DIRECTORIES", inherited_objects) + .args(["list", "--full", "--format=json"]) + .output() + .unwrap(); + assert!(output.status.success(), "wt list should succeed"); + + let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + let feature = json["items"] + .as_array() + .unwrap() + .iter() + .find(|item| item["branch"] == "feature") + .expect("feature should appear in wt list output"); + assert_eq!(feature["default_branch"]["merge_conflicts"], true); +} + +#[cfg(unix)] +#[rstest] +fn test_list_temporary_object_store_preserves_persistent_cache_writes(mut repo: TestRepo) { + std::fs::write(repo.root_path().join("shared.txt"), "original").unwrap(); + repo.commit("Initial commit"); + + let feature = repo.add_worktree("feature"); + std::fs::write(feature.join("shared.txt"), "feature").unwrap(); + repo.run_git_in(&feature, &["add", "shared.txt"]); + repo.run_git_in(&feature, &["commit", "-m", "Feature changes shared.txt"]); + + std::fs::write(repo.root_path().join("shared.txt"), "main").unwrap(); + repo.commit("Main changes shared.txt"); + + let cache = repo.root_path().join(".git/wt/cache"); + if cache.exists() { + std::fs::remove_dir_all(&cache).unwrap(); + } + + let (_conflicts, stderr) = list_feature_merge_conflicts(&repo, "feature"); + + fn paths_under(root: &std::path::Path) -> Vec { + let mut paths = Vec::new(); + let Ok(entries) = std::fs::read_dir(root) else { + return paths; + }; + for entry in entries { + let path = entry.unwrap().path(); + paths.push(path.clone()); + if path.is_dir() { + paths.extend(paths_under(&path)); + } + } + paths + } + let cache_paths = paths_under(&cache); + let merge_conflicts_cache = cache.join("merge-tree-conflicts"); + + assert!( + cache_paths.iter().any(|path| { + path.parent() == Some(merge_conflicts_cache.as_path()) + && path + .extension() + .is_some_and(|extension| extension == "json") + }), + "temporary object storage must preserve persistent merge-conflict caching: {cache_paths:#?}\n{stderr}" + ); +} + +#[cfg(unix)] +#[rstest] +fn test_list_skips_object_probes_when_temporary_store_is_unavailable(mut repo: TestRepo) { + std::fs::write(repo.root_path().join("shared.txt"), "original").unwrap(); + repo.commit("Initial commit"); + + let feature = repo.add_worktree("feature"); + std::fs::write(feature.join("shared.txt"), "feature").unwrap(); + repo.run_git_in(&feature, &["add", "shared.txt"]); + repo.run_git_in(&feature, &["commit", "-m", "Feature changes shared.txt"]); + + std::fs::write(repo.root_path().join("shared.txt"), "main").unwrap(); + repo.commit("Main changes shared.txt"); + repo.write_test_config("[list]\njson-schema = 2\n"); + + let invalid_tmp = repo.root_path().join("not-a-directory"); + std::fs::write(&invalid_tmp, "file").unwrap(); + let output = repo + .wt_command() + .env("TMPDIR", &invalid_tmp) + .args(["list", "--full", "--format=json"]) + .output() + .unwrap(); + + assert!( + output.status.success(), + "independent list fields should render" + ); + let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + let feature = json["items"] + .as_array() + .unwrap() + .iter() + .find(|item| item["branch"] == "feature") + .expect("feature should appear"); + assert_eq!( + feature["default_branch"]["merge_conflicts"], + serde_json::Value::Null + ); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert_eq!( + stderr + .lines() + .filter(|line| line.contains("Temporary object storage unavailable")) + .count(), + 1, + "one warning should explain the degraded fields: {stderr}" + ); +} + #[rstest] fn test_list_warns_when_default_branch_missing_worktree(repo: TestRepo) { // Move primary worktree off the default branch so no worktree holds it