From 8ec0bf85fa27f4daef114461df8e02e2584b3438 Mon Sep 17 00:00:00 2001 From: reckerp Date: Mon, 29 Jun 2026 21:52:16 +0200 Subject: [PATCH 1/4] build: add toml crate for shared repo config parsing The shared repo workspace policy needs real TOML parsing (with a toml::Value pre-pass for forward-compatible version detection) rather than the hand-rolled parser used for the personal profile. Add the `toml` crate as a dependency for that loader. --- Cargo.lock | 1 + Cargo.toml | 1 + 2 files changed, 2 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 293effd..3d4d454 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -621,6 +621,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.17", + "toml", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index c4451d2..d4852d7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ crossterm = "0.29" confy = { version = "2.0.0", features = ["toml_conf"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +toml = "0.9" # The profile that 'dist' will build with [profile.dist] From 081d3393891218f691652631a3dcf59164ba2698 Mon Sep 17 00:00:00 2001 From: reckerp Date: Mon, 29 Jun 2026 21:52:29 +0200 Subject: [PATCH 2/4] feat(workspace): add shared repo workspace config and policy resolver Add an optional, committable repo-level config that lets a team define a shared workspace setup, replacing the personal-only model where repo setup lived outside the repo. New `repo_config` module: - Loads `.gx/workspace.toml` (committable) and `.gx/workspace.local.toml` (git-ignored override) via serde + the `toml` crate, with upward directory discovery so the config is found from nested paths. - Merges config layers into one resolved WorkspacePolicy with the precedence: built-in defaults < global user config < personal profile < shared repo config < local override. copy_files unions across layers (mirroring the existing setup pipeline); scalars replace. - A forward-compatible version pre-pass warns (not panics) on an unknown future `version` and still loads the known subset of fields. - Hook support: pre_create / post_create shell commands with {workspace}, {workspace_path}, {main_root} and {branch} expansion, also exported as GX_* env vars. Hook stdout is redirected to stderr so gx's own stdout stays clean for shell navigation. - Helpers to scaffold `.gx` (config file, `.gitignore`, local override) for the onboarding flow. Expose `repo_setup::stderr_stdio` to `pub(crate)` so hook and repo-config script execution reuse the existing stdout-clean convention. --- src/main.rs | 1 + src/repo_config.rs | 801 +++++++++++++++++++++++++++++++++++++++++++++ src/repo_setup.rs | 2 +- 3 files changed, 803 insertions(+), 1 deletion(-) create mode 100644 src/repo_config.rs diff --git a/src/main.rs b/src/main.rs index 3b9d226..19c05a0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ mod clipboard; mod commands; mod config; mod git; +mod repo_config; mod repo_setup; mod ui; diff --git a/src/repo_config.rs b/src/repo_config.rs new file mode 100644 index 0000000..4c2e991 --- /dev/null +++ b/src/repo_config.rs @@ -0,0 +1,801 @@ +//! Repo-level workspace policy: an optional, committable config that lives in +//! the repository (`.gx/workspace.toml`), an ignored local override +//! (`.gx/workspace.local.toml`), and a resolver that merges these with the +//! global confy config and the personal repo-setup profile into one +//! [`WorkspacePolicy`]. +//! +//! This loader is intentionally separate from `confy` (which owns the global +//! user config) and from the hand-rolled parser in `repo_setup.rs` (which owns +//! the personal profile format). The shared repo config uses real `toml` +//! parsing via serde, with a `toml::Value` pre-pass for forward-compatible +//! version detection. + +use crate::config; +use crate::repo_setup::{self, RepoSetupProfile}; +use miette::{IntoDiagnostic, Result, miette}; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +/// The highest `version` value this build understands. A higher declared +/// version triggers a warning (not a panic); serde ignores unknown fields, so +/// the known subset still loads. +pub const SUPPORTED_VERSION: i64 = 1; + +/// The committable shared config. +pub const SHARED_FILE: &str = "workspace.toml"; +/// The git-ignored local override. +pub const LOCAL_FILE: &str = "workspace.local.toml"; + +/// On-disk shape of `.gx/workspace.toml` / `.gx/workspace.local.toml`. +/// +/// Every field is `Option`/defaulted so the merge can distinguish "unset" +/// (inherit the lower layer) from "set to empty". +#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)] +pub struct RepoConfigFile { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_branch: Option, + + #[serde(default)] + pub workspace: RepoWorkspaceSection, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)] +pub struct RepoWorkspaceSection { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub copy_files: Option>, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub setup_script: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub update_strategy: Option, + + // `clean` and `protection` belong to section 1 (the cleanup lifecycle + // task). They are parsed and carried onto the resolved policy so the schema + // is complete and that task can read them, but this task does not consume + // them. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub clean: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hooks: Option, + + // See note on `clean` above: parsed for the cleanup task, unused here. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub protection: Option, +} + +/// Cleanup settings (consumed by section 1, not this task). +#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)] +pub struct CleanSection { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub threshold_days: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auto: Option, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)] +pub struct HooksSection { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pre_create: Option>, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub post_create: Option>, +} + +/// Branch protection list (consumed by section 1, not this task). +#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)] +pub struct ProtectionSection { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub branches: Option>, +} + +/// The single resolved policy that callers consume. Built by [`resolve`] from +/// all config layers; CLI flags are then applied on top by the caller. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct WorkspacePolicy { + /// Repo-relative globs to copy into a new workspace. Unioned across layers + /// (see [`resolve`]). + pub copy_files: Vec, + + /// A setup script to run after creation, if any. + pub setup_script: Option, + + /// The directory a relative `setup_script` is resolved against. Repo + /// configs resolve against `main_root`; the personal profile resolves + /// against its confy `repos/` dir. Tracking the base disambiguates the + /// two sources after merging. + pub setup_script_base: Option, + + pub update_strategy: Option, + + pub pre_create_hooks: Vec, + pub post_create_hooks: Vec, + + // Carried for section 1 (cleanup lifecycle); unused by this task. + pub clean_threshold_days: Option, + pub clean_auto: Option, + pub protected_branches: Vec, + + pub default_branch: Option, +} + +impl WorkspacePolicy { + /// Absolute path to the configured setup script, resolved against + /// [`Self::setup_script_base`]. Absolute scripts are returned as-is. + pub fn resolved_setup_script(&self) -> Option { + let script = self.setup_script.as_deref()?; + let p = Path::new(script); + if p.is_absolute() { + return Some(p.to_path_buf()); + } + match &self.setup_script_base { + Some(base) => Some(base.join(p)), + None => Some(p.to_path_buf()), + } + } +} + +/// Walk upward from `start` looking for a `.gx/` directory, stopping at the +/// filesystem root. Used so a shared config can be discovered from nested +/// directories within the repo. +pub fn discover_repo_dir(start: &Path) -> Option { + let mut current = Some(start); + while let Some(dir) = current { + let candidate = dir.join(".gx"); + if candidate.is_dir() { + return Some(candidate); + } + current = dir.parent(); + } + None +} + +/// Load and parse a single repo config file. Returns `Ok(None)` when the file +/// does not exist. A declared `version` greater than [`SUPPORTED_VERSION`] +/// warns on stderr but still loads the known subset (serde ignores unknown +/// fields), satisfying the "unknown future version warns, not panics" rule. +pub fn load_file(path: &Path) -> Result> { + if !path.exists() { + return Ok(None); + } + + let content = std::fs::read_to_string(path).into_diagnostic()?; + + // Forward-compatible version pre-pass: read `version` from a loose + // `toml::Value` first so we can warn before strict deserialization. + let value: toml::Value = toml::from_str(&content) + .map_err(|e| miette!("failed to parse {}: {}", path.display(), e))?; + if let Some(version) = value.get("version").and_then(toml::Value::as_integer) + && version > SUPPORTED_VERSION + { + eprintln!( + "warning: {} declares version {}; gx supports up to {}, ignoring unknown fields", + path.display(), + version, + SUPPORTED_VERSION + ); + } + + let parsed: RepoConfigFile = toml::from_str(&content) + .map_err(|e| miette!("failed to parse {}: {}", path.display(), e))?; + Ok(Some(parsed)) +} + +/// Locate `.gx/` under `main_root` and load the shared + local config files. +/// Returns `(shared, local)`, either of which may be `None` when absent. +pub fn load_repo_layers( + main_root: &Path, +) -> Result<(Option, Option)> { + // Anchor on the main worktree root, but use upward discovery so the config + // is still found when `main_root` is itself nested (or a normalized path + // differs slightly). Within a single repo this resolves to `main_root/.gx`. + let gx_dir = discover_repo_dir(main_root).unwrap_or_else(|| main_root.join(".gx")); + let shared = load_file(&gx_dir.join(SHARED_FILE))?; + let local = load_file(&gx_dir.join(LOCAL_FILE))?; + Ok((shared, local)) +} + +/// Merge all config layers into one resolved [`WorkspacePolicy`]. +/// +/// Precedence, lowest to highest: +/// 1. built-in defaults (the seed policy), +/// 2. global confy config (`config.workspace.copy_files`), +/// 3. the personal repo-setup profile (kept as an override source), +/// 4. shared `.gx/workspace.toml`, +/// 5. local `.gx/workspace.local.toml`. +/// +/// CLI flags (the plan's layer 5) are applied by the caller after this returns. +/// +/// Merge semantics differ by field: +/// - `copy_files` is **additive**: each layer's entries are appended onto the +/// accumulated list and deduped, so a repo *adds to* (does not silently erase) +/// personal/global copy files. This mirrors the existing +/// `repo_setup::run_setup_pipeline`, which unions global + personal copy +/// files. +/// - everything else is **replace**: a higher layer with `Some(_)` overrides; +/// `None` inherits the lower layer. +pub fn resolve( + global: &config::Config, + personal: &RepoSetupProfile, + shared: Option<&RepoConfigFile>, + local: Option<&RepoConfigFile>, + main_root: &Path, +) -> WorkspacePolicy { + let mut policy = WorkspacePolicy::default(); + + // Layer 2: global confy config. + let mut copy_files: Vec = global.workspace.copy_files.clone(); + + // Layer 3: personal repo-setup profile (override source per the plan). + copy_files.extend(personal.config.copy_files.iter().cloned()); + if let Some(script) = &personal.config.setup_script { + policy.setup_script = Some(script.clone()); + // The personal profile resolves its relative script against its confy + // `repos/` dir (existing behavior). + policy.setup_script_base = Some(personal.dir.clone()); + } + + // Layers 4 and 5: shared then local repo config. + for layer in [shared, local].into_iter().flatten() { + if let Some(default_branch) = &layer.default_branch { + policy.default_branch = Some(default_branch.clone()); + } + + let ws = &layer.workspace; + + // copy_files: additive union (see doc comment). + if let Some(files) = &ws.copy_files { + copy_files.extend(files.iter().cloned()); + } + + // setup_script: scalar replace. Repo scripts resolve against + // main_root (the plan example uses ".gx/setup-workspace.sh"). + if let Some(script) = &ws.setup_script { + policy.setup_script = Some(script.clone()); + policy.setup_script_base = Some(main_root.to_path_buf()); + } + + if let Some(strategy) = &ws.update_strategy { + policy.update_strategy = Some(strategy.clone()); + } + + if let Some(hooks) = &ws.hooks { + if let Some(pre) = &hooks.pre_create { + policy.pre_create_hooks = pre.clone(); + } + if let Some(post) = &hooks.post_create { + policy.post_create_hooks = post.clone(); + } + } + + // clean / protection: parsed and carried for section 1 (unused here). + if let Some(clean) = &ws.clean { + if let Some(days) = clean.threshold_days { + policy.clean_threshold_days = Some(days); + } + if let Some(auto) = clean.auto { + policy.clean_auto = Some(auto); + } + } + if let Some(protection) = &ws.protection + && let Some(branches) = &protection.branches + { + policy.protected_branches = branches.clone(); + } + } + + dedupe(&mut copy_files); + policy.copy_files = copy_files; + policy +} + +/// Convenience: load the layers under `main_root` and resolve them together +/// with the global config and personal profile. Returns the resolved policy. +pub fn resolve_for_repo(main_root: &Path) -> Result { + let global = config::load()?; + let personal = repo_setup::profile_for_repo(main_root)?; + let (shared, local) = load_repo_layers(main_root)?; + Ok(resolve( + &global, + &personal, + shared.as_ref(), + local.as_ref(), + main_root, + )) +} + +/// Variables available to hook templates. +#[derive(Debug, Clone)] +pub struct HookVars { + /// Workspace directory name. + pub workspace: String, + /// Absolute path to the new workspace. + pub workspace_path: PathBuf, + /// Absolute path to the main worktree. + pub main_root: PathBuf, + /// Checked-out branch. + pub branch: String, +} + +/// Expand `{workspace}`, `{workspace_path}`, `{main_root}` and `{branch}` in a +/// hook template. Unknown `{...}` tokens are left as-is. Multi-pass literal +/// `String::replace`, matching the codebase's `{repo}` substitution style. +pub fn expand_hook(template: &str, vars: &HookVars) -> String { + template + .replace("{workspace_path}", &vars.workspace_path.display().to_string()) + .replace("{workspace}", &vars.workspace) + .replace("{main_root}", &vars.main_root.display().to_string()) + .replace("{branch}", &vars.branch) +} + +/// Run a list of hooks via `sh -c`, with `cwd` as the working directory and the +/// hook variables also exported as `GX_*` environment variables. Each hook's +/// stdout is redirected to stderr so gx's own stdout stays clean (it carries +/// the cd target the shell wrapper consumes). +/// +/// When `abort_on_failure` (pre-create), a non-zero exit returns `Err` so the +/// caller can abort creation. Otherwise (post-create) a failure warns on stderr +/// and continues. +pub fn run_hooks( + hooks: &[String], + vars: &HookVars, + cwd: &Path, + abort_on_failure: bool, +) -> Result<()> { + for hook in hooks { + let expanded = expand_hook(hook, vars); + if expanded.trim().is_empty() { + continue; + } + + let phase = if abort_on_failure { + "pre-create" + } else { + "post-create" + }; + eprintln!("Running {} hook: {}", phase, expanded); + + let stdout = repo_setup::stderr_stdio()?; + let status = Command::new("sh") + .arg("-c") + .arg(&expanded) + .current_dir(cwd) + .env("GX_WORKSPACE", &vars.workspace) + .env("GX_WORKSPACE_PATH", &vars.workspace_path) + .env("GX_MAIN_ROOT", &vars.main_root) + .env("GX_BRANCH", &vars.branch) + .stdin(Stdio::inherit()) + .stdout(stdout) + .stderr(Stdio::inherit()) + .status() + .into_diagnostic()?; + + if !status.success() { + let code = status + .code() + .map(|c| c.to_string()) + .unwrap_or_else(|| "terminated by signal".to_string()); + if abort_on_failure { + return Err(miette!( + "pre-create hook failed (status {}): {}", + code, + expanded + )); + } else { + eprintln!( + "warning: post-create hook failed (status {}); keeping workspace: {}", + code, expanded + ); + } + } + } + + Ok(()) +} + +fn dedupe(values: &mut Vec) { + let mut seen = std::collections::HashSet::new(); + values.retain(|value| seen.insert(value.clone())); +} + +/// Contents written to `.gx/.gitignore`: the local override and transient state +/// must never be committed. +pub const GITIGNORE_CONTENTS: &str = "workspace.local.toml\nstate.toml\ntmp/\n"; + +/// Return `main_root/.gx`, creating the directory if it does not exist. +pub fn ensure_gx_dir(main_root: &Path) -> Result { + let dir = main_root.join(".gx"); + std::fs::create_dir_all(&dir).into_diagnostic()?; + Ok(dir) +} + +/// Write a [`RepoConfigFile`] to `path` as TOML. +pub fn write_config_file(path: &Path, config: &RepoConfigFile) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).into_diagnostic()?; + } + let toml = toml::to_string_pretty(config) + .map_err(|e| miette!("failed to serialize {}: {}", path.display(), e))?; + std::fs::write(path, toml).into_diagnostic()?; + Ok(()) +} + +/// Write `.gx/.gitignore` if it does not already exist. Returns whether it was +/// created. +pub fn ensure_gitignore(gx_dir: &Path) -> Result { + let path = gx_dir.join(".gitignore"); + if path.exists() { + return Ok(false); + } + std::fs::write(&path, GITIGNORE_CONTENTS).into_diagnostic()?; + Ok(true) +} + +/// Create an empty (commented) local override if one does not already exist. +/// Returns whether it was created. +pub fn ensure_local_override(gx_dir: &Path) -> Result { + let path = gx_dir.join(LOCAL_FILE); + if path.exists() { + return Ok(false); + } + std::fs::write( + &path, + "# Local, machine-specific overrides for .gx/workspace.toml.\n\ + # This file is git-ignored and never committed.\n\n\ + [workspace]\n", + ) + .into_diagnostic()?; + Ok(true) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::repo_setup::{RepoSetupConfig, RepoSetupProfile}; + use std::sync::atomic::{AtomicU64, Ordering}; + + static COUNTER: AtomicU64 = AtomicU64::new(0); + + fn temp_dir(label: &str) -> PathBuf { + let n = COUNTER.fetch_add(1, Ordering::SeqCst); + let dir = std::env::temp_dir().join(format!( + "gx-repo-config-{}-{}-{}", + label, + std::process::id(), + n + )); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + fn empty_personal(dir: &Path) -> RepoSetupProfile { + RepoSetupProfile { + dir: dir.to_path_buf(), + config_path: dir.join("config.toml"), + config: RepoSetupConfig::default(), + } + } + + #[test] + fn test_discovered_from_nested_directories() { + let root = temp_dir("nested"); + std::fs::create_dir_all(root.join(".gx")).unwrap(); + let nested = root.join("apps").join("web").join("src"); + std::fs::create_dir_all(&nested).unwrap(); + + let found = discover_repo_dir(&nested).expect("should discover .gx from nested dir"); + assert_eq!(found, root.join(".gx")); + + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn test_discover_returns_none_without_gx_dir() { + let root = temp_dir("no-gx"); + let nested = root.join("a").join("b"); + std::fs::create_dir_all(&nested).unwrap(); + // No .gx anywhere under root, but parents of root may have one; anchor + // the search by checking only that our temp subtree has none. + let found = discover_repo_dir(&nested); + // The search may walk above our temp dir; only assert it does not find + // one *inside* our isolated subtree. + if let Some(found) = found { + assert!(!found.starts_with(&root)); + } + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn test_local_override_wins_over_shared() { + // update_strategy is a scalar -> higher layer (local) replaces shared. + let main_root = temp_dir("override"); + let shared = RepoConfigFile { + version: Some(1), + default_branch: None, + workspace: RepoWorkspaceSection { + update_strategy: Some("rebase".to_string()), + ..Default::default() + }, + }; + let local = RepoConfigFile { + version: Some(1), + default_branch: None, + workspace: RepoWorkspaceSection { + update_strategy: Some("merge".to_string()), + ..Default::default() + }, + }; + let global = config::Config::default(); + let personal = empty_personal(&main_root); + + let policy = resolve( + &global, + &personal, + Some(&shared), + Some(&local), + &main_root, + ); + assert_eq!(policy.update_strategy.as_deref(), Some("merge")); + + std::fs::remove_dir_all(&main_root).ok(); + } + + #[test] + fn test_local_setup_script_overrides_shared() { + let main_root = temp_dir("script-override"); + let shared = RepoConfigFile { + workspace: RepoWorkspaceSection { + setup_script: Some(".gx/shared.sh".to_string()), + ..Default::default() + }, + ..Default::default() + }; + let local = RepoConfigFile { + workspace: RepoWorkspaceSection { + setup_script: Some(".gx/local.sh".to_string()), + ..Default::default() + }, + ..Default::default() + }; + let global = config::Config::default(); + let personal = empty_personal(&main_root); + + let policy = resolve( + &global, + &personal, + Some(&shared), + Some(&local), + &main_root, + ); + assert_eq!(policy.setup_script.as_deref(), Some(".gx/local.sh")); + assert_eq!( + policy.resolved_setup_script(), + Some(main_root.join(".gx/local.sh")) + ); + + std::fs::remove_dir_all(&main_root).ok(); + } + + #[test] + fn test_global_config_works_when_no_repo_config() { + let main_root = temp_dir("global-only"); + let mut global = config::Config::default(); + global.workspace.copy_files = vec![".env".to_string(), ".env.local".to_string()]; + let personal = empty_personal(&main_root); + + let policy = resolve(&global, &personal, None, None, &main_root); + assert_eq!( + policy.copy_files, + vec![".env".to_string(), ".env.local".to_string()] + ); + assert!(policy.setup_script.is_none()); + assert!(policy.pre_create_hooks.is_empty()); + + std::fs::remove_dir_all(&main_root).ok(); + } + + #[test] + fn test_copy_files_union_across_layers() { + let main_root = temp_dir("copy-union"); + let mut global = config::Config::default(); + global.workspace.copy_files = vec![".env".to_string()]; + + let mut personal = empty_personal(&main_root); + personal.config.copy_files = vec!["personal.toml".to_string()]; + + let shared = RepoConfigFile { + workspace: RepoWorkspaceSection { + // includes a duplicate of the global entry to exercise dedupe + copy_files: Some(vec![".env".to_string(), "shared.toml".to_string()]), + ..Default::default() + }, + ..Default::default() + }; + let local = RepoConfigFile { + workspace: RepoWorkspaceSection { + copy_files: Some(vec!["local.toml".to_string()]), + ..Default::default() + }, + ..Default::default() + }; + + let policy = resolve( + &global, + &personal, + Some(&shared), + Some(&local), + &main_root, + ); + assert_eq!( + policy.copy_files, + vec![ + ".env".to_string(), + "personal.toml".to_string(), + "shared.toml".to_string(), + "local.toml".to_string(), + ] + ); + + std::fs::remove_dir_all(&main_root).ok(); + } + + #[test] + fn test_unknown_future_version_warns_not_panics() { + let root = temp_dir("future-version"); + let gx = root.join(".gx"); + std::fs::create_dir_all(&gx).unwrap(); + std::fs::write( + gx.join(SHARED_FILE), + "version = 999\n\n[workspace]\ncopy_files = [\".env\"]\nfuture_field = \"x\"\n", + ) + .unwrap(); + + // Must not panic; deserialization ignores unknown fields. + let loaded = load_file(&gx.join(SHARED_FILE)).expect("should not error"); + let file = loaded.expect("file exists"); + assert_eq!(file.version, Some(999)); + assert_eq!( + file.workspace.copy_files, + Some(vec![".env".to_string()]) + ); + + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn test_load_file_missing_returns_none() { + let root = temp_dir("missing"); + let result = load_file(&root.join(".gx").join(SHARED_FILE)).unwrap(); + assert!(result.is_none()); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn test_load_repo_layers_parses_full_example() { + let main_root = temp_dir("full-example"); + let gx = main_root.join(".gx"); + std::fs::create_dir_all(&gx).unwrap(); + std::fs::write( + gx.join(SHARED_FILE), + r#"version = 1 +default_branch = "main" + +[workspace] +copy_files = [".env.example", ".env.local"] +setup_script = ".gx/setup-workspace.sh" +update_strategy = "rebase" + +[workspace.clean] +threshold_days = 10 +auto = false + +[workspace.hooks] +pre_create = ["test -f package.json"] +post_create = ["pnpm install"] + +[workspace.protection] +branches = ["staging", "release"] +"#, + ) + .unwrap(); + + let (shared, local) = load_repo_layers(&main_root).unwrap(); + assert!(local.is_none()); + let shared = shared.expect("shared config present"); + assert_eq!(shared.version, Some(1)); + assert_eq!(shared.default_branch.as_deref(), Some("main")); + + let global = config::Config::default(); + let personal = empty_personal(&main_root); + let policy = resolve(&global, &personal, Some(&shared), None, &main_root); + + assert_eq!( + policy.pre_create_hooks, + vec!["test -f package.json".to_string()] + ); + assert_eq!(policy.post_create_hooks, vec!["pnpm install".to_string()]); + assert_eq!(policy.update_strategy.as_deref(), Some("rebase")); + assert_eq!(policy.clean_threshold_days, Some(10)); + assert_eq!(policy.clean_auto, Some(false)); + assert_eq!( + policy.protected_branches, + vec!["staging".to_string(), "release".to_string()] + ); + assert_eq!(policy.default_branch.as_deref(), Some("main")); + assert_eq!( + policy.resolved_setup_script(), + Some(main_root.join(".gx/setup-workspace.sh")) + ); + + std::fs::remove_dir_all(&main_root).ok(); + } + + #[test] + fn test_expand_hook_replaces_all_variables() { + let vars = HookVars { + workspace: "feat-x".to_string(), + workspace_path: PathBuf::from("/tmp/ws/feat-x"), + main_root: PathBuf::from("/tmp/main"), + branch: "feat/x".to_string(), + }; + + assert_eq!(expand_hook("{workspace}", &vars), "feat-x"); + assert_eq!(expand_hook("{workspace_path}", &vars), "/tmp/ws/feat-x"); + assert_eq!(expand_hook("{main_root}", &vars), "/tmp/main"); + assert_eq!(expand_hook("{branch}", &vars), "feat/x"); + + // {workspace} must not clobber {workspace_path}. + assert_eq!( + expand_hook("cp {main_root}/.env {workspace_path}/.env", &vars), + "cp /tmp/main/.env /tmp/ws/feat-x/.env" + ); + + // Combined and unknown tokens. + assert_eq!( + expand_hook("echo {workspace} {branch} {unknown}", &vars), + "echo feat-x feat/x {unknown}" + ); + } + + #[test] + fn test_run_hooks_pre_create_aborts_on_failure() { + let cwd = temp_dir("pre-abort"); + let vars = HookVars { + workspace: "w".to_string(), + workspace_path: cwd.clone(), + main_root: cwd.clone(), + branch: "b".to_string(), + }; + let result = run_hooks(&["false".to_string()], &vars, &cwd, true); + assert!(result.is_err(), "pre-create hook failure must abort"); + + // A succeeding pre-create hook returns Ok. + let ok = run_hooks(&["true".to_string()], &vars, &cwd, true); + assert!(ok.is_ok()); + + std::fs::remove_dir_all(&cwd).ok(); + } + + #[test] + fn test_run_hooks_post_create_failure_does_not_abort() { + let cwd = temp_dir("post-warn"); + let vars = HookVars { + workspace: "w".to_string(), + workspace_path: cwd.clone(), + main_root: cwd.clone(), + branch: "b".to_string(), + }; + // Post-create failure only warns; the call still succeeds, so the + // caller keeps the workspace. + let result = run_hooks(&["false".to_string()], &vars, &cwd, false); + assert!(result.is_ok(), "post-create hook failure must not abort"); + + std::fs::remove_dir_all(&cwd).ok(); + } +} diff --git a/src/repo_setup.rs b/src/repo_setup.rs index 05b09cf..a6e17c1 100644 --- a/src/repo_setup.rs +++ b/src/repo_setup.rs @@ -625,7 +625,7 @@ fn run_setup_script( /// stdout clean (it carries the cd target path the shell wrapper consumes). /// Prefers `/dev/stderr`, but falls back to a duplicate of the real stderr fd so /// a missing/unwritable `/dev/stderr` doesn't abort an otherwise-runnable script. -fn stderr_stdio() -> Result { +pub(crate) fn stderr_stdio() -> Result { if let Ok(file) = OpenOptions::new().write(true).open("/dev/stderr") { return Ok(Stdio::from(file)); } From 795c795dc69e5c9e8914436fc351a5abc31a4bf7 Mon Sep 17 00:00:00 2001 From: reckerp Date: Mon, 29 Jun 2026 21:52:37 +0200 Subject: [PATCH 3/4] feat(workspace): apply resolved repo policy on create and setup Wire the resolved WorkspacePolicy into workspace creation and setup so the shared repo config takes effect: - `workspace new` resolves the policy and uses its unioned copy_files, runs pre-create hooks before `git worktree add` (a failure aborts creation and leaves nothing behind), and runs post-create hooks plus the repo-config `.gx` setup script afterward (failures warn but keep the workspace). The repo-config script is skipped when it resolves to the personal profile's script, which run_setup_pipeline already runs, so the same script never runs twice. - `workspace setup` resolves the policy for the repo and applies its copy_files, honoring the shared config too. - Hook execution is gated behind a run_hooks parameter, leaving the seam for section 4's future `--no-hooks` flag. --- src/commands/workspace.rs | 137 ++++++++++++++++++++++++++++++++++---- 1 file changed, 123 insertions(+), 14 deletions(-) diff --git a/src/commands/workspace.rs b/src/commands/workspace.rs index 1a8c052..1e037b7 100644 --- a/src/commands/workspace.rs +++ b/src/commands/workspace.rs @@ -2,7 +2,7 @@ use crate::git::{self, GitError, worktree::Worktree}; use crate::repo_setup::ScriptRun; use crate::ui; use crate::ui::workspace_picker::WorkspaceAction; -use crate::{config, repo_setup}; +use crate::{config, repo_config, repo_setup}; use fuzzy_matcher::skim::SkimMatcherV2; use miette::{Diagnostic, Result}; use std::collections::HashSet; @@ -115,7 +115,9 @@ pub fn run_new( None => (name, branch), }; - let path = create_workspace(&name, base, branch, no_setup)?; + // `run_hooks` is the seam for section 4's `--no-hooks` flag. Until that + // flag is wired in args.rs, hooks always run on create. + let path = create_workspace(&name, base, branch, no_setup, true)?; print_go_path(&path); Ok(()) } @@ -131,7 +133,7 @@ pub fn ensure_workspace_for_branch(branch: &str) -> Result { { return Ok(existing.path.clone()); } - create_workspace(branch, None, None, false) + create_workspace(branch, None, None, false, true) } /// Create a workspace and return its canonical path, with no stdout side @@ -141,6 +143,7 @@ fn create_workspace( base: Option, branch: Option, no_setup: bool, + run_hooks: bool, ) -> Result { validate_name(name)?; @@ -155,6 +158,21 @@ fn create_workspace( let cfg = config::load()?; let main_root = main_worktree_root(&worktrees)?; + + // Resolve the full workspace policy: built-in defaults < global config < + // personal profile < shared .gx/workspace.toml < local override. CLI flags + // (e.g. a future `--no-hooks`) are applied on top by the caller / via the + // `run_hooks` gate below. + let personal = repo_setup::profile_for_repo(&main_root)?; + let (shared, local) = repo_config::load_repo_layers(&main_root)?; + let policy = repo_config::resolve( + &cfg, + &personal, + shared.as_ref(), + local.as_ref(), + &main_root, + ); + let path = workspace_path( &main_root, home_dir().as_deref(), @@ -207,6 +225,19 @@ fn create_workspace( (true, base) }; + // Pre-create hooks run before the worktree is added so a failed check + // (e.g. `test -f package.json`) aborts creation and leaves nothing behind. + // The workspace does not exist yet, so they run from the main worktree. + if run_hooks && !policy.pre_create_hooks.is_empty() { + let vars = repo_config::HookVars { + workspace: dir_name.clone(), + workspace_path: path.clone(), + main_root: main_root.clone(), + branch: branch_name.clone(), + }; + repo_config::run_hooks(&policy.pre_create_hooks, &vars, &main_root, true)?; + } + if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).map_err(WorkspaceError::CreateDirFailed)?; } @@ -249,14 +280,94 @@ fn create_workspace( eprintln!(" {}", path.display()); if !no_setup { - let report = - repo_setup::run_setup_pipeline(&main_root, &path, &cfg.workspace.copy_files, true)?; - print_setup_report(&report, " ", &cfg.workspace.copy_files); + // Use the resolved policy's copy_files so repo-shared/local copy files + // take effect alongside the global and personal sets. The pipeline also + // runs the personal profile's setup_script (unchanged). + let report = repo_setup::run_setup_pipeline(&main_root, &path, &policy.copy_files, true)?; + print_setup_report(&report, " ", &policy.copy_files); + + // The repo-config setup_script (from .gx) is resolved against main_root + // and runs with post-create semantics: a failure warns but keeps the + // workspace. The personal profile's script (run above) is left + // untouched. Avoid running the same script twice when both sources + // happen to point at the same file. + if let Some(repo_script) = repo_config_setup_script(&policy, &personal) { + run_repo_config_setup_script(&repo_script, &path, &main_root); + } + } + + // Post-create hooks run after creation and setup, from inside the new + // workspace. A failure only warns and keeps the workspace. + if run_hooks && !policy.post_create_hooks.is_empty() { + let vars = repo_config::HookVars { + workspace: dir_name.clone(), + workspace_path: path.clone(), + main_root: main_root.clone(), + branch: branch_name.clone(), + }; + repo_config::run_hooks(&policy.post_create_hooks, &vars, &path, false)?; } Ok(path) } +/// The repo-config (`.gx`) setup script to run, if any, distinct from the +/// personal profile's script (which `run_setup_pipeline` already runs). Returns +/// `None` when the policy's script came from the personal profile or when the +/// resolved path does not exist. +fn repo_config_setup_script( + policy: &repo_config::WorkspacePolicy, + personal: &repo_setup::RepoSetupProfile, +) -> Option { + let resolved = policy.resolved_setup_script()?; + + // If the resolved script lives under the personal profile dir, it was + // already run by run_setup_pipeline; don't run it again. + if resolved.starts_with(&personal.dir) { + return None; + } + + if resolved.exists() { Some(resolved) } else { None } +} + +/// Run a repo-config setup script with post-create semantics (warn on failure, +/// keep workspace). Mirrors the env/stdio convention of the personal profile's +/// script runner so stdout stays clean for the cd target. +fn run_repo_config_setup_script(script: &Path, workspace_root: &Path, main_root: &Path) { + let stdout = match repo_setup::stderr_stdio() { + Ok(s) => s, + Err(e) => { + eprintln!(" warning: could not run setup script {}: {}", script.display(), e); + return; + } + }; + + eprintln!(" running setup script {}", script.display()); + let status = std::process::Command::new("sh") + .arg(script) + .current_dir(workspace_root) + .env("GX_WORKSPACE_ROOT", workspace_root) + .env("GX_MAIN_ROOT", main_root) + .stdin(std::process::Stdio::inherit()) + .stdout(stdout) + .stderr(std::process::Stdio::inherit()) + .status(); + + match status { + Ok(status) if status.success() => {} + Ok(status) => eprintln!( + " warning: setup script {} failed with status {}; continuing", + script.display(), + display_exit_status(&status) + ), + Err(e) => eprintln!( + " warning: could not run setup script {}: {}", + script.display(), + e + ), + } +} + /// Resolve a workspace by query (or interactively) and print its path to /// stdout for the shell wrapper to cd into. pub fn run_go(query: Option) -> Result<()> { @@ -666,7 +777,9 @@ fn setup_worktrees(worktrees_to_setup: &[Worktree], all_worktrees: &[Worktree]) } let main_root = main_worktree_root(all_worktrees)?; - let cfg = config::load()?; + // `gx workspace setup` applies the configured policy, so it honors the + // shared repo config too. Hooks are only run on create, not setup. + let policy = repo_config::resolve_for_repo(&main_root)?; for target in &targets { if paths_equal(&main_root, &target.path) { @@ -677,15 +790,11 @@ fn setup_worktrees(worktrees_to_setup: &[Worktree], all_worktrees: &[Worktree]) continue; } - let report = repo_setup::run_setup_pipeline( - &main_root, - &target.path, - &cfg.workspace.copy_files, - true, - )?; + let report = + repo_setup::run_setup_pipeline(&main_root, &target.path, &policy.copy_files, true)?; eprintln!("Setup for '{}':", target.name); - print_setup_report(&report, " ", &cfg.workspace.copy_files); + print_setup_report(&report, " ", &policy.copy_files); } Ok(()) From c5b1a57d1d8057f45f5920aa715fea237ddcfb8d Mon Sep 17 00:00:00 2001 From: reckerp Date: Mon, 29 Jun 2026 21:52:47 +0200 Subject: [PATCH 4/4] feat(onboarding): let setup be saved as shared repo config `gx onboarding` now asks where the setup should live: personal-only (the existing behavior, kept for secrets and machine-specific scripts) or shared repo config. Choosing shared writes `.gx/workspace.toml`, a `.gx/.gitignore`, an optional `.gx/setup-workspace.sh`, and an optional `.gx/workspace.local.toml` override, giving a team a committable default while still supporting personal overrides. --- src/commands/onboarding.rs | 72 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/src/commands/onboarding.rs b/src/commands/onboarding.rs index 76b26a8..7fd3567 100644 --- a/src/commands/onboarding.rs +++ b/src/commands/onboarding.rs @@ -1,4 +1,5 @@ use crate::git::{self, GitError, worktree::Worktree}; +use crate::repo_config::{self, RepoConfigFile, RepoWorkspaceSection}; use crate::repo_setup; use crate::ui; use miette::{Diagnostic, Result}; @@ -35,6 +36,26 @@ pub fn run() -> Result<()> { profile.config.copy_files = copy_files; + // Where should this setup live? `ui::confirm` is yes/no, so ask + // sequentially. The three outcomes map to the plan's Personal / Shared / + // Shared+local choices. + eprintln!(); + eprintln!("Where should this setup be saved?"); + eprintln!(" - Shared repo config is committable (.gx/workspace.toml) and gives the team a default."); + eprintln!(" - Personal config stays on this machine (good for secrets and local-only scripts)."); + let shared = ui::confirm::run("Save as shared repo config (.gx/workspace.toml)?")?; + + if shared { + let with_local = ui::confirm::run("Also create a local override (.gx/workspace.local.toml)?")?; + save_shared(&main_root, profile.config.copy_files.clone(), with_local) + } else { + save_personal(profile) + } +} + +/// Existing personal-only save path: persist the profile under confy, optionally +/// authoring a setup script. Unchanged behavior. +fn save_personal(mut profile: repo_setup::RepoSetupProfile) -> Result<()> { let has_script = profile.config.setup_script.is_some(); let wants_script = if has_script { ui::confirm::run("Edit existing setup script?")? @@ -63,6 +84,57 @@ pub fn run() -> Result<()> { Ok(()) } +/// Shared save path: write `.gx/workspace.toml` (and `.gx/.gitignore`, plus an +/// optional local override) under the main worktree root, optionally authoring +/// a setup script at `.gx/setup-workspace.sh`. +fn save_shared( + main_root: &std::path::Path, + copy_files: Vec, + with_local: bool, +) -> Result<()> { + let gx_dir = repo_config::ensure_gx_dir(main_root)?; + + let setup_script = { + let wants_script = ui::confirm::run("Define a setup script (.gx/setup-workspace.sh)?")?; + if wants_script { + let script_path = gx_dir.join("setup-workspace.sh"); + repo_setup::create_default_setup_script(&script_path)?; + repo_setup::open_in_editor(&script_path)?; + // Stored relative to main_root, matching the plan example. + Some(".gx/setup-workspace.sh".to_string()) + } else { + None + } + }; + + let config = RepoConfigFile { + version: Some(repo_config::SUPPORTED_VERSION), + default_branch: None, + workspace: RepoWorkspaceSection { + copy_files: Some(copy_files), + setup_script, + ..Default::default() + }, + }; + + let shared_path = gx_dir.join(repo_config::SHARED_FILE); + repo_config::write_config_file(&shared_path, &config)?; + eprintln!("Saved shared repo config: {}", shared_path.display()); + + if repo_config::ensure_gitignore(&gx_dir)? { + eprintln!("Wrote {}", gx_dir.join(".gitignore").display()); + } + + if with_local && repo_config::ensure_local_override(&gx_dir)? { + eprintln!( + "Created local override: {}", + gx_dir.join(repo_config::LOCAL_FILE).display() + ); + } + + Ok(()) +} + fn main_worktree_root(worktrees: &[Worktree]) -> Result { worktrees .iter()