diff --git a/experimental/silverwood/crates/silverwood-cli/src/main.rs b/experimental/silverwood/crates/silverwood-cli/src/main.rs index 71aae27..48039b7 100644 --- a/experimental/silverwood/crates/silverwood-cli/src/main.rs +++ b/experimental/silverwood/crates/silverwood-cli/src/main.rs @@ -12,9 +12,8 @@ use std::str::FromStr; use clap::{Parser, Subcommand, ValueEnum}; use silverwood_core::{ - AgentKind, AgentSession, CheckoutMode, Forest, HttpsGitUrl, LocationWithinForest, - NewCheckoutMode, NewKind, NewWorkstream, UpgradeReport, Workstream, WorkstreamId, - DOC_SCHEMA_VERSION, + AgentKind, AgentSession, Forest, HttpsGitUrl, LocationWithinForest, NewCheckoutMode, NewKind, + NewWorkstream, UpgradeReport, Workstream, WorkstreamId, DOC_SCHEMA_VERSION, }; /// Frontend-agnostic backend for the code you work on and the agent sessions @@ -93,6 +92,9 @@ enum Command { #[arg(long)] dry_run: bool, }, + + /// List the available checkout modes for `new --mode`. + Modes, } /// Positional args are shared across kv subcommands: ` [KEY] [VALUE]`. @@ -215,6 +217,19 @@ enum SessionCreate { enum ModeArg { #[value(name = "jj-colocated")] JjColocated, + #[value(name = "jj-colocated-direnv-unsafe")] + JjColocatedDirenvUnsafe, +} + +/// A checkout mode's metadata, for `silverwood modes` (drives a frontend picker). +#[derive(serde::Serialize)] +struct ModeInfo { + /// The kebab tag passed to `new --mode` (matches the stored `checkout_mode`). + mode: String, + /// One-line human description. + description: &'static str, + /// Whether this mode requires `--source` (all modes today do). + requires_source: bool, } impl ModeArg { @@ -222,8 +237,46 @@ impl ModeArg { fn to_new_mode(self, initial_source: HttpsGitUrl) -> NewCheckoutMode { match self { ModeArg::JjColocated => NewCheckoutMode::JjColocated { initial_source }, + ModeArg::JjColocatedDirenvUnsafe => { + NewCheckoutMode::JjColocatedDirenvUnsafe { initial_source } + } + } + } + + /// One-line description of the mode (kept in sync with the variants by the + /// exhaustive match — a new variant forces filling this in). + fn description(self) -> &'static str { + match self { + ModeArg::JjColocated => "jj/git colocated clone (`jj git clone --colocate`).", + ModeArg::JjColocatedDirenvUnsafe => { + "jj-colocated clone, then `direnv allow` on the checkout (pre-approves .envrc; unsafe)." + } } } + + /// Whether `new --mode ` requires `--source` (all modes today do). + fn requires_source(self) -> bool { + match self { + ModeArg::JjColocated | ModeArg::JjColocatedDirenvUnsafe => true, + } + } + + /// Metadata for every mode, in declaration order. The `mode` tag is sourced + /// from clap's `#[value(name=...)]` so it stays the single source of truth. + fn all_infos() -> Vec { + Self::value_variants() + .iter() + .map(|m| ModeInfo { + mode: m + .to_possible_value() + .expect("no ModeArg variant is value-skipped") + .get_name() + .to_string(), + description: m.description(), + requires_source: m.requires_source(), + }) + .collect() + } } fn main() -> ExitCode { @@ -245,6 +298,19 @@ fn run(cli: Cli) -> CliResult { None => resolve_forest_dir()?, }; let json = cli.json; + + // `modes` is pure metadata — handle it before opening (and thereby creating) + // the forest, so listing modes never touches `$HOME/.silverwood`. + if let Command::Modes = cli.command { + let modes = ModeArg::all_infos(); + emit(json, &modes, || { + for m in &modes { + println!("{:28} {}", m.mode, m.description); + } + }); + return Ok(()); + } + let forest = Forest::open(&root)?; match cli.command { @@ -307,6 +373,9 @@ fn run(cli: Cli) -> CliResult { emit(json, &reports, || print_upgrade(&reports, dry_run)); Ok(()) } + + // Handled above, before the forest is opened. + Command::Modes => unreachable!("modes is handled before Forest::open"), } } @@ -452,9 +521,7 @@ fn print_workstream(ws: &Workstream) { println!(" kind: {}", ws.body.kind.tag()); if let Some(mode) = ws.body.mode() { println!(" mode: {} [{}]", mode.tag(), enum_str(mode.state())); - if let CheckoutMode::JjColocated { initial_source, .. } = mode { - println!(" source: {initial_source}"); - } + println!(" source: {}", mode.initial_source()); } println!(" created: {}", ws.body.created_at); if let Some(location) = ws.body.location() { diff --git a/experimental/silverwood/crates/silverwood-cli/tests/e2e.rs b/experimental/silverwood/crates/silverwood-cli/tests/e2e.rs index adf8c2b..05dba8c 100644 --- a/experimental/silverwood/crates/silverwood-cli/tests/e2e.rs +++ b/experimental/silverwood/crates/silverwood-cli/tests/e2e.rs @@ -15,6 +15,32 @@ use std::path::Path; use common::{create, fails, forest, json, ok, EXAMPLE_SOURCE}; +/// `modes` is pure metadata (no network, no forest), so it runs in the sandbox. +#[test] +fn modes_lists_available_checkout_modes() { + let dir = forest(); + let modes = json(&dir, &["--json", "modes"]); + let tags: Vec<&str> = modes + .as_array() + .expect("modes is an array") + .iter() + .map(|m| m["mode"].as_str().expect("mode tag is a string")) + .collect(); + assert!( + tags.contains(&"jj-colocated"), + "missing jj-colocated: {tags:?}" + ); + assert!( + tags.contains(&"jj-colocated-direnv-unsafe"), + "missing jj-colocated-direnv-unsafe: {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())); + assert!(m["requires_source"].is_boolean()); + } +} + #[test] #[ignore = "network + jj; run via `cargo test -- --ignored`"] fn new_creates_a_ready_colocated_checkout() { @@ -70,6 +96,33 @@ fn new_creates_a_ready_colocated_checkout() { assert_eq!(json(&dir, &["--json", "show", id]), listed[0]); } +/// 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 +/// needed to observe the actual approval — see the manual E2E in the plan.) +#[test] +#[ignore = "network + jj; run via `cargo test -- --ignored`"] +fn new_direnv_unsafe_mode_is_ready() { + let dir = forest(); + let ws = json( + &dir, + &[ + "--json", + "new", + "--name", + "with-direnv", + "--source", + EXAMPLE_SOURCE, + "--mode", + "jj-colocated-direnv-unsafe", + ], + ); + assert_eq!(ws["mode"]["checkout_mode"], "jj-colocated-direnv-unsafe"); + assert_eq!(ws["mode"]["state"], "ready"); + let loc = ws["location"]["within"]["path"].as_str().unwrap(); + assert!(Path::new(loc).join(".jj").is_dir(), ".jj missing"); +} + #[test] #[ignore = "network + jj; run via `cargo test -- --ignored`"] fn kv_and_session_lifecycle() { diff --git a/experimental/silverwood/crates/silverwood-core/src/doc.rs b/experimental/silverwood/crates/silverwood-core/src/doc.rs index dea654b..483352d 100644 --- a/experimental/silverwood/crates/silverwood-core/src/doc.rs +++ b/experimental/silverwood/crates/silverwood-core/src/doc.rs @@ -313,10 +313,15 @@ 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. match mode { CheckoutMode::JjColocated { initial_source, state, + } + | CheckoutMode::JjColocatedDirenvUnsafe { + initial_source, + state, } => { map.insert("initial_source", initial_source.as_str()) .map_err(loro_err)?; @@ -430,6 +435,28 @@ mod tests { assert_eq!(ws.body.sessions()["sid-1"].name, "planning"); } + /// The `jj-colocated-direnv-unsafe` mode round-trips through build → hydrate and + /// projects the expected flat `checkout_mode` tag (it carries the same fields as + /// `jj-colocated`, so `write_mode`/serde must distinguish it by tag alone). + #[test] + fn direnv_unsafe_mode_round_trips() { + let mut body = sample_body(); + body.kind = WorkstreamKind::Basic { + mode: CheckoutMode::JjColocatedDirenvUnsafe { + initial_source: "https://example.com/x.git".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"], "jj-colocated-direnv-unsafe"); + assert_eq!(json["mode"]["initial_source"], "https://example.com/x.git"); + } + /// 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 a716d2b..19adcb9 100644 --- a/experimental/silverwood/crates/silverwood-core/src/forest.rs +++ b/experimental/silverwood/crates/silverwood-core/src/forest.rs @@ -116,22 +116,33 @@ impl Forest { /// document persists with state `Failed`) and surfaces the error. pub fn create_workstream(&self, new: NewWorkstream) -> Result { let NewKind::Basic { mode: new_mode } = &new.kind; - let NewCheckoutMode::JjColocated { initial_source } = new_mode; let id = WorkstreamId::generate(); let dest = self.root.join(WORKING_COPIES_DIR).join(id.to_string()); - // The stored mode starts `pending`; core flips it after provisioning. The - // location records this forest as the single materialization site. + // The stored mode starts `pending`; core flips it after provisioning. Each + // NewCheckoutMode maps to its matching pending CheckoutMode (dest/location and + // the provision call below are mode-independent). + let mode = match new_mode { + NewCheckoutMode::JjColocated { initial_source } => CheckoutMode::JjColocated { + initial_source: initial_source.as_str().to_string(), + state: CheckoutState::Pending, + }, + NewCheckoutMode::JjColocatedDirenvUnsafe { initial_source } => { + CheckoutMode::JjColocatedDirenvUnsafe { + initial_source: initial_source.as_str().to_string(), + state: CheckoutState::Pending, + } + } + }; + + // The location records this forest as the single materialization site. let body = WorkstreamBody { name: new.name, status: Status::Active, created_at: now_rfc3339(), kind: WorkstreamKind::Basic { - mode: CheckoutMode::JjColocated { - initial_source: initial_source.as_str().to_string(), - state: CheckoutState::Pending, - }, + mode, location: Location { forest_id: self.id(), within: LocationWithinForest::BasicForest { diff --git a/experimental/silverwood/crates/silverwood-core/src/provider.rs b/experimental/silverwood/crates/silverwood-core/src/provider.rs index b93838c..669cf27 100644 --- a/experimental/silverwood/crates/silverwood-core/src/provider.rs +++ b/experimental/silverwood/crates/silverwood-core/src/provider.rs @@ -2,6 +2,7 @@ use std::path::Path; use std::process::Command; use crate::error::{Error, Result}; +use crate::source::HttpsGitUrl; use crate::workstream::NewCheckoutMode; /// Materializes a checkout on disk for a chosen [`NewCheckoutMode`]. @@ -21,25 +22,59 @@ pub struct JjColocated; impl CheckoutProvider for JjColocated { fn provision(&self, mode: &NewCheckoutMode, dest: &Path) -> Result<()> { - // Exhaustive today; a new mode forces this provider to handle it. - let NewCheckoutMode::JjColocated { initial_source } = mode; - - let output = Command::new("jj") - .arg("git") - .arg("clone") - .arg("--colocate") - .arg(initial_source.as_str()) - .arg(dest) - .output() - .map_err(|e| Error::Provision(format!("spawning jj: {e}")))?; - - if !output.status.success() { - return Err(Error::Provision(format!( - "`jj git clone --colocate` exited {}: {}", - output.status, - String::from_utf8_lossy(&output.stderr).trim() - ))); + // 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) + } } - Ok(()) } } + +/// `jj git clone --colocate ` (shared by both jj-colocated modes). +fn jj_git_clone(initial_source: &HttpsGitUrl, dest: &Path) -> Result<()> { + let output = Command::new("jj") + .arg("git") + .arg("clone") + .arg("--colocate") + .arg(initial_source.as_str()) + .arg(dest) + .output() + .map_err(|e| Error::Provision(format!("spawning jj: {e}")))?; + + if !output.status.success() { + return Err(Error::Provision(format!( + "`jj git clone --colocate` 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). +fn direnv_allow(dest: &Path) -> Result<()> { + if !dest.join(".envrc").exists() { + return Ok(()); + } + + let output = Command::new("direnv") + .arg("allow") + .arg(dest) + .output() + .map_err(|e| Error::Provision(format!("spawning direnv: {e}")))?; + + if !output.status.success() { + return Err(Error::Provision(format!( + "`direnv allow` exited {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + ))); + } + Ok(()) +} diff --git a/experimental/silverwood/crates/silverwood-core/src/workstream.rs b/experimental/silverwood/crates/silverwood-core/src/workstream.rs index 0a87373..f2e24d7 100644 --- a/experimental/silverwood/crates/silverwood-core/src/workstream.rs +++ b/experimental/silverwood/crates/silverwood-core/src/workstream.rs @@ -32,7 +32,7 @@ pub enum Status { /// 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: one mode today. +/// the 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] @@ -44,6 +44,16 @@ pub enum CheckoutMode { /// Provisioning state of the clone (core-owned lifecycle). state: CheckoutState, }, + /// As [`CheckoutMode::JjColocated`], but after a successful clone `direnv allow` + /// is run on the checkout so its `.envrc` is pre-approved. "Unsafe": pre-approval + /// lets the `.envrc` run arbitrary shell on later direnv loads — the caller opts + /// into trusting the cloned repo. + JjColocatedDirenvUnsafe { + /// The HTTPS git url the checkout was cloned from. + initial_source: String, + /// Provisioning state of the clone (core-owned lifecycle). + state: CheckoutState, + }, } /// Provisioning state of a per-forest checkout. @@ -111,13 +121,23 @@ impl CheckoutMode { pub fn tag(&self) -> &'static str { match self { CheckoutMode::JjColocated { .. } => "jj-colocated", + CheckoutMode::JjColocatedDirenvUnsafe { .. } => "jj-colocated-direnv-unsafe", } } /// The provisioning state of this checkout. pub fn state(&self) -> CheckoutState { match self { - CheckoutMode::JjColocated { state, .. } => *state, + CheckoutMode::JjColocated { state, .. } + | CheckoutMode::JjColocatedDirenvUnsafe { state, .. } => *state, + } + } + + /// The seed url this checkout was created from (every mode today carries one). + pub fn initial_source(&self) -> &str { + match self { + CheckoutMode::JjColocated { initial_source, .. } + | CheckoutMode::JjColocatedDirenvUnsafe { initial_source, .. } => initial_source, } } } @@ -333,6 +353,12 @@ pub enum NewCheckoutMode { /// The HTTPS git url to clone. initial_source: HttpsGitUrl, }, + /// As [`NewCheckoutMode::JjColocated`], plus `direnv allow` on the checkout after + /// a successful clone (pre-approves its `.envrc`; see [`CheckoutMode`] docs). + JjColocatedDirenvUnsafe { + /// The HTTPS git url to clone. + initial_source: HttpsGitUrl, + }, } #[cfg(test)] @@ -362,14 +388,21 @@ mod tests { ); } // Data-carrying enums: the internally-tagged discriminant must match tag(). - let mode = CheckoutMode::JjColocated { - initial_source: "https://example.com/x.git".into(), - state: CheckoutState::Ready, - }; - assert_eq!( - serde_json::to_value(&mode).unwrap()["checkout_mode"], - serde_json::json!(mode.tag()) - ); + for mode in [ + CheckoutMode::JjColocated { + initial_source: "https://example.com/x.git".into(), + state: CheckoutState::Ready, + }, + CheckoutMode::JjColocatedDirenvUnsafe { + initial_source: "https://example.com/x.git".into(), + state: CheckoutState::Ready, + }, + ] { + assert_eq!( + serde_json::to_value(&mode).unwrap()["checkout_mode"], + serde_json::json!(mode.tag()) + ); + } let within = LocationWithinForest::BasicForest { path: "/tmp/x".into(), }; diff --git a/experimental/silverwood/flake.nix b/experimental/silverwood/flake.nix index 1546891..3149028 100644 --- a/experimental/silverwood/flake.nix +++ b/experimental/silverwood/flake.nix @@ -64,8 +64,9 @@ } ); - # The shipped package: the binary with jujutsu + git on PATH, since - # checkout provisioning (`jj git clone --colocate`) shells out to them. + # The shipped package: the binary with jujutsu + git + direnv on PATH, since + # checkout provisioning shells out to them (`jj git clone --colocate`, and + # `direnv allow` for the jj-colocated-direnv-unsafe mode). silverwood = pkgs.runCommand "silverwood" { nativeBuildInputs = [ pkgs.makeWrapper ]; @@ -77,6 +78,7 @@ --prefix PATH : ${lib.makeBinPath [ pkgs.jujutsu pkgs.git + pkgs.direnv ]} ''; in @@ -132,11 +134,12 @@ }; devShells.default = craneLib.devShell { - # cargo + rustc + clippy + rustfmt come from craneLib; add jj/git (Part 1 - # provisioning) and taplo (toml formatting used by the toml-fmt check). + # cargo + rustc + clippy + rustfmt come from craneLib; add jj/git + direnv + # (provisioning shells out to them) and taplo (toml formatting check). packages = [ pkgs.jujutsu pkgs.git + pkgs.direnv pkgs.taplo ]; };