Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
9 changes: 9 additions & 0 deletions 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>> {
// `wt list`'s merge and conflict probes write ephemeral Git objects
// (`write-tree`, `commit-tree`, `merge-tree --write-tree`). In a read-only
// checkout those writes fail; redirect them into a temporary object
// database (real database as a read-only alternate) so the analysis still
// runs — see `Repository::redirect_objects_if_read_only`. A `None` (writable
// store, or no writable temp dir) leaves object-writing tasks on the real
// database, where they surface their own errors.
let redirected = repo.redirect_objects_if_read_only();
let repo = redirected.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
84 changes: 84 additions & 0 deletions src/git/repository/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1213,3 +1213,87 @@ mod merge_tree_cache_tests {
);
}
}

#[cfg(test)]
mod read_only_object_store_tests {
use super::*;
use crate::testing::TestRepo;

/// A cleanly-mergeable but diverged topology: `main` and `feature` touch
/// different files off a shared base, so `merge-tree --write-tree` must
/// write a genuinely new tree (not a fast-forward that reuses an existing
/// one). Returns `(main_sha, feature_sha)`.
fn diverged_repo() -> (TestRepo, String, String) {
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"]);
(test, main_sha, feature_sha)
}

/// A writable object database must never redirect — the normal path is
/// left byte-for-byte unchanged.
#[test]
fn writable_object_database_is_not_redirected() {
let test = TestRepo::with_initial_commit();
let repo = Repository::at(test.root_path()).unwrap();
assert!(
repo.redirect_objects_if_read_only().is_none(),
"a writable object database must not trigger a redirect"
);
}

/// The safety property behind scoping the redirect to observational
/// commands: a redirected merge tree is computed correctly but written only
/// to the throwaway store, so it is *not* in the real object database. A
/// mutating command that redirected would therefore lose its commit — which
/// is why mutating commands keep the persistent path and fail loudly on a
/// read-only store instead.
#[test]
fn redirected_object_writes_stay_out_of_the_real_database() {
let (test, main_sha, feature_sha) = diverged_repo();
let merge_tree = |repo: &Repository| {
repo.run_command(&["merge-tree", "--write-tree", &main_sha, &feature_sha])
.unwrap()
.lines()
.next()
.unwrap()
.trim()
.to_string()
};

// Redirected clone: the merge tree lands in the temporary store only.
let redirected = Repository::at(test.root_path())
.unwrap()
.with_temporary_object_directory()
.unwrap();
let ephemeral_tree = merge_tree(&redirected);

// A separate, non-redirected repository reads the real database
// directly; the redirected tree is absent there.
let real = Repository::at(test.root_path()).unwrap();
assert!(
!real
.run_command_check(&["cat-file", "-e", &ephemeral_tree])
.unwrap(),
"a redirected merge tree must not be written to the real object database"
);

// The persistent path yields the same content-addressed tree, this time
// materialized in the real database.
let persistent_tree = merge_tree(&real);
assert_eq!(persistent_tree, ephemeral_tree);
assert!(
real.run_command_check(&["cat-file", "-e", &persistent_tree])
.unwrap(),
"the persistent path must materialize its merge tree in the real database"
);
}
}
136 changes: 126 additions & 10 deletions src/git/repository/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@
//! 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::{Arc, LazyLock, OnceLock};
Expand Down Expand Up @@ -489,6 +490,24 @@ pub struct Repository {
git_common_dir: PathBuf,
/// Cached data for this repository. Shared across clones via Arc.
pub(super) cache: Arc<RepoCache>,
/// When set, object-writing git plumbing is redirected into a temporary
/// object database so observational commands run in a read-only checkout.
/// `None` for the normal (persistent) path. See
/// [`Repository::redirect_objects_if_read_only`].
temporary_object_directory: Option<Arc<TemporaryObjectDirectory>>,
}

/// A throwaway object database that a redirected [`Repository`] writes new
/// objects into, with the real database (plus any inherited alternates) as
/// read-only alternates so existing objects still resolve.
///
/// Held behind an `Arc` so cloning a `Repository` — as `wt list` does for its
/// parallel tasks — shares one store and one `TempDir` lifetime; the directory
/// is removed when the last clone drops.
#[derive(Debug)]
struct TemporaryObjectDirectory {
directory: tempfile::TempDir,
alternates: OsString,
}

impl Repository {
Expand Down Expand Up @@ -545,9 +564,101 @@ impl Repository {
discovery_path,
git_common_dir,
cache: Arc::new(cache),
temporary_object_directory: None,
})
}

/// If this repository's object database is read-only, return a clone whose
/// object-writing git plumbing is redirected into a temporary object
/// database (with the real database as a read-only alternate); otherwise
/// return `Ok(None)` and let the caller keep using `self` unchanged.
///
/// Only *observational* commands may redirect. `wt list`'s merge and
/// conflict probes create ephemeral objects (`write-tree`, `commit-tree`,
/// `merge-tree --write-tree`) that are never referenced, so writing them to
/// a throwaway store is harmless and lets the command run in a read-only
/// checkout. A *mutating* command must never redirect — its commit would be
/// written to the throwaway store and lost at process exit. Because a
/// read-only object database also blocks the ref/index/worktree writes
/// those commands need, keeping them on the persistent database makes them
/// fail loudly rather than silently succeed into a store that vanishes.
pub fn redirect_objects_if_read_only(&self) -> Option<Self> {
let objects = self.object_database_path();

// Probe effective writability by creating a file in the object
// database — the same thing git's object writers do, so this fails
// exactly when they would (a read-only mount and a `chmod`ed directory
// both surface here, unlike the owner-write permission bit). A
// successful probe file is dropped immediately.
if tempfile::Builder::new()
.prefix(".worktrunk-write-probe-")
.tempfile_in(&objects)
.is_ok()
{
return None;
}

self.with_temporary_object_directory()
}

