From f308eea6c654353a718e3c6e5304006f8878fc73 Mon Sep 17 00:00:00 2001 From: Layne Penney Date: Wed, 8 Apr 2026 11:29:14 -0500 Subject: [PATCH 01/15] Sprint 13: integrate gr2 team-workspace foundation (#509) --- Cargo.lock | 9 + Cargo.toml | 8 + gr2/Cargo.toml | 13 ++ gr2/src/args.rs | 85 ++++++++ gr2/src/dispatch.rs | 239 +++++++++++++++++++++ gr2/src/lib.rs | 6 + src/bin/gr2.rs | 22 ++ tests/cli_tests.rs | 513 ++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 895 insertions(+) create mode 100644 gr2/Cargo.toml create mode 100644 gr2/src/args.rs create mode 100644 gr2/src/dispatch.rs create mode 100644 gr2/src/lib.rs create mode 100644 src/bin/gr2.rs diff --git a/Cargo.lock b/Cargo.lock index d273c8a9..31da8d59 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -826,6 +826,7 @@ dependencies = [ "futures", "git2", "gix", + "gr2-cli", "indicatif", "octocrab", "once_cell", @@ -1586,6 +1587,14 @@ dependencies = [ "gix-validate 0.9.4", ] +[[package]] +name = "gr2-cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", +] + [[package]] name = "h2" version = "0.4.13" diff --git a/Cargo.toml b/Cargo.toml index 4d178366..aaa078cf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,9 @@ readme = "README.md" keywords = ["git", "monorepo", "workflow", "multi-repo", "cli"] categories = ["development-tools", "command-line-utilities"] +[workspace] +members = ["gr2"] + [[bin]] name = "gr" path = "src/main.rs" @@ -21,6 +24,10 @@ path = "src/main.rs" name = "gitgrip" path = "src/main.rs" +[[bin]] +name = "gr2" +path = "src/bin/gr2.rs" + [lib] name = "gitgrip" path = "src/lib.rs" @@ -40,6 +47,7 @@ release-logs = ["tracing/release_max_level_info"] max-perf = ["tracing/max_level_off"] [dependencies] +gr2_cli = { package = "gr2-cli", path = "gr2" } # Async runtime tokio = { version = "1", features = ["rt-multi-thread", "macros", "process", "time", "net", "io-util", "sync"] } uuid = { version = "1", features = ["v4"] } diff --git a/gr2/Cargo.toml b/gr2/Cargo.toml new file mode 100644 index 00000000..b8b7067f --- /dev/null +++ b/gr2/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "gr2-cli" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +name = "gr2_cli" +path = "src/lib.rs" + +[dependencies] +anyhow = "1" +clap = { version = "4", features = ["derive"] } diff --git a/gr2/src/args.rs b/gr2/src/args.rs new file mode 100644 index 00000000..f4f5916d --- /dev/null +++ b/gr2/src/args.rs @@ -0,0 +1,85 @@ +use clap::{Parser, Subcommand}; + +#[derive(Parser, Debug)] +#[command( + name = "gr2", + about = "Clean-break gitgrip CLI for clone-backed team workspaces", + long_about = "gr2 is the clean-break gitgrip CLI for the new team-workspace, cache, and checkout model.", + version, + arg_required_else_help = true +)] +pub struct Cli { + /// Enable verbose logging + #[arg(short, long)] + pub verbose: bool, + + #[command(subcommand)] + pub command: Commands, +} + +#[derive(Subcommand, Debug)] +pub enum Commands { + /// Initialize a new team workspace root + Init { + /// Path to create the workspace in + path: String, + + /// Optional logical workspace name + #[arg(long)] + name: Option, + }, + + /// Verify the gr2 bootstrap binary is wired correctly + Doctor, + + /// Team workspace operations + Team { + #[command(subcommand)] + command: TeamCommands, + }, + + /// Repo registry operations + Repo { + #[command(subcommand)] + command: RepoCommands, + }, +} + +#[derive(Subcommand, Debug)] +pub enum TeamCommands { + /// Register an agent workspace under agents/ + Add { + /// Agent workspace name + name: String, + }, + + /// List registered agent workspaces + List, + + /// Remove a registered agent workspace + Remove { + /// Agent workspace name + name: String, + }, +} + +#[derive(Subcommand, Debug)] +pub enum RepoCommands { + /// Register a repo in the team workspace + Add { + /// Logical repo name + name: String, + + /// Canonical remote URL + url: String, + }, + + /// List registered repos + List, + + /// Remove a registered repo + Remove { + /// Logical repo name + name: String, + }, +} diff --git a/gr2/src/dispatch.rs b/gr2/src/dispatch.rs new file mode 100644 index 00000000..29273f4d --- /dev/null +++ b/gr2/src/dispatch.rs @@ -0,0 +1,239 @@ +use anyhow::Result; +use std::fs; +use std::path::PathBuf; + +use crate::args::{Commands, RepoCommands, TeamCommands}; + +pub async fn dispatch_command(command: Commands, verbose: bool) -> Result<()> { + match command { + Commands::Init { path, name } => { + let workspace_root = PathBuf::from(path); + let workspace_name = name.unwrap_or_else(|| { + workspace_root + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| "workspace".to_string()) + }); + + if workspace_root.exists() { + anyhow::bail!( + "workspace path already exists: {}", + workspace_root.display() + ); + } + + fs::create_dir_all(workspace_root.join(".grip"))?; + fs::create_dir_all(workspace_root.join("config"))?; + fs::create_dir_all(workspace_root.join("agents"))?; + fs::create_dir_all(workspace_root.join("repos"))?; + + let workspace_toml = format!( + "version = 2\nname = \"{}\"\nlayout = \"team-workspace\"\n", + workspace_name + ); + fs::write(workspace_root.join(".grip/workspace.toml"), workspace_toml)?; + + println!( + "Initialized gr2 team workspace '{}' at {}", + workspace_name, + workspace_root.display() + ); + Ok(()) + } + Commands::Doctor => { + if verbose { + println!("gr2 bootstrap OK (verbose)"); + } else { + println!("gr2 bootstrap OK"); + } + Ok(()) + } + Commands::Team { command } => match command { + TeamCommands::Add { name } => { + let workspace_root = require_workspace_root()?; + + let agent_root = workspace_root.join("agents").join(&name); + if agent_root.exists() { + anyhow::bail!("agent '{}' already exists", name); + } + + fs::create_dir_all(&agent_root)?; + fs::write( + agent_root.join("agent.toml"), + format!("name = \"{}\"\nkind = \"agent-workspace\"\n", name), + )?; + + println!("Added gr2 agent workspace '{}'", name); + Ok(()) + } + TeamCommands::List => { + let workspace_root = require_workspace_root()?; + let agents_root = workspace_root.join("agents"); + + let mut names = Vec::new(); + for entry in fs::read_dir(&agents_root)? { + let entry = entry?; + if entry.file_type()?.is_dir() && entry.path().join("agent.toml").exists() { + names.push(entry.file_name().to_string_lossy().into_owned()); + } + } + + names.sort(); + + if names.is_empty() { + println!("No gr2 agent workspaces registered."); + } else { + println!("Agent workspaces"); + for name in names { + println!("- {}", name); + } + } + + Ok(()) + } + TeamCommands::Remove { name } => { + let workspace_root = require_workspace_root()?; + let agent_root = workspace_root.join("agents").join(&name); + + if !agent_root.join("agent.toml").exists() { + anyhow::bail!("agent '{}' not found", name); + } + + fs::remove_dir_all(&agent_root)?; + println!("Removed gr2 agent workspace '{}'", name); + Ok(()) + } + }, + Commands::Repo { command } => match command { + RepoCommands::Add { name, url } => { + let workspace_root = require_workspace_root()?; + let repos_root = workspace_root.join("repos"); + let registry_path = workspace_root.join(".grip/repos.toml"); + let repo_dir = repos_root.join(&name); + + if repo_dir.exists() { + anyhow::bail!("repo '{}' already exists", name); + } + + fs::create_dir_all(&repo_dir)?; + fs::write( + repo_dir.join("repo.toml"), + format!("name = \"{}\"\nurl = \"{}\"\n", name, url), + )?; + + let mut entries = Vec::new(); + if registry_path.exists() { + entries.push(fs::read_to_string(®istry_path)?); + } + entries.push(format!( + "[[repo]]\nname = \"{}\"\nurl = \"{}\"\n", + name, url + )); + fs::write(®istry_path, entries.join("\n"))?; + + println!("Added gr2 repo '{}' -> {}", name, url); + Ok(()) + } + RepoCommands::List => { + let workspace_root = require_workspace_root()?; + let repos_root = workspace_root.join("repos"); + + let mut repos = Vec::new(); + for entry in fs::read_dir(&repos_root)? { + let entry = entry?; + let repo_toml = entry.path().join("repo.toml"); + if entry.file_type()?.is_dir() && repo_toml.exists() { + let content = fs::read_to_string(repo_toml)?; + let fallback_name = entry.file_name().to_string_lossy().into_owned(); + let name = content + .lines() + .find_map(|line| line.strip_prefix("name = \"")) + .and_then(|line| line.strip_suffix('"')) + .map(str::to_owned) + .unwrap_or(fallback_name); + let url = content + .lines() + .find_map(|line| line.strip_prefix("url = \"")) + .and_then(|line| line.strip_suffix('"')) + .unwrap_or("") + .to_string(); + repos.push((name, url)); + } + } + + repos.sort_by(|a, b| a.0.cmp(&b.0)); + + if repos.is_empty() { + println!("No gr2 repos registered."); + } else { + println!("Repos"); + for (name, url) in repos { + println!("- {} -> {}", name, url); + } + } + + Ok(()) + } + RepoCommands::Remove { name } => { + let workspace_root = require_workspace_root()?; + let repos_root = workspace_root.join("repos"); + let repo_root = repos_root.join(&name); + let repo_toml = repo_root.join("repo.toml"); + + if !repo_toml.exists() { + anyhow::bail!("repo '{}' not found", name); + } + + fs::remove_dir_all(&repo_root)?; + + let registry_path = workspace_root.join(".grip/repos.toml"); + if registry_path.exists() { + let registry = fs::read_to_string(®istry_path)?; + let kept_entries = registry + .split("\n[[repo]]\n") + .filter_map(|chunk| { + let chunk = chunk.trim(); + if chunk.is_empty() { + return None; + } + let normalized = if chunk.starts_with("[[repo]]") { + chunk.to_string() + } else { + format!("[[repo]]\n{}", chunk) + }; + let matches_name = normalized + .lines() + .find_map(|line| line.strip_prefix("name = \"")) + .and_then(|line| line.strip_suffix('"')) + .map(|entry_name| entry_name == name) + .unwrap_or(false); + if matches_name { + None + } else { + Some(normalized) + } + }) + .collect::>(); + + if kept_entries.is_empty() { + fs::remove_file(®istry_path)?; + } else { + fs::write(®istry_path, kept_entries.join("\n\n"))?; + } + } + + println!("Removed gr2 repo '{}'", name); + Ok(()) + } + }, + } +} + +fn require_workspace_root() -> Result { + let workspace_root = std::env::current_dir()?; + let workspace_toml = workspace_root.join(".grip/workspace.toml"); + if !workspace_toml.exists() { + anyhow::bail!("not in a gr2 workspace: missing .grip/workspace.toml"); + } + Ok(workspace_root) +} diff --git a/gr2/src/lib.rs b/gr2/src/lib.rs new file mode 100644 index 00000000..7a8cd2b8 --- /dev/null +++ b/gr2/src/lib.rs @@ -0,0 +1,6 @@ +//! gr2 CLI namespace +//! +//! This crate is the clean-break CLI surface for the new team-workspace model. + +pub mod args; +pub mod dispatch; diff --git a/src/bin/gr2.rs b/src/bin/gr2.rs new file mode 100644 index 00000000..344dfd48 --- /dev/null +++ b/src/bin/gr2.rs @@ -0,0 +1,22 @@ +//! gr2 CLI entry point + +use clap::Parser; +use gr2_cli::args::Cli; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let cli = Cli::parse(); + + if cli.verbose { + tracing_subscriber::fmt() + .with_env_filter("gitgrip=debug") + .with_target(false) + .init(); + } else { + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .init(); + } + + gr2_cli::dispatch::dispatch_command(cli.command, cli.verbose).await +} diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index 4f8ac0f9..e23ad564 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -32,6 +32,519 @@ fn test_version() { .stdout(predicate::str::contains(env!("CARGO_PKG_VERSION"))); } +#[test] +fn test_gr2_help() { + let mut cmd = Command::cargo_bin("gr2").unwrap(); + cmd.arg("--help") + .assert() + .success() + .stdout(predicate::str::contains( + "gr2 is the clean-break gitgrip CLI for the new team-workspace, cache, and checkout model.", + )) + .stdout(predicate::str::contains("doctor")) + .stdout(predicate::str::contains("gr2")); +} + +#[test] +fn test_gr2_version() { + let mut cmd = Command::cargo_bin("gr2").unwrap(); + cmd.arg("--version") + .assert() + .success() + .stdout(predicate::str::contains("gr2 0.1.0")); +} + +#[test] +fn test_gr2_doctor() { + let mut cmd = Command::cargo_bin("gr2").unwrap(); + cmd.arg("doctor") + .assert() + .success() + .stdout(predicate::str::contains("gr2 bootstrap OK")); +} + +#[test] +fn test_gr2_init_scaffolds_team_workspace() { + let temp = TempDir::new().unwrap(); + let workspace_root = temp.path().join("demo-team"); + + let mut cmd = Command::cargo_bin("gr2").unwrap(); + cmd.arg("init") + .arg(&workspace_root) + .arg("--name") + .arg("demo") + .assert() + .success() + .stdout(predicate::str::contains( + "Initialized gr2 team workspace 'demo'", + )); + + assert!(workspace_root.join(".grip").is_dir()); + assert!(workspace_root.join("config").is_dir()); + assert!(workspace_root.join("agents").is_dir()); + assert!(workspace_root.join("repos").is_dir()); + + let workspace_toml = + std::fs::read_to_string(workspace_root.join(".grip/workspace.toml")).unwrap(); + assert!(workspace_toml.contains("version = 2")); + assert!(workspace_toml.contains("name = \"demo\"")); + assert!(workspace_toml.contains("layout = \"team-workspace\"")); +} + +#[test] +fn test_gr2_init_rejects_existing_path() { + let temp = TempDir::new().unwrap(); + let workspace_root = temp.path().join("demo-team"); + std::fs::create_dir_all(&workspace_root).unwrap(); + + let mut cmd = Command::cargo_bin("gr2").unwrap(); + cmd.arg("init") + .arg(&workspace_root) + .assert() + .failure() + .stderr(predicate::str::contains("workspace path already exists")); +} + +#[test] +fn test_gr2_team_add_registers_agent_workspace() { + let temp = TempDir::new().unwrap(); + let workspace_root = temp.path().join("demo-team"); + + let mut init = Command::cargo_bin("gr2").unwrap(); + init.arg("init") + .arg(&workspace_root) + .arg("--name") + .arg("demo") + .assert() + .success(); + + let mut team_add = Command::cargo_bin("gr2").unwrap(); + team_add + .current_dir(&workspace_root) + .arg("team") + .arg("add") + .arg("atlas") + .assert() + .success() + .stdout(predicate::str::contains( + "Added gr2 agent workspace 'atlas'", + )); + + let agent_toml = + std::fs::read_to_string(workspace_root.join("agents/atlas/agent.toml")).unwrap(); + assert!(agent_toml.contains("name = \"atlas\"")); + assert!(agent_toml.contains("kind = \"agent-workspace\"")); +} + +#[test] +fn test_gr2_team_add_rejects_duplicate_agent() { + let temp = TempDir::new().unwrap(); + let workspace_root = temp.path().join("demo-team"); + + let mut init = Command::cargo_bin("gr2").unwrap(); + init.arg("init").arg(&workspace_root).assert().success(); + + let mut first = Command::cargo_bin("gr2").unwrap(); + first + .current_dir(&workspace_root) + .arg("team") + .arg("add") + .arg("atlas") + .assert() + .success(); + + let mut duplicate = Command::cargo_bin("gr2").unwrap(); + duplicate + .current_dir(&workspace_root) + .arg("team") + .arg("add") + .arg("atlas") + .assert() + .failure() + .stderr(predicate::str::contains("agent 'atlas' already exists")); +} + +#[test] +fn test_gr2_team_add_requires_gr2_workspace() { + let temp = TempDir::new().unwrap(); + + let mut team_add = Command::cargo_bin("gr2").unwrap(); + team_add + .current_dir(temp.path()) + .arg("team") + .arg("add") + .arg("atlas") + .assert() + .failure() + .stderr(predicate::str::contains( + "not in a gr2 workspace: missing .grip/workspace.toml", + )); +} + +#[test] +fn test_gr2_team_list_shows_registered_agents() { + let temp = TempDir::new().unwrap(); + let workspace_root = temp.path().join("demo-team"); + + let mut init = Command::cargo_bin("gr2").unwrap(); + init.arg("init").arg(&workspace_root).assert().success(); + + let mut add_atlas = Command::cargo_bin("gr2").unwrap(); + add_atlas + .current_dir(&workspace_root) + .arg("team") + .arg("add") + .arg("atlas") + .assert() + .success(); + + let mut add_opus = Command::cargo_bin("gr2").unwrap(); + add_opus + .current_dir(&workspace_root) + .arg("team") + .arg("add") + .arg("opus") + .assert() + .success(); + + let mut list = Command::cargo_bin("gr2").unwrap(); + list.current_dir(&workspace_root) + .arg("team") + .arg("list") + .assert() + .success() + .stdout(predicate::str::contains("Agent workspaces")) + .stdout(predicate::str::contains("- atlas")) + .stdout(predicate::str::contains("- opus")); +} + +#[test] +fn test_gr2_team_list_reports_empty_state() { + let temp = TempDir::new().unwrap(); + let workspace_root = temp.path().join("demo-team"); + + let mut init = Command::cargo_bin("gr2").unwrap(); + init.arg("init").arg(&workspace_root).assert().success(); + + let mut list = Command::cargo_bin("gr2").unwrap(); + list.current_dir(&workspace_root) + .arg("team") + .arg("list") + .assert() + .success() + .stdout(predicate::str::contains( + "No gr2 agent workspaces registered.", + )); +} + +#[test] +fn test_gr2_team_list_requires_gr2_workspace() { + let temp = TempDir::new().unwrap(); + + let mut list = Command::cargo_bin("gr2").unwrap(); + list.current_dir(temp.path()) + .arg("team") + .arg("list") + .assert() + .failure() + .stderr(predicate::str::contains( + "not in a gr2 workspace: missing .grip/workspace.toml", + )); +} + +#[test] +fn test_gr2_team_remove_deletes_registered_agent() { + let temp = TempDir::new().unwrap(); + let workspace_root = temp.path().join("demo-team"); + + let mut init = Command::cargo_bin("gr2").unwrap(); + init.arg("init").arg(&workspace_root).assert().success(); + + let mut add = Command::cargo_bin("gr2").unwrap(); + add.current_dir(&workspace_root) + .arg("team") + .arg("add") + .arg("atlas") + .assert() + .success(); + + let agent_root = workspace_root.join("agents/atlas"); + assert!(agent_root.join("agent.toml").exists()); + + let mut remove = Command::cargo_bin("gr2").unwrap(); + remove + .current_dir(&workspace_root) + .arg("team") + .arg("remove") + .arg("atlas") + .assert() + .success() + .stdout(predicate::str::contains( + "Removed gr2 agent workspace 'atlas'", + )); + + assert!(!agent_root.exists()); +} + +#[test] +fn test_gr2_team_remove_rejects_missing_agent() { + let temp = TempDir::new().unwrap(); + let workspace_root = temp.path().join("demo-team"); + + let mut init = Command::cargo_bin("gr2").unwrap(); + init.arg("init").arg(&workspace_root).assert().success(); + + let mut remove = Command::cargo_bin("gr2").unwrap(); + remove + .current_dir(&workspace_root) + .arg("team") + .arg("remove") + .arg("atlas") + .assert() + .failure() + .stderr(predicate::str::contains("agent 'atlas' not found")); +} + +#[test] +fn test_gr2_team_remove_requires_gr2_workspace() { + let temp = TempDir::new().unwrap(); + + let mut remove = Command::cargo_bin("gr2").unwrap(); + remove + .current_dir(temp.path()) + .arg("team") + .arg("remove") + .arg("atlas") + .assert() + .failure() + .stderr(predicate::str::contains( + "not in a gr2 workspace: missing .grip/workspace.toml", + )); +} + +#[test] +fn test_gr2_repo_add_registers_repo() { + let temp = TempDir::new().unwrap(); + let workspace_root = temp.path().join("demo-team"); + + let mut init = Command::cargo_bin("gr2").unwrap(); + init.arg("init").arg(&workspace_root).assert().success(); + + let mut repo_add = Command::cargo_bin("gr2").unwrap(); + repo_add + .current_dir(&workspace_root) + .arg("repo") + .arg("add") + .arg("app") + .arg("https://github.com/synapt-dev/app.git") + .assert() + .success() + .stdout(predicate::str::contains( + "Added gr2 repo 'app' -> https://github.com/synapt-dev/app.git", + )); + + let repo_toml = std::fs::read_to_string(workspace_root.join("repos/app/repo.toml")).unwrap(); + assert!(repo_toml.contains("name = \"app\"")); + assert!(repo_toml.contains("url = \"https://github.com/synapt-dev/app.git\"")); + + let registry = std::fs::read_to_string(workspace_root.join(".grip/repos.toml")).unwrap(); + assert!(registry.contains("[[repo]]")); + assert!(registry.contains("name = \"app\"")); +} + +#[test] +fn test_gr2_repo_add_rejects_duplicate_repo() { + let temp = TempDir::new().unwrap(); + let workspace_root = temp.path().join("demo-team"); + + let mut init = Command::cargo_bin("gr2").unwrap(); + init.arg("init").arg(&workspace_root).assert().success(); + + let mut first = Command::cargo_bin("gr2").unwrap(); + first + .current_dir(&workspace_root) + .arg("repo") + .arg("add") + .arg("app") + .arg("https://github.com/synapt-dev/app.git") + .assert() + .success(); + + let mut duplicate = Command::cargo_bin("gr2").unwrap(); + duplicate + .current_dir(&workspace_root) + .arg("repo") + .arg("add") + .arg("app") + .arg("https://github.com/synapt-dev/app.git") + .assert() + .failure() + .stderr(predicate::str::contains("repo 'app' already exists")); +} + +#[test] +fn test_gr2_repo_add_requires_gr2_workspace() { + let temp = TempDir::new().unwrap(); + + let mut repo_add = Command::cargo_bin("gr2").unwrap(); + repo_add + .current_dir(temp.path()) + .arg("repo") + .arg("add") + .arg("app") + .arg("https://github.com/synapt-dev/app.git") + .assert() + .failure() + .stderr(predicate::str::contains( + "not in a gr2 workspace: missing .grip/workspace.toml", + )); +} + +#[test] +fn test_gr2_repo_list_shows_registered_repos() { + let temp = TempDir::new().unwrap(); + let workspace_root = temp.path().join("demo-team"); + + let mut init = Command::cargo_bin("gr2").unwrap(); + init.arg("init").arg(&workspace_root).assert().success(); + + let mut add_app = Command::cargo_bin("gr2").unwrap(); + add_app + .current_dir(&workspace_root) + .arg("repo") + .arg("add") + .arg("app") + .arg("https://github.com/synapt-dev/app.git") + .assert() + .success(); + + let mut add_docs = Command::cargo_bin("gr2").unwrap(); + add_docs + .current_dir(&workspace_root) + .arg("repo") + .arg("add") + .arg("docs") + .arg("https://github.com/synapt-dev/docs.git") + .assert() + .success(); + + let mut list = Command::cargo_bin("gr2").unwrap(); + list.current_dir(&workspace_root) + .arg("repo") + .arg("list") + .assert() + .success() + .stdout(predicate::str::contains("Repos")) + .stdout(predicate::str::contains( + "- app -> https://github.com/synapt-dev/app.git", + )) + .stdout(predicate::str::contains( + "- docs -> https://github.com/synapt-dev/docs.git", + )); +} + +#[test] +fn test_gr2_repo_list_reports_empty_state() { + let temp = TempDir::new().unwrap(); + let workspace_root = temp.path().join("demo-team"); + + let mut init = Command::cargo_bin("gr2").unwrap(); + init.arg("init").arg(&workspace_root).assert().success(); + + let mut list = Command::cargo_bin("gr2").unwrap(); + list.current_dir(&workspace_root) + .arg("repo") + .arg("list") + .assert() + .success() + .stdout(predicate::str::contains("No gr2 repos registered.")); +} + +#[test] +fn test_gr2_repo_list_requires_gr2_workspace() { + let temp = TempDir::new().unwrap(); + + let mut list = Command::cargo_bin("gr2").unwrap(); + list.current_dir(temp.path()) + .arg("repo") + .arg("list") + .assert() + .failure() + .stderr(predicate::str::contains( + "not in a gr2 workspace: missing .grip/workspace.toml", + )); +} + +#[test] +fn test_gr2_repo_remove_deletes_registered_repo() { + let temp = TempDir::new().unwrap(); + let workspace_root = temp.path().join("demo-team"); + + let mut init = Command::cargo_bin("gr2").unwrap(); + init.arg("init").arg(&workspace_root).assert().success(); + + let mut add = Command::cargo_bin("gr2").unwrap(); + add.current_dir(&workspace_root) + .arg("repo") + .arg("add") + .arg("app") + .arg("https://github.com/synapt-dev/app.git") + .assert() + .success(); + + let repo_root = workspace_root.join("repos/app"); + assert!(repo_root.join("repo.toml").exists()); + + let mut remove = Command::cargo_bin("gr2").unwrap(); + remove + .current_dir(&workspace_root) + .arg("repo") + .arg("remove") + .arg("app") + .assert() + .success() + .stdout(predicate::str::contains("Removed gr2 repo 'app'")); + + assert!(!repo_root.exists()); + assert!(!workspace_root.join(".grip/repos.toml").exists()); +} + +#[test] +fn test_gr2_repo_remove_rejects_missing_repo() { + let temp = TempDir::new().unwrap(); + let workspace_root = temp.path().join("demo-team"); + + let mut init = Command::cargo_bin("gr2").unwrap(); + init.arg("init").arg(&workspace_root).assert().success(); + + let mut remove = Command::cargo_bin("gr2").unwrap(); + remove + .current_dir(&workspace_root) + .arg("repo") + .arg("remove") + .arg("app") + .assert() + .failure() + .stderr(predicate::str::contains("repo 'app' not found")); +} + +#[test] +fn test_gr2_repo_remove_requires_gr2_workspace() { + let temp = TempDir::new().unwrap(); + + let mut remove = Command::cargo_bin("gr2").unwrap(); + remove + .current_dir(temp.path()) + .arg("repo") + .arg("remove") + .arg("app") + .assert() + .failure() + .stderr(predicate::str::contains( + "not in a gr2 workspace: missing .grip/workspace.toml", + )); +} + /// Test that `gr status` fails gracefully outside a workspace #[test] fn test_status_outside_workspace() { From 1035a6f55f559be1f34a76a123e40e95bffd0be0 Mon Sep 17 00:00:00 2001 From: Layne Penney Date: Wed, 8 Apr 2026 11:51:33 -0500 Subject: [PATCH 02/15] ci: split Windows tests into non-blocking lane (#487) --- .github/workflows/ci.yml | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c51b4b26..d18ed5c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,7 +70,22 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest, windows-latest] + os: [ubuntu-latest, macos-latest] + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + + - name: Run tests + run: cargo test --all-features + + test-windows: + name: Test (windows-latest) + runs-on: windows-latest steps: - uses: actions/checkout@v4 @@ -83,10 +98,7 @@ jobs: - name: Run tests run: cargo test --all-features env: - # Windows: link advapi32 for libgit2-sys (needed for test crates that - # don't go through build.rs) and increase stack to 8 MB (debug builds - # overflow the default 1 MB Windows stack during clap parsing) - RUSTFLAGS: ${{ matrix.os == 'windows-latest' && '-C link-arg=advapi32.lib -C link-arg=/STACK:8388608' || '' }} + RUSTFLAGS: '-C link-arg=advapi32.lib -C link-arg=/STACK:8388608' build: name: Build Release @@ -149,14 +161,16 @@ jobs: - name: Run spawn integration tests run: GR=./target/debug/gr ./tests/spawn_graceful_shutdown.sh - # Summary job for branch protection - requires all other jobs to pass + # Summary job for branch protection - requires all jobs except Windows to pass. + # Windows tests run separately (test-windows) and are visible but non-blocking, + # since they are significantly slower and should not gate PR merges. ci: name: CI runs-on: ubuntu-latest needs: [check, fmt, clippy, test, build, bench, integration] if: always() steps: - - name: Check all jobs passed + - name: Check all required jobs passed run: | if [[ "${{ needs.check.result }}" != "success" ]] || \ [[ "${{ needs.fmt.result }}" != "success" ]] || \ @@ -165,7 +179,7 @@ jobs: [[ "${{ needs.build.result }}" != "success" ]] || \ [[ "${{ needs.bench.result }}" != "success" ]] || \ [[ "${{ needs.integration.result }}" != "success" ]]; then - echo "One or more jobs failed" + echo "One or more required jobs failed" exit 1 fi - echo "All jobs passed" + echo "All required jobs passed" From a8f6a3f0d41bdc38faa48f323c42fe83d75fe0d2 Mon Sep 17 00:00:00 2001 From: Layne Penney Date: Wed, 8 Apr 2026 12:00:48 -0500 Subject: [PATCH 03/15] feat: add machine-level manifest repo caches (#484) --- README.md | 31 +++ src/cli/args.rs | 2 +- src/cli/commands/cache.rs | 26 ++- src/core/workspace_cache.rs | 356 ++++++++++++++++++++++++--------- src/core/workspace_checkout.rs | 206 +++++++++++-------- 5 files changed, 426 insertions(+), 195 deletions(-) diff --git a/README.md b/README.md index bb5751ff..f7c01a28 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,9 @@ gr sync | `gr link` | Manage file links | | `gr run