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).
This commit is contained in:
INX "Xenon" 2026-01-27 15:22:21 +08:00
parent b3e139b689
commit 999b270293
Signed by: inx
SSH key fingerprint: SHA256:oEFbclBdeqw4M09C3hfnDej0ioZdzZW6BKxsZH6quX8
9 changed files with 818 additions and 3 deletions

View file

@ -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"

38
src/cli.rs Normal file
View file

@ -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<ManageFlags>,
// 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<String>,
#[arg(allow_hyphen_values = true)]
pub git_args: Vec<String>,
#[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<Vec<String>>,
#[arg(short, long, value_name = "ID")]
pub remove: Option<String>,
#[arg(short, long, value_name = "ID")]
pub edit: Option<String>,
#[arg(short, long)]
pub list: bool,
}

90
src/config.rs Normal file
View file

@ -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<PathBuf> {
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<GoshConfig> {
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<GoshConfig> {
// 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)
}

240
src/git.rs Normal file
View file

@ -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<PathBuf> {
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 <PATH_TO_PROFILE>.
// 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 <url> <dir>" -> last arg is dir.
// "clone <url>" -> 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"))
}

View file

@ -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(())
}

81
src/manage.rs Normal file
View file

@ -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<PathBuf> {
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(())
}

21
src/sanitizer.rs Normal file
View file

@ -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"),
]
}

26
src/utils.rs Normal file
View file

@ -0,0 +1,26 @@
use anyhow::{anyhow, Result};
use std::path::PathBuf;
pub fn expand_path(path_str: &str) -> Result<PathBuf> {
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))
}

278
tests/integration_test.rs Normal file
View file

@ -0,0 +1,278 @@
use assert_cmd::Command;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_config_initialization() -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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(())
}