From 03fa7cae4dd4d02177ecdc39bffab96dd10d8d4b Mon Sep 17 00:00:00 2001 From: Yuto Nishida Date: Fri, 24 Jul 2026 04:23:04 -0700 Subject: [PATCH] [silverwood] Add apfs-cow checkout mode to basic workstream --- experimental/silverwood/Cargo.lock | 1 + experimental/silverwood/Cargo.toml | 1 + experimental/silverwood/DESIGN.md | 12 +++-- experimental/silverwood/TODO.md | 2 + .../crates/silverwood-cli/src/main.rs | 24 ++++++--- .../crates/silverwood-cli/tests/cli.rs | 11 ++++ .../crates/silverwood-cli/tests/e2e.rs | 50 +++++++++++++++++ .../crates/silverwood-core/Cargo.toml | 1 + .../crates/silverwood-core/src/apfs.rs | 53 +++++++++++++++++++ .../crates/silverwood-core/src/doc.rs | 28 +++++++++- .../crates/silverwood-core/src/forest.rs | 52 +++++++++++++++++- .../crates/silverwood-core/src/lib.rs | 3 +- .../crates/silverwood-core/src/provider.rs | 31 +++++++++-- .../crates/silverwood-core/src/source.rs | 51 ++++++++++++++++++ .../crates/silverwood-core/src/workstream.rs | 37 ++++++++++--- 15 files changed, 330 insertions(+), 27 deletions(-) create mode 100644 experimental/silverwood/crates/silverwood-core/src/apfs.rs diff --git a/experimental/silverwood/Cargo.lock b/experimental/silverwood/Cargo.lock index 35fcb14..8f571e6 100644 --- a/experimental/silverwood/Cargo.lock +++ b/experimental/silverwood/Cargo.lock @@ -1551,6 +1551,7 @@ dependencies = [ name = "silverwood-core" version = "0.1.0" dependencies = [ + "libc", "loro", "proptest", "serde", diff --git a/experimental/silverwood/Cargo.toml b/experimental/silverwood/Cargo.toml index 8a83510..e41f2c8 100644 --- a/experimental/silverwood/Cargo.toml +++ b/experimental/silverwood/Cargo.toml @@ -19,6 +19,7 @@ thiserror = "2" clap = { version = "4", features = ["derive"] } loro = "1" url = "2" +libc = "0.2" time = { version = "0.3", features = ["std", "formatting"] } tempfile = "3" proptest = "1" diff --git a/experimental/silverwood/DESIGN.md b/experimental/silverwood/DESIGN.md index 3c256df..a5fc20f 100644 --- a/experimental/silverwood/DESIGN.md +++ b/experimental/silverwood/DESIGN.md @@ -178,11 +178,13 @@ Forest::create_workstream(NewWorkstream { - **`WorkstreamKind` is an open, tagged enum**, `Basic` its only variant today; the enum is where new kinds (with their own data and session relationships) land. -- **`CheckoutMode` is a data-carrying, internally-tagged open enum**, `JjColocated` - its only variant today. `source` and `state` are **inside** the variant - (`initial_source`, `state`) because they are meaningless without the strategy — a - future adopt-a-local-dir mode would carry no source and be instantly ready. The - creation-side **`NewCheckoutMode`** omits `state`: core owns that lifecycle. +- **`CheckoutMode` is a data-carrying, internally-tagged open enum** — the + jj-colocated modes clone an HTTPS url; `apfs-cow` adopts a local directory via an + APFS copy-on-write clone (`cp -c`), hard-failing at creation unless its source and + the forest's checkout location share one APFS volume. `source` and `state` are + **inside** each variant (`initial_source`, `state`) because they are meaningless + without the strategy. The creation-side **`NewCheckoutMode`** omits `state`: core + owns that lifecycle. - **`Location` = `forest_id` + `LocationWithinForest`**, the latter an open enum over *forest kind* (today `BasicForest { path }`, an absolute path). This axis is independent of `CheckoutMode`. A basic workstream has exactly one location — it is diff --git a/experimental/silverwood/TODO.md b/experimental/silverwood/TODO.md index 949ad49..9832ead 100644 --- a/experimental/silverwood/TODO.md +++ b/experimental/silverwood/TODO.md @@ -78,6 +78,8 @@ Part 2 notes: - [x] all inputs explicit (no defaults): the workstream variant → checkout mode → seed are nested subcommands mirroring the data model — `new basic jj-colocated ` (and `…jj-colocated-direnv-unsafe `); `--name` is a global option accepted at any `new` level; `silverwood modes` metadata is derived from the `new basic` subcommand tree +- [x] `apfs-cow` checkout mode — `new basic apfs-cow ` seeds a checkout from a local dir via an APFS copy-on-write + clone (`cp -c`); creation hard-fails up front unless the source and the forest's checkout location are both APFS on one volume - [x] dual output: human-readable default + `--json` (global flag); mutating cmds print the affected object after the change - [x] verify (structural): `nix flake check` green + `cli.rs` smoke tests (info json, ls empty=`[]`, bad-id exits non-zero) — no network needed - [x] verify (behavioral): drove the WRAPPED binary (`nix build .#default`) new→ls→kv→session→show→archive with a real clone of diff --git a/experimental/silverwood/crates/silverwood-cli/src/main.rs b/experimental/silverwood/crates/silverwood-cli/src/main.rs index 7b866e3..b29002d 100644 --- a/experimental/silverwood/crates/silverwood-cli/src/main.rs +++ b/experimental/silverwood/crates/silverwood-cli/src/main.rs @@ -13,9 +13,9 @@ use std::str::FromStr; use clap::{Parser, Subcommand}; use silverwood_core::{ - agent_shell_plan, base_shell_plan, AgentKind, AgentSession, CheckoutState, Forest, HttpsGitUrl, - LocationWithinForest, NewCheckoutMode, NewKind, NewWorkstream, SpawnSeed, UpgradeReport, - Workstream, WorkstreamId, DOC_SCHEMA_VERSION, + agent_shell_plan, base_shell_plan, AbsolutePath, AgentKind, AgentSession, CheckoutState, + Forest, HttpsGitUrl, LocationWithinForest, NewCheckoutMode, NewKind, NewWorkstream, SpawnSeed, + UpgradeReport, Workstream, WorkstreamId, DOC_SCHEMA_VERSION, }; /// Frontend-agnostic backend for the code you work on and the agent sessions @@ -254,6 +254,12 @@ enum NewModeArg { #[arg(value_name = "SOURCE_HTTPS_URL")] source: String, }, + /// APFS copy-on-write clone of a local dir (source + forest must share an APFS volume). + ApfsCow { + /// Absolute path to the local directory to copy-on-write clone. + #[arg(value_name = "ABSOLUTE_PATH")] + path: String, + }, } impl NewModeArg { @@ -269,6 +275,9 @@ impl NewModeArg { initial_source: HttpsGitUrl::parse(&source)?, } } + NewModeArg::ApfsCow { path } => NewCheckoutMode::ApfsCow { + source_path: AbsolutePath::parse(&path)?, + }, }) } } @@ -281,13 +290,14 @@ struct ModeInfo { mode: String, /// One-line human description. description: String, - /// Whether this mode takes a source seed (has a `source` positional). + /// Whether this mode takes a seed the user must supply (has a positional argument — + /// an HTTPS url or a local path, depending on the mode). requires_source: bool, } /// Metadata for every checkout mode, derived from the `new basic` subcommand tree so the /// clap definitions stay the single source of truth (tag = subcommand name, description = -/// its help, `requires_source` = whether it has the `source` positional). +/// its help, `requires_source` = whether it has a seed positional). fn mode_infos() -> Vec { let basic = NewVariant::augment_subcommands(clap::Command::new("new")); basic @@ -297,9 +307,7 @@ fn mode_infos() -> Vec { .map(|sc| ModeInfo { mode: sc.get_name().to_string(), description: sc.get_about().map(|a| a.to_string()).unwrap_or_default(), - requires_source: sc - .get_positionals() - .any(|a| a.get_id().as_str() == "source"), + requires_source: sc.get_positionals().next().is_some(), }) .collect() } diff --git a/experimental/silverwood/crates/silverwood-cli/tests/cli.rs b/experimental/silverwood/crates/silverwood-cli/tests/cli.rs index a67388c..362fec3 100644 --- a/experimental/silverwood/crates/silverwood-cli/tests/cli.rs +++ b/experimental/silverwood/crates/silverwood-cli/tests/cli.rs @@ -124,6 +124,17 @@ fn new_subcommand_structure_mirrors_the_data_model() { ); assert!(stderr.contains("scheme must be https"), "got: {stderr}"); + // The apfs-cow mode leaf demands its own seed: an `` positional... + let stderr = fails(&dir, &["new", "basic", "apfs-cow"]); + assert!(stderr.contains("ABSOLUTE_PATH"), "got: {stderr}"); + + // ...and it must be absolute — a relative path is rejected before any forest work. + let stderr = fails( + &dir, + &["new", "basic", "apfs-cow", "relative/path", "--name", "x"], + ); + assert!(stderr.contains("absolute"), "got: {stderr}"); + // None of the above created anything. assert_eq!(json(&dir, &["--json", "ls"]), serde_json::json!([])); } diff --git a/experimental/silverwood/crates/silverwood-cli/tests/e2e.rs b/experimental/silverwood/crates/silverwood-cli/tests/e2e.rs index 999634a..3e3f4e0 100644 --- a/experimental/silverwood/crates/silverwood-cli/tests/e2e.rs +++ b/experimental/silverwood/crates/silverwood-cli/tests/e2e.rs @@ -13,6 +13,8 @@ mod common; use std::path::Path; +use tempfile::TempDir; + use common::{create, fails, forest, json, ok, EXAMPLE_SOURCE}; /// `modes` is pure metadata (no network, no forest), so it runs in the sandbox. @@ -34,6 +36,7 @@ fn modes_lists_available_checkout_modes() { tags.contains(&"jj-colocated-direnv-unsafe"), "missing jj-colocated-direnv-unsafe: {tags:?}" ); + assert!(tags.contains(&"apfs-cow"), "missing apfs-cow: {tags:?}"); // Every entry carries a description + requires_source flag. for m in modes.as_array().unwrap() { assert!(m["description"].as_str().is_some_and(|d| !d.is_empty())); @@ -95,6 +98,53 @@ fn new_creates_a_ready_colocated_checkout() { assert_eq!(json(&dir, &["--json", "show", id]), listed[0]); } +/// The apfs-cow mode copy-on-write clones a local directory into the checkout. No +/// network, but it needs a real APFS volume (macOS temp dirs are APFS) and shells out +/// to `/bin/cp -c`, so it is `#[ignore]`d like the rest and must run on macOS. The +/// source temp dir shares the forest's temp volume, so the same-volume precheck passes. +#[test] +#[ignore = "macOS + apfs (cp -c); run via `cargo test -- --ignored`"] +fn new_apfs_cow_creates_a_ready_checkout() { + let dir = forest(); + + // A source directory with a marker file. + let src = TempDir::new().expect("create source dir"); + std::fs::write(src.path().join("marker.txt"), "hello").expect("write marker"); + let src_path = src.path().to_str().unwrap(); + + let ws = json( + &dir, + &[ + "--json", + "new", + "basic", + "apfs-cow", + src_path, + "--name", + "cow-checkout", + ], + ); + + assert_eq!(ws["name"], "cow-checkout"); + assert_eq!(ws["kind"], "basic"); + assert_eq!(ws["mode"]["checkout_mode"], "apfs-cow"); + assert_eq!(ws["mode"]["state"], "ready"); + assert_eq!(ws["mode"]["initial_source"], src_path); + + // The checkout is a copy-on-write clone: it lives under the forest and carries the + // source's marker file. + let location = ws["location"]["within"]["path"].as_str().unwrap(); + let loc = Path::new(location); + assert!( + loc.starts_with(dir.path()), + "checkout not under forest: {location}" + ); + assert_eq!( + std::fs::read_to_string(loc.join("marker.txt")).expect("marker copied into checkout"), + "hello" + ); +} + /// The direnv-unsafe mode produces a ready checkout too. The example repo has no /// `.envrc`, so `direnv allow` is a no-op and provisioning still succeeds; the /// stored `checkout_mode` reflects the chosen mode. (A repo WITH an `.envrc` is diff --git a/experimental/silverwood/crates/silverwood-core/Cargo.toml b/experimental/silverwood/crates/silverwood-core/Cargo.toml index 1eba1a5..d527c4f 100644 --- a/experimental/silverwood/crates/silverwood-core/Cargo.toml +++ b/experimental/silverwood/crates/silverwood-core/Cargo.toml @@ -15,6 +15,7 @@ thiserror.workspace = true loro.workspace = true url.workspace = true time.workspace = true +libc.workspace = true [dev-dependencies] proptest.workspace = true diff --git a/experimental/silverwood/crates/silverwood-core/src/apfs.rs b/experimental/silverwood/crates/silverwood-core/src/apfs.rs new file mode 100644 index 0000000..774235e --- /dev/null +++ b/experimental/silverwood/crates/silverwood-core/src/apfs.rs @@ -0,0 +1,53 @@ +//! macOS APFS probes for the `apfs-cow` checkout mode: whether a path lives on an +//! APFS volume, and whether two paths share one volume. Native copy-on-write +//! (`clonefile(2)`, via `cp -c`) requires both endpoints on a single APFS volume, +//! so the mode's creation precheck consults these before anything is persisted. +//! +//! APFS detection is macOS-only (`statfs(2)`'s `f_fstypename` string is a BSD/macOS +//! field; Linux's `statfs` has a numeric `f_type`), so [`is_apfs`] has a non-macOS +//! stub returning `false` — the mode is unsupported off macOS, and its precheck +//! rejects there. [`same_volume`] is portable std and needs no such split. + +use std::path::Path; + +use crate::error::{Error, Result}; + +/// Whether `path` resides on an APFS volume (always `false` off macOS). +#[cfg(target_os = "macos")] +pub(crate) fn is_apfs(path: &Path) -> Result { + use std::os::unix::ffi::OsStrExt; + + let c_path = std::ffi::CString::new(path.as_os_str().as_bytes()) + .map_err(|e| Error::InvalidSource(format!("{path:?}: path has interior nul: {e}")))?; + + // SAFETY: `c_path` is a valid NUL-terminated C string; `buf` is a zeroed + // repr(C) `statfs` we only read after the call reports success (rc == 0). + let mut buf: libc::statfs = unsafe { std::mem::zeroed() }; + let rc = unsafe { libc::statfs(c_path.as_ptr(), &mut buf) }; + if rc != 0 { + return Err(Error::io(path, std::io::Error::last_os_error())); + } + + // SAFETY: on success `f_fstypename` is a NUL-terminated fs-type name, e.g. "apfs". + let fstype = unsafe { std::ffi::CStr::from_ptr(buf.f_fstypename.as_ptr()) }; + Ok(fstype.to_bytes() == b"apfs") +} + +/// Off macOS there is no APFS: the `apfs-cow` mode is unsupported, so report `false` +/// and let the precheck reject it. +#[cfg(not(target_os = "macos"))] +pub(crate) fn is_apfs(_path: &Path) -> Result { + Ok(false) +} + +/// Whether `a` and `b` live on the same volume (same device id) — a precondition +/// for an APFS copy-on-write clone, which cannot cross volumes. +pub(crate) fn same_volume(a: &Path, b: &Path) -> Result { + use std::os::unix::fs::MetadataExt; + let dev = |p: &Path| { + std::fs::metadata(p) + .map(|m| m.dev()) + .map_err(|e| Error::io(p, e)) + }; + Ok(dev(a)? == dev(b)?) +} diff --git a/experimental/silverwood/crates/silverwood-core/src/doc.rs b/experimental/silverwood/crates/silverwood-core/src/doc.rs index 483352d..4bcb058 100644 --- a/experimental/silverwood/crates/silverwood-core/src/doc.rs +++ b/experimental/silverwood/crates/silverwood-core/src/doc.rs @@ -313,7 +313,8 @@ fn write_mode(basic: &LoroMap, mode: &CheckoutMode) -> Result<()> { .insert_container("mode", LoroMap::new()) .map_err(loro_err)?; map.insert("checkout_mode", mode.tag()).map_err(loro_err)?; - // Both jj-colocated modes carry the same fields; only the tag (above) differs. + // Every mode carries the same two fields (`initial_source` + `state`); only the tag + // (above) differs. match mode { CheckoutMode::JjColocated { initial_source, @@ -322,6 +323,10 @@ fn write_mode(basic: &LoroMap, mode: &CheckoutMode) -> Result<()> { | CheckoutMode::JjColocatedDirenvUnsafe { initial_source, state, + } + | CheckoutMode::ApfsCow { + initial_source, + state, } => { map.insert("initial_source", initial_source.as_str()) .map_err(loro_err)?; @@ -457,6 +462,27 @@ mod tests { assert_eq!(json["mode"]["initial_source"], "https://example.com/x.git"); } + /// The `apfs-cow` mode round-trips through build → hydrate: its `initial_source` + /// holds a local path (not a url) and it projects the `apfs-cow` tag. + #[test] + fn apfs_cow_mode_round_trips() { + let mut body = sample_body(); + body.kind = WorkstreamKind::Basic { + mode: CheckoutMode::ApfsCow { + initial_source: "/Users/x/repo".into(), + state: CheckoutState::Ready, + }, + location: body.location().unwrap().clone(), + }; + + let doc = build(7, &body).unwrap(); + let ws = hydrate(WorkstreamId::generate(), &snapshot(&doc).unwrap()).unwrap(); + assert_eq!(ws.body, body); + let json = serde_json::to_value(&ws).unwrap(); + assert_eq!(json["mode"]["checkout_mode"], "apfs-cow"); + assert_eq!(json["mode"]["initial_source"], "/Users/x/repo"); + } + /// The heart of the CRDT model: two forests editing the same workstream /// concurrently (same kv namespace, different keys; different sessions) must /// converge to the union after exchanging updates — now through the nested diff --git a/experimental/silverwood/crates/silverwood-core/src/forest.rs b/experimental/silverwood/crates/silverwood-core/src/forest.rs index 19adcb9..ed7c2a7 100644 --- a/experimental/silverwood/crates/silverwood-core/src/forest.rs +++ b/experimental/silverwood/crates/silverwood-core/src/forest.rs @@ -8,6 +8,7 @@ use time::OffsetDateTime; use serde::Serialize; +use crate::apfs; use crate::config::ForestConfig; use crate::doc; use crate::docstore::{DocStore, FilesDocStore}; @@ -117,8 +118,15 @@ impl Forest { pub fn create_workstream(&self, new: NewWorkstream) -> Result { let NewKind::Basic { mode: new_mode } = &new.kind; + let working_copies = self.root.join(WORKING_COPIES_DIR); + + // Mode-specific creation preconditions that must reject *before* anything is + // persisted (a hard failure, unlike a provisioning error, which leaves a + // recoverable `Failed` document). Today only apfs-cow has one. + precheck_new_mode(new_mode, &working_copies)?; + let id = WorkstreamId::generate(); - let dest = self.root.join(WORKING_COPIES_DIR).join(id.to_string()); + let dest = working_copies.join(id.to_string()); // The stored mode starts `pending`; core flips it after provisioning. Each // NewCheckoutMode maps to its matching pending CheckoutMode (dest/location and @@ -134,6 +142,10 @@ impl Forest { state: CheckoutState::Pending, } } + NewCheckoutMode::ApfsCow { source_path } => CheckoutMode::ApfsCow { + initial_source: source_path.as_str().to_string(), + state: CheckoutState::Pending, + }, }; // The location records this forest as the single materialization site. @@ -404,6 +416,44 @@ fn ensure_dir(path: &Path) -> Result<()> { fs::create_dir_all(path).map_err(|e| Error::io(path, e)) } +/// Validate a mode's creation preconditions that must reject *before* any document +/// is persisted. A no-op for modes without one. +/// +/// `apfs-cow` requires a native copy-on-write clone, which `clonefile(2)` can only +/// perform within one APFS volume — so the source must be an existing directory, and +/// both it and the forest's checkout location (`working_copies_dir`) must be APFS and +/// share a volume. Any failure is an [`Error::InvalidSource`] (a hard rejection, not a +/// recoverable `Failed` checkout). +fn precheck_new_mode(mode: &NewCheckoutMode, working_copies_dir: &Path) -> Result<()> { + match mode { + NewCheckoutMode::JjColocated { .. } | NewCheckoutMode::JjColocatedDirenvUnsafe { .. } => { + Ok(()) + } + NewCheckoutMode::ApfsCow { source_path } => { + let src = source_path.as_path(); + if !src.is_dir() { + return Err(Error::InvalidSource(format!( + "apfs-cow source {src:?} is not an existing directory" + ))); + } + for path in [src, working_copies_dir] { + if !apfs::is_apfs(path)? { + return Err(Error::InvalidSource(format!( + "apfs-cow requires APFS: {path:?} is not on an APFS volume" + ))); + } + } + if !apfs::same_volume(src, working_copies_dir)? { + return Err(Error::InvalidSource(format!( + "apfs-cow requires one APFS volume: source {src:?} and the forest's \ + checkout location {working_copies_dir:?} are on different volumes" + ))); + } + Ok(()) + } + } +} + /// Reject writes to a core-reserved kv namespace. Reserved namespaces /// (`app.andref.silverwood.*`) hold core-owned state such as sessions; frontends /// mutate them through the typed API, not raw `set_kv`/`unset_kv`. diff --git a/experimental/silverwood/crates/silverwood-core/src/lib.rs b/experimental/silverwood/crates/silverwood-core/src/lib.rs index 230dd90..524bebc 100644 --- a/experimental/silverwood/crates/silverwood-core/src/lib.rs +++ b/experimental/silverwood/crates/silverwood-core/src/lib.rs @@ -10,6 +10,7 @@ //! per workstream — and the basic workstream kind (a checkout mode with //! jj-colocated provisioning + a single-forest location, plus agent sessions). +mod apfs; mod config; mod doc; mod docstore; @@ -32,7 +33,7 @@ pub use forest::{Forest, UpgradeReport}; pub use id::{ForestId, WorkstreamId}; pub use migrate::DOC_SCHEMA_VERSION; pub use provider::{CheckoutProvider, JjColocated}; -pub use source::HttpsGitUrl; +pub use source::{AbsolutePath, HttpsGitUrl}; pub use spawn::{agent_shell_plan, base_shell_plan, ShellPlan, SpawnSeed}; pub use workstream::{ AgentKind, AgentSession, CheckoutMode, CheckoutState, Location, LocationWithinForest, diff --git a/experimental/silverwood/crates/silverwood-core/src/provider.rs b/experimental/silverwood/crates/silverwood-core/src/provider.rs index 669cf27..1ed0836 100644 --- a/experimental/silverwood/crates/silverwood-core/src/provider.rs +++ b/experimental/silverwood/crates/silverwood-core/src/provider.rs @@ -16,20 +16,21 @@ pub trait CheckoutProvider { fn provision(&self, mode: &NewCheckoutMode, dest: &Path) -> Result<()>; } -/// The default provider: clones into a jj/git colocated repository via -/// `jj git clone --colocate`. +/// The default checkout provider, dispatching every [`NewCheckoutMode`]: the +/// jj-colocated modes `jj git clone --colocate` (the direnv-unsafe one also +/// `direnv allow`s the checkout), and `apfs-cow` makes an APFS copy-on-write clone of a +/// local directory. (Named for its original, once-sole mode.) pub struct JjColocated; impl CheckoutProvider for JjColocated { fn provision(&self, mode: &NewCheckoutMode, dest: &Path) -> Result<()> { - // The colocated clone is shared by both modes; the direnv-unsafe mode chains - // `direnv allow` after a successful clone. match mode { NewCheckoutMode::JjColocated { initial_source } => jj_git_clone(initial_source, dest), NewCheckoutMode::JjColocatedDirenvUnsafe { initial_source } => { jj_git_clone(initial_source, dest)?; direnv_allow(dest) } + NewCheckoutMode::ApfsCow { source_path } => apfs_clone(source_path.as_path(), dest), } } } @@ -55,6 +56,28 @@ fn jj_git_clone(initial_source: &HttpsGitUrl, dest: &Path) -> Result<()> { Ok(()) } +/// APFS copy-on-write clone of `source_path` into `dest`: `/bin/cp -Rc ` +/// (BSD `cp`; `-c` uses `clonefile(2)`). The absolute `/bin/cp` avoids a GNU `cp` on +/// PATH, which lacks `-c`. The APFS + same-volume precondition is enforced before +/// creation, so the clone is expected to succeed. +fn apfs_clone(source_path: &Path, dest: &Path) -> Result<()> { + let output = Command::new("/bin/cp") + .arg("-Rc") + .arg(source_path) + .arg(dest) + .output() + .map_err(|e| Error::Provision(format!("spawning cp: {e}")))?; + + if !output.status.success() { + return Err(Error::Provision(format!( + "`cp -Rc` exited {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + ))); + } + Ok(()) +} + /// Pre-approve the checkout's `.envrc`: `direnv allow `. Marks approval only — /// it does not evaluate the `.envrc`. A no-op when the cloned repo has no `.envrc` /// (`direnv allow` errors on a missing file, and there is nothing to approve). diff --git a/experimental/silverwood/crates/silverwood-core/src/source.rs b/experimental/silverwood/crates/silverwood-core/src/source.rs index 987c28b..c8a2ffd 100644 --- a/experimental/silverwood/crates/silverwood-core/src/source.rs +++ b/experimental/silverwood/crates/silverwood-core/src/source.rs @@ -1,5 +1,42 @@ +use std::path::Path; + use crate::error::{Error, Result}; +/// An absolute local directory path a code-change is copy-on-write cloned from +/// (the seed of the `apfs-cow` checkout mode). Validated at construction to be +/// absolute; existence, filesystem type, and same-volume are checked later by the +/// forest (they depend on the checkout's target location). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AbsolutePath(String); + +impl AbsolutePath { + /// Parse and validate an absolute path. Relative paths are rejected. + pub fn parse(input: &str) -> Result { + if !Path::new(input).is_absolute() { + return Err(Error::InvalidSource(format!( + "{input:?}: path must be absolute" + ))); + } + Ok(AbsolutePath(input.to_string())) + } + + /// The validated path as a string. + pub fn as_str(&self) -> &str { + &self.0 + } + + /// The validated path as a [`Path`]. + pub fn as_path(&self) -> &Path { + Path::new(&self.0) + } +} + +impl std::fmt::Display for AbsolutePath { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + /// An HTTPS git endpoint a code-change is cloned from (e.g. a GitHub clone /// URL). Validated at construction: the scheme must be `https` and a host must /// be present. Non-HTTPS forms (`ssh://`, `git@…`) are rejected — per @@ -57,4 +94,18 @@ mod tests { assert!(HttpsGitUrl::parse("http://github.com/x/y.git").is_err()); assert!(HttpsGitUrl::parse("not a url").is_err()); } + + #[test] + fn absolute_path_accepts_absolute() { + let p = AbsolutePath::parse("/Users/x/repo").unwrap(); + assert_eq!(p.as_str(), "/Users/x/repo"); + assert_eq!(p.as_path(), std::path::Path::new("/Users/x/repo")); + } + + #[test] + fn absolute_path_rejects_relative() { + assert!(AbsolutePath::parse("repo").is_err()); + assert!(AbsolutePath::parse("./repo").is_err()); + assert!(AbsolutePath::parse("../repo").is_err()); + } } diff --git a/experimental/silverwood/crates/silverwood-core/src/workstream.rs b/experimental/silverwood/crates/silverwood-core/src/workstream.rs index f2e24d7..2771099 100644 --- a/experimental/silverwood/crates/silverwood-core/src/workstream.rs +++ b/experimental/silverwood/crates/silverwood-core/src/workstream.rs @@ -3,7 +3,7 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; use crate::id::{ForestId, WorkstreamId}; -use crate::source::HttpsGitUrl; +use crate::source::{AbsolutePath, HttpsGitUrl}; /// The `kind` discriminant stored on a basic workstream. pub(crate) const BASIC_KIND: &str = "basic"; @@ -30,9 +30,9 @@ pub enum Status { /// How a workstream's code-change is materialized on disk, together with the data /// that is only meaningful *for that strategy* — its seed (`initial_source`) and its -/// provisioning `state`. A future mode that adopts an existing local directory, for -/// instance, would carry no source and be instantly ready; folding these fields into -/// the variant keeps `Basic` honest. Open, internally-tagged enum. +/// provisioning `state`. The jj-colocated modes clone an HTTPS url; `apfs-cow` instead +/// adopts an existing local directory via an APFS copy-on-write clone. Folding the seed +/// and state into each variant keeps `Basic` honest. Open, internally-tagged enum. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "checkout_mode", rename_all = "kebab-case")] #[non_exhaustive] @@ -54,6 +54,16 @@ pub enum CheckoutMode { /// Provisioning state of the clone (core-owned lifecycle). state: CheckoutState, }, + /// An APFS copy-on-write clone (`clonefile(2)`, via `cp -c`) of a local directory. + /// The source and the forest's checkout location must share an APFS volume (enforced + /// at creation), so the checkout materializes near-instantly and shares storage with + /// the source until either is written. + ApfsCow { + /// The absolute local path the checkout was copy-on-write cloned from. + initial_source: String, + /// Provisioning state of the clone (core-owned lifecycle). + state: CheckoutState, + }, } /// Provisioning state of a per-forest checkout. @@ -122,6 +132,7 @@ impl CheckoutMode { match self { CheckoutMode::JjColocated { .. } => "jj-colocated", CheckoutMode::JjColocatedDirenvUnsafe { .. } => "jj-colocated-direnv-unsafe", + CheckoutMode::ApfsCow { .. } => "apfs-cow", } } @@ -129,15 +140,18 @@ impl CheckoutMode { pub fn state(&self) -> CheckoutState { match self { CheckoutMode::JjColocated { state, .. } - | CheckoutMode::JjColocatedDirenvUnsafe { state, .. } => *state, + | CheckoutMode::JjColocatedDirenvUnsafe { state, .. } + | CheckoutMode::ApfsCow { state, .. } => *state, } } - /// The seed url this checkout was created from (every mode today carries one). + /// The seed this checkout was created from — an HTTPS url or, for `apfs-cow`, a + /// local path (every mode today carries one). pub fn initial_source(&self) -> &str { match self { CheckoutMode::JjColocated { initial_source, .. } - | CheckoutMode::JjColocatedDirenvUnsafe { initial_source, .. } => initial_source, + | CheckoutMode::JjColocatedDirenvUnsafe { initial_source, .. } + | CheckoutMode::ApfsCow { initial_source, .. } => initial_source, } } } @@ -359,6 +373,11 @@ pub enum NewCheckoutMode { /// The HTTPS git url to clone. initial_source: HttpsGitUrl, }, + /// An APFS copy-on-write clone of a local directory (see [`CheckoutMode::ApfsCow`]). + ApfsCow { + /// The absolute local path to copy-on-write clone. + source_path: AbsolutePath, + }, } #[cfg(test)] @@ -397,6 +416,10 @@ mod tests { initial_source: "https://example.com/x.git".into(), state: CheckoutState::Ready, }, + CheckoutMode::ApfsCow { + initial_source: "/Users/x/repo".into(), + state: CheckoutState::Ready, + }, ] { assert_eq!( serde_json::to_value(&mode).unwrap()["checkout_mode"], -- 2.51.2