fix: corrected worktree baseline capture with synthetic tree diff

- Added untracked worktree baseline capture via `untrackedPatch` and synthetic tree diffing.
- Added fallback-aware backend ordering by collecting host candidates and retrying alternates on unavailable PAL.
- Hardened overlay mount lifecycle by removing stale overlays and deleting upper/work dirs after unmount.
- Refined ZFS clone deletion to validate ownership and remove dataset+origin only when checks pass.
- Updated ProjFS integration to use extended-info callbacks and symlink metadata reads.
- Updated rcopy path handling to absolutize paths, and added `writeTree` plus combined shell timeout checks.
This commit is contained in:
can1357
2026-05-12 14:24:26 +02:00
parent f81af06a2b
commit 1cffb3473a
12 changed files with 430 additions and 218 deletions
+90 -33
View File
@@ -119,6 +119,22 @@ impl BackendKind {
}
}
#[cfg(target_os = "macos")]
const MACOS_AUTO_ORDER: &[BackendKind] = &[BackendKind::Apfs, BackendKind::Zfs, BackendKind::Rcopy];
#[cfg(target_os = "linux")]
const LINUX_AUTO_ORDER: &[BackendKind] = &[
BackendKind::Btrfs,
BackendKind::Zfs,
BackendKind::LinuxReflink,
BackendKind::Overlayfs,
BackendKind::Rcopy,
];
#[cfg(windows)]
const WINDOWS_AUTO_ORDER: &[BackendKind] =
&[BackendKind::WindowsBlockClone, BackendKind::Projfs, BackendKind::Rcopy];
#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
const FALLBACK_AUTO_ORDER: &[BackendKind] = &[BackendKind::Rcopy];
impl fmt::Display for BackendKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
@@ -261,52 +277,93 @@ pub fn backend_kind() -> BackendKind {
default_backend().kind()
}
/// Outcome of [`resolve`]. `kind` is the backend that will actually be
/// used; `fell_back` is `true` when a `preferred` choice (or the
/// platform-native pick) was unusable and the resolver downgraded.
/// `reason` carries the original probe's explanation when available.
#[derive(Debug, Clone)]
pub struct Resolution {
pub kind: BackendKind,
pub fell_back: bool,
pub reason: Option<String>,
/// Backend preference order for automatic isolation on this build target.
///
/// The order is intentionally broader than [`BackendKind::native`]: it tries
/// filesystem-native snapshot/reflink mechanisms first, then mount/projection
/// overlays, and keeps [`BackendKind::Rcopy`] as the universal final fallback.
pub const fn auto_order() -> &'static [BackendKind] {
#[cfg(target_os = "macos")]
{
MACOS_AUTO_ORDER
}
#[cfg(target_os = "linux")]
{
LINUX_AUTO_ORDER
}
#[cfg(windows)]
{
WINDOWS_AUTO_ORDER
}
#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
{
FALLBACK_AUTO_ORDER
}
}
/// Pick the best backend available right now.
/// Outcome of [`resolve`].
///
/// `kind` is the first host-available backend to try. `candidates` contains
/// every host-available backend in fallback order, starting with `kind`, so
/// callers can retry when a backend is unavailable for a specific filesystem
/// path. `fell_back` is `true` when a `preferred` choice (or earlier automatic
/// candidate) was unusable. `reason` carries the first unavailable probe's
/// explanation when available.
#[derive(Debug, Clone)]
pub struct Resolution {
pub kind: BackendKind,
pub candidates: Vec<BackendKind>,
pub fell_back: bool,
pub reason: Option<String>,
}
/// Pick the best backend whose host-level prerequisites are available.
///
/// Caller priority:
/// 1. If `preferred` is `Some` and its [`probe`](IsolationBackend::probe)
/// reports `available`, use it as-is.
/// 2. Otherwise try the platform-native backend ([`BackendKind::native`]); if
/// it differs from `preferred` and probes available, use it.
/// 3. Otherwise fall back to [`BackendKind::Rcopy`], which is always available.
/// 2. Otherwise walk [`auto_order`], skipping `preferred` if present.
/// 3. [`BackendKind::Rcopy`] is the final automatic candidate and is expected
/// to be available on every platform.
///
/// The unavailable probe's `reason` is carried through `Resolution::reason`
/// so callers can surface it to the user instead of guessing.
/// This is only a host-level probe. Some backends still reject a specific
/// `lower`/`merged` pair at [`IsolationBackend::start`] time (cross-device
/// reflinks, non-subvolume btrfs paths, non-ZFS mountpoints). Callers that can
/// recover should retry the remaining automatic candidates when `start`
/// returns [`IsoError::Unavailable`].
pub fn resolve(preferred: Option<BackendKind>) -> Resolution {
let mut reason = None;
let mut candidates = Vec::with_capacity(auto_order().len() + usize::from(preferred.is_some()));
if let Some(p) = preferred {
let probe = backend(p).probe();
if probe.available {
return Resolution { kind: p, fell_back: false, reason: None };
candidates.push(p);
} else {
reason = probe.reason;
}
let original_reason = probe.reason;
let native = BackendKind::native();
if native != p {
let np = backend(native).probe();
if np.available {
return Resolution { kind: native, fell_back: true, reason: original_reason };
}
}
for candidate in auto_order() {
if Some(*candidate) == preferred {
continue;
}
let probe = backend(*candidate).probe();
if probe.available {
candidates.push(*candidate);
} else if reason.is_none() {
reason = probe.reason;
}
return Resolution {
kind: BackendKind::Rcopy,
fell_back: true,
reason: original_reason,
};
}
let native = BackendKind::native();
let probe = backend(native).probe();
if probe.available {
return Resolution { kind: native, fell_back: false, reason: None };
if candidates.is_empty() {
candidates.push(BackendKind::Rcopy);
}
Resolution { kind: BackendKind::Rcopy, fell_back: true, reason: probe.reason }
let kind = candidates[0];
let fell_back = match preferred {
Some(p) => kind != p,
None => kind != auto_order()[0],
};
Resolution { kind, candidates, fell_back, reason }
}
+37 -16
View File
@@ -117,6 +117,10 @@ mod imp {
let upper = base.join("upper");
let work = base.join("work");
remove_dir_if_exists(&upper, "stale overlay upper")?;
remove_dir_if_exists(&work, "stale overlay work")?;
remove_dir_if_exists(&merged, "stale overlay merged")?;
fs::create_dir_all(&upper)
.map_err(|err| IsoError::other(format!("create upper dir {}: {err}", upper.display())))?;
fs::create_dir_all(&work)
@@ -152,23 +156,32 @@ mod imp {
pub fn stop(merged: &Path) -> IsoResult<()> {
let merged = absolutize(merged);
let flavor = ACTIVE_MOUNTS.lock().remove(&merged);
match flavor {
Some(MountFlavor::Fuse) => fuse_umount(&merged),
Some(MountFlavor::Kernel) | None => {
// `None` covers callers that skipped `start` (probe-style flow)
// or processes that re-attached after a crash; try a kernel
// umount first, fall back to fusermount so we don't silently
// leak a mount.
kernel_umount(&merged).or_else(|err| {
if err.is_unavailable() {
fuse_umount(&merged)
} else {
Err(err)
}
})
},
let result = {
let flavor = ACTIVE_MOUNTS.lock().remove(&merged);
match flavor {
Some(MountFlavor::Fuse) => fuse_umount(&merged),
Some(MountFlavor::Kernel) | None => {
// `None` covers callers that skipped `start` (probe-style flow)
// or processes that re-attached after a crash; try a kernel
// umount first, fall back to fusermount so we don't silently
// leak a mount.
kernel_umount(&merged).or_else(|err| {
if err.is_unavailable() {
fuse_umount(&merged)
} else {
Err(err)
}
})
},
}
};
result?;
if let Some(base) = merged.parent() {
remove_dir_if_exists(&base.join("upper"), "overlay upper")?;
remove_dir_if_exists(&base.join("work"), "overlay work")?;
}
remove_dir_if_exists(&merged, "overlay merged")
}
fn kernel_mount(merged: &Path, opts: &str) -> IsoResult<()> {
@@ -321,6 +334,14 @@ mod imp {
}
}
fn remove_dir_if_exists(path: &Path, label: &str) -> IsoResult<()> {
match fs::remove_dir_all(path) {
Ok(()) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(IsoError::other(format!("remove {label} {}: {err}", path.display()))),
}
}
fn to_cstring(bytes: &[u8], label: &str) -> IsoResult<CString> {
CString::new(bytes)
.map_err(|err| IsoError::other(format!("{label} path contains NUL byte: {err}")))
+72 -22
View File
@@ -82,17 +82,29 @@ fn x64_under_arm64_emulation() -> bool {
if !cfg!(windows) || !cfg!(target_arch = "x86_64") {
return false;
}
let matches_arm64 = |var: &str| {
std::env::var(var)
.ok()
.is_some_and(|v| v.eq_ignore_ascii_case("ARM64"))
};
matches_arm64("PROCESSOR_ARCHITEW6432") || matches_arm64("PROCESSOR_ARCHITECTURE")
let env_value = |var: &str| std::env::var(var).ok();
vars_indicate_arm64_emulation(
env_value("PROCESSOR_ARCHITEW6432").as_deref(),
env_value("PROCESSOR_ARCHITECTURE").as_deref(),
)
}
fn vars_indicate_arm64_emulation(wow64_arch: Option<&str>, process_arch: Option<&str>) -> bool {
let matches_arm64 = |value: Option<&str>| value.is_some_and(|v| v.eq_ignore_ascii_case("ARM64"));
matches_arm64(wow64_arch) || matches_arm64(process_arch)
}
#[cfg(test)]
mod tests {
use super::x64_under_arm64_emulation;
use super::{vars_indicate_arm64_emulation, x64_under_arm64_emulation};
#[test]
fn detects_windows_arm64_emulation_markers() {
assert!(vars_indicate_arm64_emulation(Some("ARM64"), None));
assert!(vars_indicate_arm64_emulation(None, Some("arm64")));
assert!(!vars_indicate_arm64_emulation(Some("AMD64"), Some("x86")));
assert!(!vars_indicate_arm64_emulation(None, None));
}
#[test]
fn returns_false_off_windows_or_non_x64() {
@@ -139,8 +151,9 @@ mod imp {
Storage::ProjectedFileSystem::{
PRJ_CALLBACK_DATA, PRJ_CALLBACKS, PRJ_CB_DATA_FLAG_ENUM_RESTART_SCAN,
PRJ_CB_DATA_FLAG_ENUM_RETURN_SINGLE_ENTRY, PRJ_DIR_ENTRY_BUFFER_HANDLE,
PRJ_FILE_BASIC_INFO, PRJ_NAMESPACE_VIRTUALIZATION_CONTEXT, PRJ_PLACEHOLDER_INFO,
PRJ_STARTVIRTUALIZING_OPTIONS,
PRJ_EXT_INFO_TYPE_SYMLINK, PRJ_EXTENDED_INFO, PRJ_EXTENDED_INFO_0,
PRJ_EXTENDED_INFO_0_0, PRJ_FILE_BASIC_INFO, PRJ_NAMESPACE_VIRTUALIZATION_CONTEXT,
PRJ_PLACEHOLDER_INFO, PRJ_STARTVIRTUALIZING_OPTIONS,
},
System::{
Com::CoCreateGuid,
@@ -176,7 +189,7 @@ mod imp {
PRJ_DIR_ENTRY_BUFFER_HANDLE,
PCWSTR,
*const PRJ_FILE_BASIC_INFO,
*const c_void,
*const PRJ_EXTENDED_INFO,
) -> HRESULT;
type PrjWriteFileDataFn = unsafe extern "system" fn(
PRJ_NAMESPACE_VIRTUALIZATION_CONTEXT,
@@ -185,11 +198,12 @@ mod imp {
u64,
u32,
) -> HRESULT;
type PrjWritePlaceholderInfoFn = unsafe extern "system" fn(
type PrjWritePlaceholderInfo2Fn = unsafe extern "system" fn(
PRJ_NAMESPACE_VIRTUALIZATION_CONTEXT,
PCWSTR,
*const PRJ_PLACEHOLDER_INFO,
u32,
*const PRJ_EXTENDED_INFO,
) -> HRESULT;
struct ProjfsApi {
@@ -203,7 +217,7 @@ mod imp {
prj_stop_virtualizing: PrjStopVirtualizingFn,
prj_fill_dir_entry_buffer2: PrjFillDirEntryBuffer2Fn,
prj_write_file_data: PrjWriteFileDataFn,
prj_write_placeholder_info: PrjWritePlaceholderInfoFn,
prj_write_placeholder_info2: PrjWritePlaceholderInfo2Fn,
}
unsafe impl Send for ProjfsApi {}
@@ -263,17 +277,18 @@ mod imp {
PrjFillDirEntryBuffer2Fn
),
prj_write_file_data: load_symbol!("PrjWriteFileData", PrjWriteFileDataFn),
prj_write_placeholder_info: load_symbol!(
"PrjWritePlaceholderInfo",
PrjWritePlaceholderInfoFn
prj_write_placeholder_info2: load_symbol!(
"PrjWritePlaceholderInfo2",
PrjWritePlaceholderInfo2Fn
),
})
}
}
struct DirectoryEntry {
name_wide: Vec<u16>,
basic_info: PRJ_FILE_BASIC_INFO,
name_wide: Vec<u16>,
basic_info: PRJ_FILE_BASIC_INFO,
symlink_target: Option<Vec<u16>>,
}
#[derive(Default)]
@@ -538,12 +553,16 @@ mod imp {
};
if matched {
let extended_info = symlink_extended_info(entry.symlink_target.as_deref());
let extended_info_ptr = extended_info
.as_ref()
.map_or(std::ptr::null(), |info| info as *const _);
let hr = unsafe {
(context.api.prj_fill_dir_entry_buffer2)(
dir_entry_buffer_handle,
entry.name_wide.as_ptr(),
&raw const entry.basic_info,
std::ptr::null(),
extended_info_ptr,
)
};
if is_failed(hr) {
@@ -572,10 +591,18 @@ mod imp {
let relative_path = callback_relative_path(callback_data);
let source_path = context.lower_root.join(relative_path);
let metadata = match fs::metadata(&source_path) {
let metadata = match fs::symlink_metadata(&source_path) {
Ok(metadata) => metadata,
Err(err) => return io_error_to_hresult(&err),
};
let symlink_target = match symlink_target_wide(&source_path, &metadata) {
Ok(target) => target,
Err(err) => return io_error_to_hresult(&err),
};
let extended_info = symlink_extended_info(symlink_target.as_deref());
let extended_info_ptr = extended_info
.as_ref()
.map_or(std::ptr::null(), |info| info as *const _);
let placeholder_info =
PRJ_PLACEHOLDER_INFO { FileBasicInfo: to_basic_info(&metadata), ..Default::default() };
@@ -587,11 +614,12 @@ mod imp {
};
unsafe {
(context.api.prj_write_placeholder_info)(
(context.api.prj_write_placeholder_info2)(
callback_data.NamespaceVirtualizationContext,
destination,
&raw const placeholder_info,
mem::size_of::<PRJ_PLACEHOLDER_INFO>() as u32,
extended_info_ptr,
)
}
}
@@ -706,18 +734,21 @@ mod imp {
let mut entries = Vec::new();
for entry in fs::read_dir(&source_dir)? {
let entry = entry?;
let metadata = entry.metadata()?;
let path = entry.path();
let metadata = fs::symlink_metadata(&path)?;
let symlink_target = symlink_target_wide(&path, &metadata)?;
let name = entry.file_name();
let mut name_wide = to_wide(name.as_os_str());
if name_wide.is_empty() {
continue;
}
entries.push(DirectoryEntry {
name_wide: {
name_wide: {
name_wide.shrink_to_fit();
name_wide
},
basic_info: to_basic_info(&metadata),
symlink_target,
});
}
@@ -742,6 +773,25 @@ mod imp {
}
}
fn symlink_target_wide(path: &Path, metadata: &fs::Metadata) -> io::Result<Option<Vec<u16>>> {
if !metadata.file_type().is_symlink() {
return Ok(None);
}
let mut target = to_wide(fs::read_link(path)?.as_os_str());
target.shrink_to_fit();
Ok(Some(target))
}
fn symlink_extended_info(target: Option<&[u16]>) -> Option<PRJ_EXTENDED_INFO> {
target.map(|target| PRJ_EXTENDED_INFO {
InfoType: PRJ_EXT_INFO_TYPE_SYMLINK,
NextInfoOffset: 0,
Anonymous: PRJ_EXTENDED_INFO_0 {
Symlink: PRJ_EXTENDED_INFO_0_0 { TargetName: target.as_ptr() },
},
})
}
fn callback_relative_path(callback_data: &PRJ_CALLBACK_DATA) -> PathBuf {
if callback_data.FilePathName.is_null() {
return PathBuf::new();
+13 -4
View File
@@ -35,18 +35,19 @@ impl IsolationBackend for RcopyBackend {
fn start(&self, lower: &Path, merged: &Path) -> IsoResult<()> {
let lower = canonical_existing_dir(lower)?;
prepare_destination(merged)?;
let merged = absolutize(merged);
prepare_destination(&merged)?;
if is_git_worktree(&lower) {
git_worktree_add(&lower, merged)?;
git_worktree_add(&lower, &merged)?;
// `worktree add --detach HEAD` lands on a clean checkout. omp
// (and friends) expect `merged` to mirror `lower`'s **live**
// working tree, so seed the index + working tree + untracked
// files exactly as they exist in lower. No applyBaseline call
// in the caller — every backend's post-`start` invariant is
// the same.
seed_dirty_state(&lower, merged)
seed_dirty_state(&lower, &merged)
} else {
recursive_copy(&lower, merged)
recursive_copy(&lower, &merged)
}
}
@@ -83,6 +84,14 @@ fn canonical_existing_dir(path: &Path) -> IsoResult<PathBuf> {
Ok(std::fs::canonicalize(&resolved).unwrap_or(resolved))
}
fn absolutize(path: &Path) -> PathBuf {
if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir().map_or_else(|_| path.to_path_buf(), |cwd| cwd.join(path))
}
}
fn prepare_destination(merged: &Path) -> IsoResult<()> {
if let Some(parent) = merged.parent() {
std::fs::create_dir_all(parent)
+22 -7
View File
@@ -128,10 +128,14 @@ mod imp {
match dataset_for_mountpoint(&merged)? {
Some(dataset) => {
let origin = zfs_get_value("origin", &dataset)?;
run_zfs_other(["destroy", dataset.as_str()])?;
if is_own_snapshot(&origin) {
run_zfs_other(["destroy", origin.as_str()])?;
if !is_own_clone(&dataset, &origin) {
return Err(IsoError::other(format!(
"refusing to destroy unrelated ZFS dataset {dataset} mounted at {}",
merged.display()
)));
}
run_zfs_other(["destroy", dataset.as_str()])?;
run_zfs_other(["destroy", origin.as_str()])?;
Ok(())
},
None => match fs::remove_dir_all(&merged) {
@@ -306,16 +310,27 @@ mod imp {
hash
}
fn is_own_snapshot(snapshot: &str) -> bool {
let Some((_, name)) = snapshot.rsplit_once('@') else {
return false;
};
fn is_own_name(name: &str) -> bool {
name.starts_with(SNAP_PREFIX)
&& name[SNAP_PREFIX.len()..]
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':'))
}
fn is_own_snapshot(snapshot: &str) -> bool {
let Some((_, name)) = snapshot.rsplit_once('@') else {
return false;
};
is_own_name(name)
}
fn is_own_clone(dataset: &str, origin: &str) -> bool {
let Some(name) = dataset.rsplit('/').next() else {
return false;
};
is_own_name(name) && is_own_snapshot(origin)
}
fn absolute_path(path: &Path) -> PathBuf {
if path.is_absolute() {
path.to_path_buf()
+15 -8
View File
@@ -62,13 +62,15 @@ pub struct IsoProbeResult {
/// Outcome of [`iso_resolve`].
#[napi(object)]
pub struct IsoResolveResult {
/// Backend that will actually be used.
pub kind: IsoBackendKind,
/// Backend that will actually be tried first.
pub kind: IsoBackendKind,
/// Host-available backends in retry order, starting with `kind`.
pub candidates: Vec<IsoBackendKind>,
/// True when the resolver fell back from `preferred` (or from the
/// platform native) to a different backend.
pub fell_back: bool,
/// first automatic candidate) to a different backend.
pub fell_back: bool,
/// Human-readable reason for the fallback, if any.
pub reason: Option<String>,
pub reason: Option<String>,
}
/// One entry in an [`IsoDiff`].
@@ -113,9 +115,14 @@ pub fn iso_probe(kind: Option<IsoBackendKind>) -> IsoProbeResult {
pub fn iso_resolve(preferred: Option<IsoBackendKind>) -> IsoResolveResult {
let resolution = pi_iso::resolve(preferred.map(from_napi_kind));
IsoResolveResult {
kind: to_napi_kind(resolution.kind),
fell_back: resolution.fell_back,
reason: resolution.reason,
kind: to_napi_kind(resolution.kind),
candidates: resolution
.candidates
.into_iter()
.map(to_napi_kind)
.collect(),
fell_back: resolution.fell_back,
reason: resolution.reason,
}
}
+11 -13
View File
@@ -12,7 +12,6 @@ use std::{
};
use anyhow::{Error, Result};
use bytes::Bytes;
use brush_builtins::{BuiltinSet, default_builtins};
use brush_core::{
ExecutionContext, ExecutionControlFlow, ExecutionExitCode, ExecutionResult, ProcessGroupPolicy,
@@ -21,6 +20,7 @@ use brush_core::{
env::EnvironmentScope,
openfiles::{self, OpenFile, OpenFiles},
};
use bytes::Bytes;
use clap::Parser;
#[cfg(not(unix))]
use tokio::io::AsyncReadExt as _;
@@ -971,23 +971,21 @@ async fn run_shell_command_streams(
if !stdout_finished || !stderr_finished {
reader_cancel.cancel();
}
if !stdout_finished {
if time::timeout(READER_SHUTDOWN_TIMEOUT, &mut stdout_handle)
if !stdout_finished
&& time::timeout(READER_SHUTDOWN_TIMEOUT, &mut stdout_handle)
.await
.is_err()
{
stdout_handle.abort();
let _ = stdout_handle.await;
}
{
stdout_handle.abort();
let _ = stdout_handle.await;
}
if !stderr_finished {
if time::timeout(READER_SHUTDOWN_TIMEOUT, &mut stderr_handle)
if !stderr_finished
&& time::timeout(READER_SHUTDOWN_TIMEOUT, &mut stderr_handle)
.await
.is_err()
{
stderr_handle.abort();
let _ = stderr_handle.await;
}
{
stderr_handle.abort();
let _ = stderr_handle.await;
}
cancel_bridge.abort();
let _ = cancel_bridge.await;
+3
View File
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
### Added
- Added new `task.isolation.mode` values `auto`, `apfs`, `btrfs`, `zfs`, `reflink`, `overlayfs`, `projfs`, `block-clone`, and `rcopy` for native PAL-backed task isolation backends
@@ -13,6 +14,8 @@
### Fixed
- Fixed worktree delta capture to include previously untracked file state by baselining untracked patches for both snapshots
- Fixed task isolation startup to try alternate PAL backends when the preferred one is unavailable, allowing successful fallback instead of immediate failure
- Mapped legacy `task.isolation.mode` values `worktree`, `fuse-overlay`, and `fuse-projfs` to their new equivalents during settings migration to preserve behavior with older configs
## [14.9.8] - 2026-05-12
+85 -93
View File
@@ -2,10 +2,13 @@ import type { Dirent } from "node:fs";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { IsoBackendKind, isoResolve, isoStart, isoStop } from "@oh-my-pi/pi-natives";
import * as natives from "@oh-my-pi/pi-natives";
import { getWorktreeDir, logger, Snowflake } from "@oh-my-pi/pi-utils";
import * as git from "../utils/git";
const { IsoBackendKind } = natives;
type IsoBackendKind = natives.IsoBackendKind;
/** Baseline state for a single git repository. */
export interface RepoBaseline {
repoRoot: string;
@@ -13,6 +16,7 @@ export interface RepoBaseline {
staged: string;
unstaged: string;
untracked: string[];
untrackedPatch: string;
}
/** Baseline state for the project, including any nested git repos. */
@@ -79,12 +83,49 @@ async function discoverNestedRepos(repoRoot: string): Promise<string[]> {
return result;
}
async function captureUntrackedPatch(repoRoot: string, untracked: readonly string[]): Promise<string> {
if (untracked.length === 0) return "";
const nullPath = getGitNoIndexNullPath();
const untrackedDiffs = await Promise.all(
untracked.map(entry =>
git.diff(repoRoot, {
allowFailure: true,
binary: true,
noIndex: { left: nullPath, right: entry },
}),
),
);
return untrackedDiffs.filter(diff => diff.trim()).join("\n");
}
async function captureRepoBaseline(repoRoot: string): Promise<RepoBaseline> {
const headCommit = (await git.head.sha(repoRoot)) ?? "";
const staged = await git.diff(repoRoot, { binary: true, cached: true });
const unstaged = await git.diff(repoRoot, { binary: true });
const untracked = await git.ls.untracked(repoRoot);
return { repoRoot, headCommit, staged, unstaged, untracked };
const untrackedPatch = await captureUntrackedPatch(repoRoot, untracked);
return { repoRoot, headCommit, staged, unstaged, untracked, untrackedPatch };
}
async function writeSyntheticTree(repoDir: string, baseTreeish: string, patches: readonly string[]): Promise<string> {
const tempIndex = path.join(os.tmpdir(), `omp-task-index-${Snowflake.next()}`);
try {
await git.readTree(repoDir, baseTreeish, {
env: { GIT_INDEX_FILE: tempIndex },
});
for (const patch of patches) {
if (!patch.trim()) continue;
await git.patch.applyText(repoDir, patch, {
cached: true,
env: { GIT_INDEX_FILE: tempIndex },
});
}
return await git.writeTree(repoDir, {
env: { GIT_INDEX_FILE: tempIndex },
});
} finally {
await fs.rm(tempIndex, { force: true });
}
}
export async function captureBaseline(repoRoot: string): Promise<WorktreeBaseline> {
@@ -99,87 +140,23 @@ export async function captureBaseline(repoRoot: string): Promise<WorktreeBaselin
}
async function captureRepoDeltaPatch(repoDir: string, rb: RepoBaseline): Promise<string> {
// Check if HEAD advanced (task committed changes)
const currentHead = (await git.head.sha(repoDir)) ?? "";
const headAdvanced = currentHead && currentHead !== rb.headCommit;
const currentStaged = await git.diff(repoDir, { binary: true, cached: true });
const currentUnstaged = await git.diff(repoDir, { binary: true });
const currentUntracked = await git.ls.untracked(repoDir);
const currentUntrackedPatch = await captureUntrackedPatch(repoDir, currentUntracked);
if (headAdvanced) {
// HEAD moved: use diff-tree to capture committed changes, plus any uncommitted on top
const parts: string[] = [];
const baselineTree = await writeSyntheticTree(repoDir, rb.headCommit, [rb.staged, rb.unstaged, rb.untrackedPatch]);
const currentTree = await writeSyntheticTree(repoDir, currentHead, [
currentStaged,
currentUnstaged,
currentUntrackedPatch,
]);
// Committed changes since baseline
const committedDiff = await git.diff.tree(repoDir, rb.headCommit, currentHead, {
allowFailure: true,
binary: true,
});
if (committedDiff.trim()) parts.push(committedDiff);
// Uncommitted changes on top of the new HEAD
const staged = await git.diff(repoDir, { binary: true, cached: true });
const unstaged = await git.diff(repoDir, { binary: true });
if (staged.trim()) parts.push(staged);
if (unstaged.trim()) parts.push(unstaged);
// New untracked files (relative to both baseline and current tracking)
const currentUntracked = await git.ls.untracked(repoDir);
const baselineUntracked = new Set(rb.untracked);
const newUntracked = currentUntracked.filter(entry => !baselineUntracked.has(entry));
if (newUntracked.length > 0) {
const nullPath = getGitNoIndexNullPath();
const untrackedDiffs = await Promise.all(
newUntracked.map(entry =>
git.diff(repoDir, {
allowFailure: true,
binary: true,
noIndex: { left: nullPath, right: entry },
}),
),
);
parts.push(...untrackedDiffs.filter(d => d.trim()));
}
return parts.join("\n");
}
// HEAD unchanged: use temp index approach (subtracts baseline from delta)
const tempIndex = path.join(os.tmpdir(), `omp-task-index-${Snowflake.next()}`);
try {
await git.readTree(repoDir, rb.headCommit, {
env: { GIT_INDEX_FILE: tempIndex },
});
await git.patch.applyText(repoDir, rb.staged, {
cached: true,
env: { GIT_INDEX_FILE: tempIndex },
});
await git.patch.applyText(repoDir, rb.unstaged, {
cached: true,
env: { GIT_INDEX_FILE: tempIndex },
});
const diff = await git.diff(repoDir, {
binary: true,
env: { GIT_INDEX_FILE: tempIndex },
});
const currentUntracked = await git.ls.untracked(repoDir);
const baselineUntracked = new Set(rb.untracked);
const newUntracked = currentUntracked.filter(entry => !baselineUntracked.has(entry));
if (newUntracked.length === 0) return diff;
const nullPath = getGitNoIndexNullPath();
const untrackedDiffs = await Promise.all(
newUntracked.map(entry =>
git.diff(repoDir, {
allowFailure: true,
binary: true,
noIndex: { left: nullPath, right: entry },
}),
),
);
return `${diff}${diff && !diff.endsWith("\n") ? "\n" : ""}${untrackedDiffs.join("\n")}`;
} finally {
await fs.rm(tempIndex, { force: true });
}
return git.diff.tree(repoDir, baselineTree, currentTree, {
allowFailure: true,
binary: true,
});
}
export interface NestedRepoPatch {
@@ -328,6 +305,11 @@ export interface IsolationHandle {
* caller learns about that through `IsolationHandle.fellBack` +
* `fallbackReason`.
*/
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
export async function ensureIsolation(
baseCwd: string,
id: string,
@@ -338,28 +320,38 @@ export async function ensureIsolation(
const baseDir = getWorktreeDir(encodedProject, id);
const mergedDir = path.join(baseDir, "merged");
const resolution = isoResolve(preferred ?? null);
const resolution = natives.isoResolve(preferred ?? null);
const candidates = resolution.candidates.length > 0 ? resolution.candidates : [resolution.kind];
let fallbackReason = resolution.reason ?? null;
await fs.rm(baseDir, { recursive: true, force: true });
try {
await isoStart(resolution.kind, repoRoot, mergedDir);
return {
mergedDir,
backend: resolution.kind,
fellBack: resolution.fellBack,
fallbackReason: resolution.reason ?? null,
};
} catch (err) {
for (const candidate of candidates) {
await fs.rm(baseDir, { recursive: true, force: true });
throw err;
try {
await natives.isoStart(candidate, repoRoot, mergedDir);
return {
mergedDir,
backend: candidate,
fellBack: candidate !== resolution.kind || resolution.fellBack,
fallbackReason,
};
} catch (err) {
await fs.rm(baseDir, { recursive: true, force: true });
const message = errorMessage(err);
if (!natives.isoIsUnavailableError(message)) {
throw err;
}
fallbackReason ??= message;
}
}
throw new Error(fallbackReason ?? "No isolation backend is available.");
}
/** Tear down a handle returned by {@link ensureIsolation}. */
export async function cleanupIsolation(handle: IsolationHandle): Promise<void> {
try {
try {
await isoStop(handle.backend, handle.mergedDir);
await natives.isoStop(handle.backend, handle.mergedDir);
} catch (err) {
logger.warn("isolation backend stop failed during cleanup", {
backend: handle.backend,
+5
View File
@@ -895,6 +895,11 @@ export async function readTree(
await runEffect(cwd, ["read-tree", treeish], options);
}
/** Write the current index as a tree and return its object id. */
export async function writeTree(cwd: string, options: Pick<CommandOptions, "env" | "signal"> = {}): Promise<string> {
return (await runText(cwd, ["write-tree"], options)).trim();
}
// ════════════════════════════════════════════════════════════════════════════
// API: show
// ════════════════════════════════════════════════════════════════════════════
@@ -2,29 +2,18 @@ import { afterEach, describe, expect, it, vi } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { getGitNoIndexNullPath, mergeTaskBranches } from "../../src/task/worktree";
import * as natives from "@oh-my-pi/pi-natives";
import {
captureBaseline,
captureDeltaPatch,
ensureIsolation,
getGitNoIndexNullPath,
mergeTaskBranches,
parseIsolationMode,
} from "../../src/task/worktree";
const isoStartMock = vi.fn();
const isoStopMock = vi.fn();
const isoIsUnavailableErrorMock = vi.fn(
(message: string) => typeof message === "string" && message.startsWith("ISO_UNAVAILABLE:"),
);
const tempDirs: string[] = [];
// Numeric mirror of the napi-generated const-enum so the production code's
// `IsoBackendKind.Overlayfs` references resolve without loading the addon.
const IsoBackendKind = { Apfs: 0, Overlayfs: 1, Projfs: 2, Rcopy: 3 } as const;
vi.mock("@oh-my-pi/pi-natives", () => ({
IsoBackendKind,
isoBackend: vi.fn(() => IsoBackendKind.Rcopy),
isoDiff: vi.fn(),
isoIsUnavailableError: isoIsUnavailableErrorMock,
isoProbe: vi.fn(() => ({ available: true, reason: null, kind: IsoBackendKind.Rcopy })),
isoStart: isoStartMock,
isoStop: isoStopMock,
}));
async function runGit(repo: string, args: string[]): Promise<string> {
const proc = Bun.spawn(["git", ...args], {
cwd: repo,
@@ -70,6 +59,49 @@ describe("worktree isolation helpers", () => {
expect(getGitNoIndexNullPath()).toBe(expected);
});
it("maps every isolation mode to the native backend contract", () => {
expect(parseIsolationMode("none")).toBeUndefined();
expect(parseIsolationMode("auto")).toBeUndefined();
expect(parseIsolationMode("apfs")).toBe(natives.IsoBackendKind.Apfs);
expect(parseIsolationMode("btrfs")).toBe(natives.IsoBackendKind.Btrfs);
expect(parseIsolationMode("zfs")).toBe(natives.IsoBackendKind.Zfs);
expect(parseIsolationMode("reflink")).toBe(natives.IsoBackendKind.LinuxReflink);
expect(parseIsolationMode("overlayfs")).toBe(natives.IsoBackendKind.Overlayfs);
expect(parseIsolationMode("fuse-overlay")).toBe(natives.IsoBackendKind.Overlayfs);
expect(parseIsolationMode("projfs")).toBe(natives.IsoBackendKind.Projfs);
expect(parseIsolationMode("fuse-projfs")).toBe(natives.IsoBackendKind.Projfs);
expect(parseIsolationMode("block-clone")).toBe(natives.IsoBackendKind.WindowsBlockClone);
expect(parseIsolationMode("rcopy")).toBe(natives.IsoBackendKind.Rcopy);
expect(parseIsolationMode("worktree")).toBe(natives.IsoBackendKind.Rcopy);
});
it("retries isoResolve candidates when a backend is path-unavailable", async () => {
const { repo } = await createGitRepo();
const unavailable = new Error("ISO_UNAVAILABLE: btrfs source is not a subvolume");
const isoResolve = vi.spyOn(natives, "isoResolve").mockReturnValue({
kind: natives.IsoBackendKind.Btrfs,
candidates: [natives.IsoBackendKind.Btrfs, natives.IsoBackendKind.Rcopy],
fellBack: false,
reason: undefined,
});
const isoStart = vi
.spyOn(natives, "isoStart")
.mockRejectedValueOnce(unavailable)
.mockResolvedValueOnce(undefined);
vi.spyOn(natives, "isoIsUnavailableError").mockImplementation(message => message.startsWith("ISO_UNAVAILABLE:"));
const handle = await ensureIsolation(repo, "retry-path-unavailable");
expect(isoResolve).toHaveBeenCalledWith(null);
expect(isoStart.mock.calls.map(call => call[0])).toEqual([
natives.IsoBackendKind.Btrfs,
natives.IsoBackendKind.Rcopy,
]);
expect(handle.backend).toBe(natives.IsoBackendKind.Rcopy);
expect(handle.fellBack).toBe(true);
expect(handle.fallbackReason).toBe(unavailable.message);
});
it("does not pop an unrelated pre-existing stash when the working tree is clean", async () => {
const { repo } = await createGitRepo();
await fs.writeFile(path.join(repo, "preexisting.txt"), "user stash\n");
@@ -103,4 +135,25 @@ describe("worktree isolation helpers", () => {
expect(await runGit(repo, ["diff", "--cached", "--", "staged.txt"])).toContain("+local staged change");
expect(await runGit(repo, ["stash", "list"])).toBe("");
});
it("subtracts baseline dirty state even when the task commits it", async () => {
const { repo } = await createGitRepo();
await fs.writeFile(path.join(repo, "merged.txt"), "baseline dirty change\n");
await fs.writeFile(path.join(repo, "preexisting.txt"), "baseline untracked\n");
const baseline = await captureBaseline(repo);
await runGit(repo, ["add", "-A"]);
await runGit(repo, ["commit", "-m", "baseline committed inside isolation"]);
await fs.writeFile(path.join(repo, "task.txt"), "task output\n");
await runGit(repo, ["add", "task.txt"]);
await runGit(repo, ["commit", "-m", "task output"]);
const delta = await captureDeltaPatch(repo, baseline);
expect(delta.nestedPatches).toEqual([]);
expect(delta.rootPatch).toContain("task.txt");
expect(delta.rootPatch).toContain("+task output");
expect(delta.rootPatch).not.toContain("baseline dirty change");
expect(delta.rootPatch).not.toContain("preexisting.txt");
});
});
+4 -2
View File
@@ -847,11 +847,13 @@ export declare function isoResolve(preferred?: IsoBackendKind | undefined | null
/** Outcome of [`iso_resolve`]. */
export interface IsoResolveResult {
/** Backend that will actually be used. */
/** Backend that will actually be tried first. */
kind: IsoBackendKind
/** Host-available backends in retry order, starting with `kind`. */
candidates: Array<IsoBackendKind>
/**
* True when the resolver fell back from `preferred` (or from the
* platform native) to a different backend.
* first automatic candidate) to a different backend.
*/
fellBack: boolean
/** Human-readable reason for the fallback, if any. */