From 999b2702932c78c12d7be66ae8a9c72067a7d29e Mon Sep 17 00:00:00 2001 From: inx Date: Tue, 27 Jan 2026 15:22:21 +0800 Subject: [PATCH] feat(core): implement secure multi-identity management system This commit introduces the complete core logic for Gosh (Git Operations Shell), providing a fail-safe mechanism for managing multiple Git identities. Key features implemented: - **Ephemeral Execution (Exec Mode)**: Uses `git -c` with blind injection to sanitize the environment (clearing user/gpg keys) before injecting the target profile. Zero disk modification. - **Persistent Switching (Switch Mode)**: Modifies `.git/config` using `[include]` directive. Supports `-f` force mode to strictly sanitize legacy dirty configurations (removing user/gpg sections). - **Setup Automation**: Handles `clone` and `init` by automatically inferring directory names and applying the profile immediately. - **Profile Management**: CRUD operations (-c, -r, -e, -l) for profiles. - **Config**: XDG-compliant configuration with auto-initialization at `~/.config/gosh/`. - **Testing**: Comprehensive integration suite covering isolation, injection, persistence, and sanitization logic. Implements strict security boundaries defined in the PRD (Blacklist/Whitelist). --- Cargo.toml | 12 +- src/cli.rs | 38 ++++++ src/config.rs | 90 ++++++++++++ src/git.rs | 240 ++++++++++++++++++++++++++++++++ src/main.rs | 35 ++++- src/manage.rs | 81 +++++++++++ src/sanitizer.rs | 21 +++ src/utils.rs | 26 ++++ tests/integration_test.rs | 278 ++++++++++++++++++++++++++++++++++++++ 9 files changed, 818 insertions(+), 3 deletions(-) create mode 100644 src/cli.rs create mode 100644 src/config.rs create mode 100644 src/git.rs create mode 100644 src/manage.rs create mode 100644 src/sanitizer.rs create mode 100644 src/utils.rs create mode 100644 tests/integration_test.rs diff --git a/Cargo.toml b/Cargo.toml index f31d10b..8fe1aae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,16 @@ [package] name = "gosh" version = "0.1.0" -edition = "2024" +edition = "2021" [dependencies] +clap = { version = "4.5", features = ["derive"] } +serde = { version = "1.0", features = ["derive"] } +toml = "0.8" +dirs = "5.0" +anyhow = "1.0" + +[dev-dependencies] +assert_cmd = "2.0" +tempfile = "3.10" +predicates = "3.1" diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..06205e6 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,38 @@ +use clap::{Parser, Args}; + +#[derive(Parser, Debug)] +#[command(name = "gosh")] +#[command(about = "Git Operations Shell", long_about = None)] +pub struct Cli { + #[command(flatten)] + pub manage: Option, + + // Operation mode args + // We make profile_id optional so manage flags can exist without it, + // but check constraints in main or via clap attributes if possible. + // required_unless_present("manage_group") makes it required if no manage flag is set. + #[arg(required_unless_present("manage_group"))] + pub profile_id: Option, + + #[arg(allow_hyphen_values = true)] + pub git_args: Vec, + + #[arg(short, long)] + pub force: bool, +} + +#[derive(Args, Debug)] +#[group(id = "manage_group", multiple = false)] +pub struct ManageFlags { + #[arg(short, long, num_args = 3, value_names = ["NAME", "EMAIL", "ID"])] + pub create: Option>, + + #[arg(short, long, value_name = "ID")] + pub remove: Option, + + #[arg(short, long, value_name = "ID")] + pub edit: Option, + + #[arg(short, long)] + pub list: bool, +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..45bfb91 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,90 @@ +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use std::fs; +use anyhow::{Context, Result}; +use crate::utils::expand_path; + +#[derive(Serialize, Deserialize, Debug)] +pub struct Strategies { + pub clone: String, + pub switch: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct GoshConfig { + pub strategies: Strategies, + pub profile_dir: String, +} + +impl Default for GoshConfig { + fn default() -> Self { + GoshConfig { + strategies: Strategies { + clone: "INCLUDE".to_string(), + switch: "include".to_string(), + }, + profile_dir: "~/.config/gosh/profiles".to_string(), + } + } +} + +pub fn get_config_root() -> Result { + if let Ok(path) = std::env::var("GOSH_CONFIG_PATH") { + return Ok(PathBuf::from(path)); + } + let config_dir = dirs::config_dir().ok_or_else(|| anyhow::anyhow!("Could not find config directory"))?; + Ok(config_dir.join("gosh")) +} + +pub fn load_config() -> Result { + let root = get_config_root()?; + let config_path = root.join("gosh.toml"); + + if !config_path.exists() { + return initialize_config(&root, &config_path); + } + + let content = fs::read_to_string(&config_path).context("Failed to read config file")?; + let config: GoshConfig = toml::from_str(&content).context("Failed to parse config file")?; + Ok(config) +} + +fn initialize_config(root: &Path, config_path: &Path) -> Result { + // Ensure root exists + fs::create_dir_all(root).context("Failed to create config root")?; + + // Determine default profile_dir based on environment to support testing isolation + let profile_dir_str = if let Ok(env_path) = std::env::var("GOSH_CONFIG_PATH") { + // If GOSH_CONFIG_PATH is set, default profile dir should be inside it for isolation + let p = PathBuf::from(env_path).join("profiles"); + // Use forward slashes for TOML consistency if possible, though PathBuf handles it. + // On Windows, replace backslashes to avoid escape issues in TOML string if not raw + // We'll just rely on to_string_lossy but be careful with escaping in the format macro + p.to_string_lossy().to_string() + } else { + "~/.config/gosh/profiles".to_string() + }; + + // Use toml serialization to ensure string is escaped properly? + // Manual format is safer for comments. + // If path contains backslashes (Windows), we need to escape them for the TOML string literal: "C:\\Foo" + let escaped_profile_dir = profile_dir_str.replace("\\", "\\\\"); + + let generated_toml = format!(r#"# Gosh Configuration + +profile_dir = "{}" + +[strategies] +clone = "INCLUDE" # Hard strategy +switch = "include" # Soft strategy +"#, escaped_profile_dir); + + fs::write(config_path, &generated_toml).context("Failed to write default config")?; + + // Create the profiles directory recursively + let expanded_profile_dir = expand_path(&profile_dir_str)?; + fs::create_dir_all(&expanded_profile_dir).context("Failed to create profiles directory")?; + + let config: GoshConfig = toml::from_str(&generated_toml)?; + Ok(config) +} diff --git a/src/git.rs b/src/git.rs new file mode 100644 index 0000000..fe9bbee --- /dev/null +++ b/src/git.rs @@ -0,0 +1,240 @@ +use crate::config::GoshConfig; +use crate::sanitizer; +use crate::utils::expand_path; +use anyhow::{Result, anyhow, Context}; +use std::process::Command; +use std::path::{Path, PathBuf}; + +enum Action { + Setup, + Exec, + Switch, +} + +pub fn run(config: &GoshConfig, profile_id: &str, args: &[String], force: bool) -> Result<()> { + // Determine Action + let action = if args.is_empty() { + Action::Switch + } else if args[0] == "clone" || args[0] == "init" { + Action::Setup + } else { + Action::Exec + }; + + match action { + Action::Exec => run_exec(config, profile_id, args), + Action::Switch => run_switch(config, profile_id, force), + Action::Setup => run_setup(config, profile_id, args), + } +} + +fn get_profile_path(config: &GoshConfig, id: &str) -> Result { + let profile_dir = expand_path(&config.profile_dir)?; + let p = profile_dir.join(format!("{}.gitconfig", id)); + if !p.exists() { + return Err(anyhow!("Profile '{}' not found at {:?}", id, p)); + } + Ok(p) +} + +fn is_mocking() -> bool { + std::env::var("GOSH_MOCKING").is_ok() +} + +fn run_command(cmd: &mut Command) -> Result<()> { + if is_mocking() { + eprintln!("[DRY-RUN] {:?}", cmd); + return Ok(()); + } + + // inherit stdio + // But cmd is passed in. + // The requirement says "The process MUST inherit stdin/stdout/stderr" + // We assume the caller sets that up or we do it here? + // Command default is NOT inherit. + // We should set it before calling this wrapper or inside. + // Let's set it inside but we need to modify cmd. + // actually, std::process::Command methods like spawn or status need to be called. + + // We can't easily iterate args from Command generic debug, but checking env var is enough. + // Let's assume the caller constructs the command and we run it. + + let status = cmd.status().context("Failed to execute git command")?; + if !status.success() { + // We might not want to error hard if git fails, just propagate exit code? + // But anyhow::Result implies error. + // For CLI tools, usually we want to return the exact exit code. + // But for now, let's just return Error if failed. + return Err(anyhow!("Git command exited with status: {}", status)); + } + Ok(()) +} + +fn run_exec(config: &GoshConfig, profile_id: &str, args: &[String]) -> Result<()> { + let profile_path = get_profile_path(config, profile_id)?; + let injections = sanitizer::get_blind_injections(); + + let mut cmd = Command::new("git"); + + // Layer 1: Sanitization + for (k, v) in injections { + cmd.arg("-c").arg(format!("{}={}", k, v)); + } + + // Layer 2: Profile + cmd.arg("-c").arg(format!("include.path={}", profile_path.to_string_lossy())); + + // Layer 3: User Command + cmd.args(args); + + cmd.stdin(std::process::Stdio::inherit()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()); + + run_command(&mut cmd) +} + +fn run_switch(config: &GoshConfig, profile_id: &str, force: bool) -> Result<()> { + if !Path::new(".git").exists() { + return Err(anyhow!("Not a git repository (checked current directory)")); + } + + let profile_path = get_profile_path(config, profile_id)?; + // Use absolute path for include to avoid issues if we move around? + // The requirement just says . + // Usually absolute path is best for git config include.path. + let abs_profile_path = if profile_path.is_absolute() { + profile_path + } else { + std::env::current_dir()?.join(profile_path) + }; + + // Strategy determination + let strategy = if force { + "HARD".to_string() + } else { + config.strategies.switch.to_uppercase() + }; + + if strategy == "HARD" { + // Remove sections + let sections = sanitizer::BLACKLIST_SECTIONS; + for section in sections { + let mut cmd = Command::new("git"); + cmd.args(&["config", "--remove-section", section]); + // Ignore errors + let _ = cmd.output(); + } + + // Unset keys + let keys = sanitizer::BLACKLIST_KEYS; + for key in keys { + let mut cmd = Command::new("git"); + cmd.args(&["config", "--unset", key]); + let _ = cmd.output(); + } + } + + // Add include path + // We should check if it exists? "The system SHOULD check if the include already exists" + // git config --get-all include.path + let output = Command::new("git") + .args(&["config", "--get-all", "include.path"]) + .output()?; + + let current_includes = String::from_utf8_lossy(&output.stdout); + let path_str = abs_profile_path.to_string_lossy(); + + if !current_includes.contains(&*path_str) { + let mut cmd = Command::new("git"); + cmd.args(&["config", "--add", "include.path", &path_str]); + run_command(&mut cmd)?; + println!("Switched to profile '{}'", profile_id); + } else { + println!("Profile '{}' already active", profile_id); + } + + Ok(()) +} + +fn run_setup(config: &GoshConfig, profile_id: &str, args: &[String]) -> Result<()> { + // 1. Pass raw args to git (no injection) + let mut cmd = Command::new("git"); + cmd.args(args); + cmd.stdin(std::process::Stdio::inherit()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()); + + // Check if dry run? setup actions have side effects (creating dirs). + // If mocking, we print validation and skip real execution of git? + + if is_mocking() { + eprintln!("[DRY-RUN] {:?}", cmd); + // We can't really continue to infer directory if we don't run it? + // But for testing logic, we might want to see what happens next. + // However, if git clone doesn't run, the dir won't exist for switch mode check. + return Ok(()); + } + + let status = cmd.status().context("Failed to execute git setup command")?; + if !status.success() { + return Err(anyhow!("Git setup command failed")); + } + + // 2. Infer target directory + // Last arg analysis + if let Some(last_arg) = args.last() { + let target_dir = if !is_git_url(last_arg) && args.len() > 1 { + // Case A: Explicit Directory + // "If the last argument does not look like a Git URL ... treat it as the Target Directory" + // Wait, "clone " -> last arg is dir. + // "clone " -> last arg is url. + // So we check if it LOOKS like a URL. + PathBuf::from(last_arg) + } else { + // Case B: Implicit Directory + // Extract basename + let url = last_arg; + // Remove checks for safety? + extract_basename(url) + }; + + println!("Detected target directory: {:?}", target_dir); + + // 3. Switch mode on new directory + let cwd = std::env::current_dir()?; + let repo_path = cwd.join(&target_dir); + + if repo_path.exists() && repo_path.join(".git").exists() { + std::env::set_current_dir(&repo_path)?; + // Force Hard switch + run_switch(config, profile_id, true)?; + } else { + eprintln!("Warning: Could not find repository at {:?} to apply profile", repo_path); + } + } + + Ok(()) +} + +fn is_git_url(s: &str) -> bool { + s.starts_with("http://") || + s.starts_with("https://") || + s.starts_with("git@") || + s.starts_with("ssh://") || + s.contains('@') || // scp-like syntax user@host:path + s.contains(':') // scp-like syntax host:path (but http has : too) + // The requirement says: "(does not start with http://, https://, git@, ssh://, and contains no @ or :)" +} + +fn extract_basename(url: &str) -> PathBuf { + // Remove trailing slash + let mut s = url.trim_end_matches('/'); + // Remove .git suffix + if s.ends_with(".git") { + s = &s[..s.len() - 4]; + } + // Basename + let path = Path::new(s); + path.file_name().map(|n| PathBuf::from(n)).unwrap_or_else(|| PathBuf::from("repo")) +} diff --git a/src/main.rs b/src/main.rs index e7a11a9..63548f4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,34 @@ -fn main() { - println!("Hello, world!"); +mod cli; +mod config; +mod manage; +mod sanitizer; +mod git; +mod utils; + +use anyhow::Result; +use clap::Parser; +use cli::Cli; + +fn main() -> Result<()> { + let cli = Cli::parse(); + let config = config::load_config()?; + + if let Some(manage_flags) = &cli.manage { + if let Some(create_args) = &manage_flags.create { + manage::create_profile(&config, &create_args[0], &create_args[1], &create_args[2])?; + } else if let Some(id) = &manage_flags.remove { + manage::remove_profile(&config, id)?; + } else if let Some(id) = &manage_flags.edit { + manage::edit_profile(&config, id)?; + } else if manage_flags.list { + manage::list_profiles(&config)?; + } + } else { + // Operation mode + let profile_id = cli.profile_id.as_ref().expect("Profile ID required in operation mode"); + let git_args = &cli.git_args; + git::run(&config, profile_id, git_args, cli.force)?; + } + + Ok(()) } diff --git a/src/manage.rs b/src/manage.rs new file mode 100644 index 0000000..98d187f --- /dev/null +++ b/src/manage.rs @@ -0,0 +1,81 @@ +use anyhow::{bail, Context, Result}; +use std::fs; +use std::path::PathBuf; +use std::process::Command; +use crate::config::GoshConfig; +use crate::utils::expand_path; + +fn get_profile_path(config: &GoshConfig, id: &str) -> Result { + let profile_dir = expand_path(&config.profile_dir)?; + Ok(profile_dir.join(format!("{}.gitconfig", id))) +} + +pub fn create_profile(config: &GoshConfig, name: &str, email: &str, id: &str) -> Result<()> { + let file_path = get_profile_path(config, id)?; + + if file_path.exists() { + bail!("Profile '{}' already exists", id); + } + + // Ensure dir exists (it typically should from init, but good to be safe) + if let Some(parent) = file_path.parent() { + fs::create_dir_all(parent)?; + } + + let content = format!("[user]\n name = {}\n email = {}\n # signingkey = \n", name, email); + fs::write(&file_path, content).with_context(|| format!("Failed to create profile {}", id))?; + println!("Created profile '{}'", id); + Ok(()) +} + +pub fn remove_profile(config: &GoshConfig, id: &str) -> Result<()> { + let file_path = get_profile_path(config, id)?; + + if !file_path.exists() { + bail!("Profile '{}' does not exist", id); + } + + fs::remove_file(&file_path).with_context(|| format!("Failed to remove profile {}", id))?; + println!("Removed profile '{}'", id); + Ok(()) +} + +pub fn edit_profile(config: &GoshConfig, id: &str) -> Result<()> { + let file_path = get_profile_path(config, id)?; + + if !file_path.exists() { + bail!("Profile '{}' does not exist", id); + } + + let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string()); + + let status = Command::new(&editor) + .arg(&file_path) + .status() + .with_context(|| format!("Failed to launch editor '{}'", editor))?; + + if !status.success() { + bail!("Editor exited with non-zero status"); + } + Ok(()) +} + +pub fn list_profiles(config: &GoshConfig) -> Result<()> { + let profile_dir = expand_path(&config.profile_dir)?; + + if !profile_dir.exists() { + println!("No profiles found (directory {:?} does not exist)", profile_dir); + return Ok(()); + } + + for entry in fs::read_dir(profile_dir)? { + let entry = entry?; + let path = entry.path(); + if path.is_file() && path.extension().map_or(false, |ext| ext == "gitconfig") { + if let Some(stem) = path.file_stem() { + println!("{}", stem.to_string_lossy()); + } + } + } + Ok(()) +} diff --git a/src/sanitizer.rs b/src/sanitizer.rs new file mode 100644 index 0000000..b76ff7d --- /dev/null +++ b/src/sanitizer.rs @@ -0,0 +1,21 @@ +pub const BLACKLIST_SECTIONS: &[&str] = &["user", "author", "committer", "gpg"]; + +pub const BLACKLIST_KEYS: &[&str] = &[ + "core.sshCommand", + "commit.gpgsign", + "tag.gpgsign", + "http.cookieFile", +]; + +pub fn get_blind_injections() -> Vec<(&'static str, &'static str)> { + vec![ + ("user.name", ""), + ("user.email", ""), + ("user.signingkey", ""), + ("core.sshCommand", ""), + ("gpg.format", ""), + ("gpg.ssh.program", ""), + ("commit.gpgsign", "false"), + ("tag.gpgsign", "false"), + ] +} diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..085c1dc --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,26 @@ +use anyhow::{anyhow, Result}; +use std::path::PathBuf; + +pub fn expand_path(path_str: &str) -> Result { + if path_str.starts_with('~') { + let home = dirs::home_dir().ok_or_else(|| anyhow!("Could not find home directory"))?; + + // Handle "~" exactly + if path_str == "~" { + return Ok(home); + } + + // Handle "~/" or "~\" (windows) + // We check for the separator to avoid matching "~user" which we don't support simple replacement for + if path_str.starts_with("~/") || path_str.starts_with("~\\") { + let remainder = &path_str[2..]; + return Ok(home.join(remainder)); + } + + // If it starts with ~ but isn't ~/ or ~\, we might want to support it or error? + // For simplicity and matching requirements, we treat it as literal if it doesn't match our expansion pattern + // Or we could error. Given the requirement is mostly for config paths like "~/.config/...", we assume standard expansion. + } + + Ok(PathBuf::from(path_str)) +} diff --git a/tests/integration_test.rs b/tests/integration_test.rs new file mode 100644 index 0000000..21d4291 --- /dev/null +++ b/tests/integration_test.rs @@ -0,0 +1,278 @@ +use assert_cmd::Command; +use std::fs; +use tempfile::TempDir; + +#[test] +fn test_config_initialization() -> Result<(), Box> { + let temp_dir = TempDir::new()?; + let config_path = temp_dir.path().join("config"); + + // Run gosh with GOSH_CONFIG_PATH set to temp dir + let mut cmd = Command::new(env!("CARGO_BIN_EXE_gosh")); + cmd.env("GOSH_CONFIG_PATH", &config_path) + .arg("-l") // Trigger config load using list flag (not positional "list" profile) + .assert() + .success(); + + // Verify config file exists + assert!(config_path.join("gosh.toml").exists()); + assert!(config_path.join("profiles").exists()); + + Ok(()) +} + +#[test] +fn test_profile_creation_and_listing() -> Result<(), Box> { + let temp_dir = TempDir::new()?; + let config_path = temp_dir.path(); + + let mut cmd = Command::new(env!("CARGO_BIN_EXE_gosh")); + cmd.env("GOSH_CONFIG_PATH", config_path) + .args(&["-c", "Test User", "test@example.com", "test_user"]) + .assert() + .success(); + + // Verify profile file + let profile_path = config_path.join("profiles").join("test_user.gitconfig"); + assert!(profile_path.exists()); + let content = fs::read_to_string(profile_path)?; + assert!(content.contains("name = Test User")); + assert!(content.contains("email = test@example.com")); + + // Verify list + let mut cmd_list = Command::new(env!("CARGO_BIN_EXE_gosh")); + cmd_list.env("GOSH_CONFIG_PATH", config_path) + .arg("-l") + .assert() + .success() + .stdout(predicates::str::contains("test_user")); + + Ok(()) +} + +#[test] +fn test_duplicate_creation_failure() -> Result<(), Box> { + let temp_dir = TempDir::new()?; + let config_path = temp_dir.path(); + + // Create first + Command::new(env!("CARGO_BIN_EXE_gosh")) + .env("GOSH_CONFIG_PATH", config_path) + .args(&["-c", "User", "u@e.com", "dup_test"]) + .assert() + .success(); + + // Create duplicate + Command::new(env!("CARGO_BIN_EXE_gosh")) + .env("GOSH_CONFIG_PATH", config_path) + .args(&["-c", "User2", "u2@e.com", "dup_test"]) + .assert() + .failure(); // Should fail + + Ok(()) +} + +#[test] +fn test_remove_profile() -> Result<(), Box> { + let temp_dir = TempDir::new()?; + let config_path = temp_dir.path(); + let profile_path = config_path.join("profiles").join("rem_test.gitconfig"); + + // Create + Command::new(env!("CARGO_BIN_EXE_gosh")) + .env("GOSH_CONFIG_PATH", config_path) + .args(&["-c", "User", "u@e.com", "rem_test"]) + .assert() + .success(); + assert!(profile_path.exists()); + + // Remove + Command::new(env!("CARGO_BIN_EXE_gosh")) + .env("GOSH_CONFIG_PATH", config_path) + .args(&["-r", "rem_test"]) + .assert() + .success(); + assert!(!profile_path.exists()); + + // Remove non-existent + Command::new(env!("CARGO_BIN_EXE_gosh")) + .env("GOSH_CONFIG_PATH", config_path) + .args(&["-r", "rem_test"]) + .assert() + .failure(); + + Ok(()) +} + +#[test] +fn test_exec_dry_run_injection_strict() -> Result<(), Box> { + let temp_dir = TempDir::new()?; + let config_path = temp_dir.path(); + + // Create a profile first + Command::new(env!("CARGO_BIN_EXE_gosh")) + .env("GOSH_CONFIG_PATH", config_path) + .args(&["-c", "Test", "test@e.com", "p1"]) + .assert() + .success(); + + // Run exec with mocking + let mut cmd = Command::new(env!("CARGO_BIN_EXE_gosh")); + cmd.env("GOSH_CONFIG_PATH", config_path) + .env("GOSH_MOCKING", "1") + .args(&["p1", "commit", "-m", "foo"]) + .assert() + .success() + .stderr(predicates::str::contains("user.name=")) + .stderr(predicates::str::contains("user.email=")) + .stderr(predicates::str::contains("user.signingkey=")) + .stderr(predicates::str::contains("core.sshCommand=")) + .stderr(predicates::str::contains("commit.gpgsign=false")) + .stderr(predicates::str::contains("include.path=")) + .stderr(predicates::str::contains("p1.gitconfig")); + + Ok(()) +} + +#[test] +fn test_switch_mode_persistent() -> Result<(), Box> { + let temp_dir = TempDir::new()?; + let config_path = temp_dir.path().join("config"); + let repo_dir = temp_dir.path().join("repo"); + let git_dir = repo_dir.join(".git"); + + fs::create_dir_all(&repo_dir)?; + + // Init valid git repo + std::process::Command::new("git") + .arg("init") + .current_dir(&repo_dir) + .output()?; + + // Create profile + Command::new(env!("CARGO_BIN_EXE_gosh")) + .env("GOSH_CONFIG_PATH", &config_path) + .args(&["-c", "Switch User", "s@e.com", "switch_test"]) + .assert() + .success(); + + // Switch + Command::new(env!("CARGO_BIN_EXE_gosh")) + .env("GOSH_CONFIG_PATH", &config_path) + .current_dir(&repo_dir) + .arg("switch_test") + .assert() + .success(); + + // Verify .git/config + let git_config = fs::read_to_string(git_dir.join("config"))?; + assert!(git_config.contains("[include]")); + assert!(git_config.contains("path =")); + assert!(git_config.contains("switch_test.gitconfig")); + + Ok(()) +} + +#[test] +fn test_switch_force_mode_sanitization() -> Result<(), Box> { + let temp_dir = TempDir::new()?; + let config_path = temp_dir.path().join("config"); + let repo_dir = temp_dir.path().join("repo"); + let git_dir = repo_dir.join(".git"); + + fs::create_dir_all(&repo_dir)?; + std::process::Command::new("git") + .arg("init") + .current_dir(&repo_dir) + .output()?; + + // Pre-fill with dirty data + // We append to the config created by git init + let config_file = git_dir.join("config"); + let mut current_config = fs::read_to_string(&config_file)?; + current_config.push_str(r#"[user] + name = OldName + email = old@example.com +"#); + fs::write(&config_file, current_config)?; + + // Create profile + Command::new(env!("CARGO_BIN_EXE_gosh")) + .env("GOSH_CONFIG_PATH", &config_path) + .args(&["-c", "Force User", "f@e.com", "force_test"]) + .assert() + .success(); + + // Force Switch + Command::new(env!("CARGO_BIN_EXE_gosh")) + .env("GOSH_CONFIG_PATH", &config_path) + .current_dir(&repo_dir) + .args(&["force_test", "-f"]) + .assert() + .success(); + + // Verify .git/config + let git_config = fs::read_to_string(git_dir.join("config"))?; + + // Should NOT contain user section + assert!(!git_config.contains("[user]")); + assert!(!git_config.contains("OldName")); + + // Should contain include + assert!(git_config.contains("[include]")); + assert!(git_config.contains("force_test.gitconfig")); + + Ok(()) +} + +#[test] +fn test_setup_mode_local_clone() -> Result<(), Box> { + let temp_dir = TempDir::new()?; + let config_path = temp_dir.path().join("config"); + let source_repo = temp_dir.path().join("source"); + + // 1. Create a dummy source repo locally + fs::create_dir_all(&source_repo)?; + std::process::Command::new("git") + .arg("init") + .current_dir(&source_repo) + .output()?; + // Git allows cloning an empty repo, so this is enough + + // 2. Create Profile + Command::new(env!("CARGO_BIN_EXE_gosh")) + .env("GOSH_CONFIG_PATH", &config_path) + .args(&["-c", "CloneUser", "c@e.com", "clone_test"]) + .assert() + .success(); + + // 3. Run Gosh Clone (Setup Mode) + // We clone from local source to a folder named "dest_repo" + // Gosh should: + // a) Run git clone + // b) Infer directory is "dest_repo" + // c) Run switch logic on "dest_repo" + let dest_repo_name = "dest_repo"; + + Command::new(env!("CARGO_BIN_EXE_gosh")) + .env("GOSH_CONFIG_PATH", &config_path) + .current_dir(temp_dir.path()) // Execute in temp root + .args(&["clone_test", "clone", source_repo.to_str().unwrap(), dest_repo_name]) + .assert() + .success(); + + // 4. Verify Result + let dest_git_config = temp_dir.path() + .join(dest_repo_name) + .join(".git") + .join("config"); + + assert!(dest_git_config.exists(), "Cloned repo config should exist"); + + let content = fs::read_to_string(dest_git_config)?; + // Verify that gosh automatically switched the profile after cloning + assert!(content.contains("[include]")); + assert!(content.contains("clone_test.gitconfig")); + + Ok(()) +} \ No newline at end of file