Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

[dev-dependencies]
tempfile = "3"
Expand Down
72 changes: 72 additions & 0 deletions src/commands/onboarding.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -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?")?
Expand Down Expand Up @@ -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<String>,
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<std::path::PathBuf, OnboardingError> {
worktrees
.iter()
Expand Down
140 changes: 121 additions & 19 deletions src/commands/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -152,10 +152,7 @@ pub struct NewWorkspaceOptions {
/// flag was absent; `Some(vec)` means copy all staged files (empty vec) or
/// only the listed paths (non-empty vec).
pub from_staged: Option<Vec<String>>,
/// Skip workspace creation hooks. Threaded through for forward
/// compatibility; Section 3's hook runner consumes it once merged, so it
/// currently controls nothing (no hook engine exists yet).
#[allow(dead_code)]
/// Skip the repo policy's pre- and post-create hooks.
pub no_hooks: bool,
/// Create the workspace with a detached HEAD instead of a new branch.
pub detach: bool,
Expand Down Expand Up @@ -240,6 +237,21 @@ fn create_workspace(name: &str, opts: &NewWorkspaceOptions) -> Result<PathBuf> {

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(),
Expand Down Expand Up @@ -329,6 +341,20 @@ fn create_workspace(name: &str, opts: &NewWorkspaceOptions) -> Result<PathBuf> {
return Err(WorkspaceError::BaseUnresolvedOffline { base: base.clone() }.into());
}

// 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.
// `--no-hooks` skips them.
if !opts.no_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)?;
}
Expand Down Expand Up @@ -395,18 +421,96 @@ fn create_workspace(name: &str, opts: &NewWorkspaceOptions) -> Result<PathBuf> {
return Err(e.into());
}

// Note: hook gating via opts.no_hooks is consumed by Section 3's hook
// runner once merged; for now the flag is threaded through but controls
// nothing because no hook engine exists yet.
if !opts.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. `--no-hooks`
// skips them.
if !opts.no_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<PathBuf> {
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
),
}
}

/// Copy staged file contents from the current (source) worktree's index into
/// the new workspace at `dest_root`. When `filter` is non-empty, only the
/// listed paths are copied (and any requested path not staged is warned about).
Expand Down Expand Up @@ -1062,7 +1166,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) {
Expand All @@ -1073,15 +1179,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(())
Expand Down
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ mod clipboard;
mod commands;
mod config;
mod git;
mod repo_config;
mod repo_setup;
mod ui;

Expand Down
Loading
Loading