/// Build a clone whose object writes are redirected into a fresh temporary
/// object database, with the real database as a read-only alternate so
/// existing objects still resolve. Returns `None` when the temporary store
/// can't be created (no writable temp dir), leaving the caller on the real
/// database. This is the *mechanism*; the *policy* — whether to redirect at
/// all — lives in [`Self::redirect_objects_if_read_only`], the only
/// production caller.
fn with_temporary_object_directory(&self) -> Option<Self> {
let alternates = self.object_database_path().into_os_string();
let directory = tempfile::Builder::new()
.prefix("worktrunk-list-objects-")
.tempdir()
.ok()?;

let mut clone = self.clone();
clone.temporary_object_directory = Some(Arc::new(TemporaryObjectDirectory {
directory,
alternates,
}));
Some(clone)
}

/// Absolute path to this repository's shared object database — the store a
/// redirected repository probes for writability and names as its read-only
/// alternate.
///
/// This is the common dir's `objects`, shared by every linked worktree. It
/// does not resolve an inherited `GIT_OBJECT_DIRECTORY` (set only when `wt`
/// runs under a git alias); that combined with a read-only store and a
/// `wt list` is vanishingly rare, and the redirect degrades to reading the
/// common store rather than failing.
fn object_database_path(&self) -> PathBuf {
let objects = self.git_common_dir.join("objects");
canonicalize(&objects).unwrap_or(objects)
}

/// The `(object-directory, alternates)` environment for a redirected
/// repository, or `None` when object writes go to the real database.
/// Copied into [`WorkingTree`]'s [`TempIndex`], which builds its own `Cmd`.
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()))
}

/// Add the temporary-object-database environment to `cmd` when this
/// repository is redirected; otherwise return `cmd` unchanged. Applied to
/// every git command the repository runs, so a redirected repository's
/// object writes all land in the temporary store.
fn with_object_store_env(&self, cmd: Cmd) -> Cmd {
match self.object_store_environment() {
Some((directory, alternates)) => cmd
.env("GIT_OBJECT_DIRECTORY", directory)
.env("GIT_ALTERNATE_OBJECT_DIRECTORIES", alternates),
None => cmd,
}
}

/// 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 Expand Up @@ -1418,10 +1529,13 @@ impl Repository {
/// # Ok::<(), anyhow::Error>(())
/// ```
pub fn run_command(&self, args: &[&str]) -> anyhow::Result<String> {
let output = Cmd::new("git")
.args(args.iter().copied())
.current_dir(&self.discovery_path)
.context(self.logging_context())
let output = self
.with_object_store_env(
Cmd::new("git")
.args(args.iter().copied())
.current_dir(&self.discovery_path)
.context(self.logging_context()),
)
.run()
.with_context(|| format!("Failed to execute: git {}", args.join(" ")))?;

Expand Down Expand Up @@ -1500,12 +1614,14 @@ impl Repository {
/// Use this when exit codes have semantic meaning beyond success/failure.
/// For most cases, prefer `run_command` (returns stdout) or `run_command_check` (returns bool).
pub(super) fn run_command_output(&self, args: &[&str]) -> anyhow::Result<std::process::Output> {
Cmd::new("git")
.args(args.iter().copied())
.current_dir(&self.discovery_path)
.context(self.logging_context())
.run()
.with_context(|| format!("Failed to execute: git {}", args.join(" ")))
self.with_object_store_env(
Cmd::new("git")
.args(args.iter().copied())
.current_dir(&self.discovery_path)
.context(self.logging_context()),
)
.run()
.with_context(|| format!("Failed to execute: git {}", args.join(" ")))
}

/// Extract structured failure info from a command-runner error.
Expand Down
3 changes: 3 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 @@ -650,6 +652,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
28 changes: 22 additions & 6 deletions src/git/repository/working_tree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,10 +135,13 @@ impl<'a> WorkingTree<'a> {
/// Use this when you need to check exit codes directly (e.g., for commands
/// where non-zero exit is not an error condition).
pub fn run_command_output(&self, args: &[&str]) -> anyhow::Result<std::process::Output> {
Cmd::new("git")
.args(args.iter().copied())
.current_dir(&self.path)
.context(path_to_logging_context(&self.path))
self.repo
.with_object_store_env(
Cmd::new("git")
.args(args.iter().copied())
.current_dir(&self.path)
.context(path_to_logging_context(&self.path)),
)
.run()
.with_context(|| format!("Failed to execute: git {}", args.join(" ")))
}
Expand Down Expand Up @@ -567,6 +570,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()),
),
})
}

Expand Down Expand Up @@ -675,6 +681,10 @@ pub struct TempIndex {
temp: tempfile::TempPath,
worktree_root: PathBuf,
log_ctx: String,
/// Copied from the [`Repository`] so a redirected `wt list` writes the temp
/// index's `write-tree` objects into the temporary store. `None` on the
/// normal persistent path. See [`Repository::redirect_objects_if_read_only`].
object_store_environment: Option<(PathBuf, std::ffi::OsString)>,
}

impl TempIndex {
Expand All @@ -693,11 +703,17 @@ impl TempIndex {
I: IntoIterator<Item = S>,
S: Into<String>,
{
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());
match &self.object_store_environment {
Some((directory, alternates)) => command
.env("GIT_OBJECT_DIRECTORY", directory)
.env("GIT_ALTERNATE_OBJECT_DIRECTORIES", alternates),
None => command,
}
}
}

Expand Down
Loading
Loading