Skip to content
Merged
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
19 changes: 19 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 Expand Up @@ -2203,6 +2212,16 @@ pub fn populate_item(
item: &mut ListItem,
mut options: CollectOptions,
) -> anyhow::Result<()> {
// Mirror `collect()`: in a read-only checkout, redirect this item's
// object-writing merge/conflict probes into a temporary object database so
// the statusline still classifies integration state. `wt list statusline`
// is a separate entry point from `collect()` (its only callers are in
// `commands/statusline.rs`) and renders on every Claude Code prompt inside
// exactly the managed read-only sandbox this targets. See
// `Repository::redirect_objects_if_read_only`.
let redirected = repo.redirect_objects_if_read_only();
let repo = redirected.as_ref().unwrap_or(repo);

// Populate commit data directly. The main `collect()` path batches this
// across all items pre-skeleton; the single-item statusline path has no
// such batch, so fetch the one SHA here. Skip null OIDs (unborn branches).
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