Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion src/commands/list/collect/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -800,6 +800,15 @@ pub fn collect(
show_config: ShowConfig,
render_target: RenderTarget,
) -> anyhow::Result<Option<super::model::ListData>> {
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");
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/commands/list/collect/tasks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))?
};
Expand Down
8 changes: 8 additions & 0 deletions src/commands/list/collect/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion src/git/repository/diff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
84 changes: 76 additions & 8 deletions src/git/repository/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)?;
Expand All @@ -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);
Expand Down Expand Up @@ -1166,7 +1176,7 @@ mod merge_tree_cache_tests {
/// integration column) both run `git merge-tree --write-tree <target>
/// <branch>` 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.
Expand Down Expand Up @@ -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"
);
}
}
109 changes: 107 additions & 2 deletions src/git/repository/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,10 @@
//! The picker also maintains a `PreviewCache` (`Arc<DashMap>` 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;
Expand Down Expand Up @@ -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<String, String>,
/// 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
Expand All @@ -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.
Expand Down Expand Up @@ -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<RepoCache>,
/// Temporary object database used by observational Git plumbing.
temporary_object_directory: Option<Arc<TemporaryObjectDirectory>>,
}

#[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.
///
Expand Down Expand Up @@ -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<Self> {
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<String> {
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<String> {
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<std::process::Output> {
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
Expand Down
20 changes: 20 additions & 0 deletions src/git/repository/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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::<CommandError>()
.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;
Expand All @@ -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,
}
}

Expand Down
Loading
Loading