Compare commits

..

3 commits

Author SHA1 Message Date
inx
9a16f9a22e
chore: extend cargo informations 2026-01-28 21:20:59 +08:00
inx
adce0a5e0d
chore(rename): rename project from 'gosh' to 'naj'
This rebrands the CLI tool to 'naj' (Old Chinese reconstruction for "Me/I").
This name was chosen for better typing ergonomics (R-L-R alternation) and
availability on crates.io.

Changes:
- Update `Cargo.toml` package name to `naj`.
- Update binary name target to `naj`.
- Update documentation and README to reflect the new identity.

BREAKING CHANGE: The binary name is now `naj`. Users must update their
scripts and usage from `gosh` to `naj`.
2026-01-28 21:08:09 +08:00
inx
bb41d4acda
fix(mocking): some moking will leak
Fix leakage for the config while using `git config`.
2026-01-28 21:01:48 +08:00
15 changed files with 202 additions and 139 deletions

View file

@ -1,7 +1,16 @@
[package]
name = "gosh"
version = "0.1.1"
name = "naj"
version = "0.1.2"
edition = "2021"
description = "Manage your digital selves. (Derived from Old Chinese /*ŋˤajʔ/ 'I/Me')"
authors = ["INX <inx@inx.wf>"]
license = "BSD-2-Clause"
readme = "README.md"
homepage = "https://github.com/dotinx/naj"
repository = "https://github.com/dotinx/naj"
categories = ["command-line-utilities", "development-tools::cargo-plugins"]
exclude = ["tests/", "scripts/",]
[dependencies]
clap = { version = "4.5", features = ["derive"] }
@ -11,6 +20,10 @@ dirs = "5.0"
anyhow = "1.0"
[dev-dependencies]
assert_cmd = "2.0"
tempfile = "3.10"
assert_cmd = "2.1"
tempfile = "3.24"
predicates = "3.1"
[[bin]]
name = "naj"
path = "src/main.rs"

View file

@ -1,14 +1,14 @@
# Gosh (Git Operations SHell)
# Naj (我 `/*ŋˤajʔ/`)
**Gosh** is a lightweight, secure, and idempotent wrapper for Git, written in Rust. It solves the chaos of managing multiple Git identities (Work vs. Personal) by strictly isolating configurations and preventing accidental identity leaks.
**Naj** is a lightweight, secure, and idempotent wrapper for Git, written in Rust. It solves the chaos of managing multiple Git identities (Work vs. Personal) by strictly isolating configurations and preventing accidental identity leaks.
## 🚀 Features
* **🛡️ Fail-Safe Security**: Uses "Blind Injection" to forcibly wipe global identity keys before applying a profile. If your profile lacks a key, Gosh fails securely rather than falling back to your global `~/.gitconfig`.
* **⚡ Ephemeral Execution**: Run commands like `gosh work commit` without modifying any files on disk. Perfect for one-off fixes.
* **🛡️ Fail-Safe Security**: Uses "Blind Injection" to forcibly wipe global identity keys before applying a profile. If your profile lacks a key, Naj fails securely rather than falling back to your global `~/.gitconfig`.
* **⚡ Ephemeral Execution**: Run commands like `naj work commit` without modifying any files on disk. Perfect for one-off fixes.
* **💾 Persistent Switching**: Permanently bind a repository to an identity using Git's native `[include]` directive.
* **🛠️ Zero Config Setup**: Automatically handles `clone` and `init` setup, applying the correct identity immediately.
* **📂 Portable Profiles**: Profiles are stored in `~/.config/gosh/profiles/`, designed to be synced via a private Git repository.
* **📂 Portable Profiles**: Profiles are stored in `~/.config/naj/profiles/`, designed to be synced via a private Git repository.
## 📦 Installation
@ -22,28 +22,28 @@ cargo install --path .
### 1. Management: Create Identities
Gosh manages identities as "Profiles".
Naj manages identities as "Profiles".
```bash
# Syntax: gosh -c <Name> <Email> <ProfileID>
gosh -c "Alice Work" "alice@company.com" "work"
gosh -c "Alice Hobby" "alice@gmail.com" "personal"
# Syntax: naj -c <Name> <Email> <ProfileID>
naj -c "Alice Work" "alice@company.com" "work"
naj -c "Alice Hobby" "alice@gmail.com" "personal"
# List all profiles
gosh -l
naj -l
# Edit a profile (e.g., to add signingkey or sshCommand)
gosh -e work
naj -e work
```
### 2. Workflow A: Setup New Projects (Recommended)
When you clone or init a repository, Gosh automatically sets up the local config.
When you clone or init a repository, Naj automatically sets up the local config.
```bash
# Clones the repo and immediately binds it to the "work" profile
gosh work clone git@github.com:company/backend.git
naj work clone git@github.com:company/backend.git
# Inside the repo, you can now just use standard git
cd backend
@ -57,10 +57,10 @@ Run a command with a specific identity *without* modifying the repository config
```bash
# Temporarily commit as "personal" in a work repo (e.g., fixing a typo)
gosh personal commit -m "Fix typo"
naj personal commit -m "Fix typo"
# Verification
gosh personal config user.email
naj personal config user.email
```
@ -70,21 +70,21 @@ Change the identity bound to an existing repository.
```bash
cd my-repo
gosh work
naj work
# If the repo has "dirty" config (manually set user.name), force overwrite it:
gosh work -f
naj work -f
```
## ⚙️ Configuration
Gosh follows the XDG Base Directory specification.
Naj follows the XDG Base Directory specification.
* **Config File**: `~/.config/gosh/gosh.toml`
* **Profiles**: `~/.config/gosh/profiles/*.gitconfig`
* **Config File**: `~/.config/naj/naj.toml`
* **Profiles**: `~/.config/naj/profiles/*.gitconfig`
On the first run, Gosh will automatically create these directories and a default configuration file.
On the first run, Naj will automatically create these directories and a default configuration file.
### Environment Variables
@ -93,9 +93,9 @@ On the first run, Gosh will automatically create these directories and a default
## 🔒 Security Design: Blind Injection
In **Exec Mode**, Gosh does **not** read your local configuration to decide what to override. Instead, it aggressively injects empty values for sensitive keys before applying your profile.
In **Exec Mode**, Naj does **not** read your local configuration to decide what to override. Instead, it aggressively injects empty values for sensitive keys before applying your profile.
**Example command generated by Gosh:**
**Example command generated by Naj:**
```bash
git \
@ -103,7 +103,7 @@ git \
-c user.email="" \
-c user.signingkey="" \
-c commit.gpgsign=false \
-c include.path=~/.config/gosh/profiles/work.gitconfig \ # 2. Apply Profile
-c include.path=~/.config/naj/profiles/work.gitconfig \ # 2. Apply Profile
commit ...
```

View file

@ -2,7 +2,7 @@
set -e # 遇到错误立即停止
# --- 配置部分 ---
APP_NAME="gosh"
APP_NAME="naj"
OUTPUT_DIR="dist"
# 新增:获取 dist 目录的绝对物理路径
mkdir -p "$OUTPUT_DIR"
@ -65,7 +65,7 @@ for target in "${TARGETS[@]}"; do
exit 1
fi
# 3. 打包文件名格式: gosh-v0.1.0-x86_64-unknown-linux-musl.tar.gz
# 3. 打包文件名格式: naj-v0.1.0-x86_64-unknown-linux-musl.tar.gz
ARCHIVE_NAME="${APP_NAME}-v${VERSION}-${target}"
# 进入输出目录进行打包操作

View file

@ -1,10 +1,10 @@
# Gosh Integration & Scenario Tests
# Naj Integration & Scenario Tests
This directory contains the integration test suite and scenario simulations for `gosh`. These scripts are designed to validate the core functionality, security isolation, and developer workflows in a controlled, sandbox environment.
This directory contains the integration test suite and scenario simulations for `naj`. These scripts are designed to validate the core functionality, security isolation, and developer workflows in a controlled, sandbox environment.
## Overview
The tests in this directory focus on end-to-end (E2E) validation, ensuring that `gosh` correctly interacts with Git configurations, SSH keys, and system environments without contaminating the user's global settings.
The tests in this directory focus on end-to-end (E2E) validation, ensuring that `naj` correctly interacts with Git configurations, SSH keys, and system environments without contaminating the user's global settings.
## Test Matrix
@ -21,7 +21,7 @@ The tests in this directory focus on end-to-end (E2E) validation, ensuring that
To run these tests locally, ensure you have the following installed:
- **Bash**: Most scripts use standard Bash features.
- **Git**: Version 2.34+ is required for SSH signing tests.
- **Gosh**: The `gosh` binary must be available in your PATH or accessible via the `GOSH_CMD` environment variable.
- **Naj**: The `naj` binary must be available in your PATH or accessible via the `GOSH_CMD` environment variable.
## Running Tests
@ -42,7 +42,7 @@ You can run individual test scripts directly:
bash scripts/tests/alice.sh
```
Each script initializes a sandbox in `/tmp/gosh_test_*` or similar, ensuring that your `~/.gitconfig` and `~/.ssh` remain untouched.
Each script initializes a sandbox in `/tmp/naj_test_*` or similar, ensuring that your `~/.gitconfig` and `~/.ssh` remain untouched.
## Design Principles

View file

@ -2,10 +2,10 @@
set -e
# --- 0. 环境与工具准备 ---
GOSH_CMD="gosh" # 确保已编译或 alias 到 cargo run
GOSH_CMD="naj" # 确保已编译或 alias 到 cargo run
BASE_DIR="/tmp/alice_demo_signed"
# 隔离 Gosh 配置
# 隔离 Naj 配置
export GOSH_CONFIG_PATH="$BASE_DIR/config"
# 隔离 SSH 密钥目录
SSH_DIR="$BASE_DIR/ssh_keys"
@ -44,14 +44,14 @@ info "Generated Work Key: $SSH_DIR/id_work"
ssh-keygen -t ed25519 -C "alice@alice.com" -f "$SSH_DIR/id_personal" -N "" -q
info "Generated Personal Key: $SSH_DIR/id_personal"
# --- 3. 使用 Gosh 创建 Profile 并注入签名配置 ---
log "Creating Gosh Profiles..."
# --- 3. 使用 Naj 创建 Profile 并注入签名配置 ---
log "Creating Naj Profiles..."
# 3.1 创建基础 Work Profile
$GOSH_CMD -c "Alice Work" "alice@contoso.com" "work"
# 3.2 手动追加 SSH 签名配置到 Work Profile
# 这里演示了 Gosh 的灵活性:你可以手动编辑生成的 .gitconfig
# 这里演示了 Naj 的灵活性:你可以手动编辑生成的 .gitconfig
WORK_PROFILE="$GOSH_CONFIG_PATH/profiles/work.gitconfig"
cat >> "$WORK_PROFILE" <<EOF
[gpg]
@ -90,7 +90,7 @@ cd "$REPO_DIR"
# 模拟远程仓库
git init --bare --quiet "backend.git"
# 使用 Gosh 克隆 (Clone -> Infer -> Switch)
# 使用 Naj 克隆 (Clone -> Infer -> Switch)
# 注意:这里我们 Clone 本地路径,但 core.sshCommand 依然会被配置进去,这是符合预期的
$GOSH_CMD work clone "$REPO_DIR/backend.git" work-backend
cd work-backend
@ -148,12 +148,12 @@ fi
log "Scenario C: Ephemeral Execution (Security Check)"
# 当前在 oss-project (Personal),我们想用 Work 身份签个名
# 执行 gosh work commit
# 执行 naj work commit
$GOSH_CMD work commit --allow-empty -m "Hotfix via Exec" > /dev/null
# 验证最后一次提交的签名
# 注意Exec 模式下,Gosh 会通过 -c user.signingkey="" 先清空,再注入 work profile
# 如果这一步成功且签名了,说明 Gosh 正确注入了 id_work.pub
# 注意Exec 模式下,Naj 会通过 -c user.signingkey="" 先清空,再注入 work profile
# 如果这一步成功且签名了,说明 Naj 正确注入了 id_work.pub
LATEST_COMMIT_MSG=$(git log -1 --pretty=%B)
info "Latest commit: $LATEST_COMMIT_MSG"

View file

@ -2,10 +2,10 @@
set -e
# --- 0. 环境与工具准备 ---
GOSH_CMD="gosh" # 确保已编译或 alias 到 cargo run
GOSH_CMD="naj" # 确保已编译或 alias 到 cargo run
BASE_DIR="/tmp/alice_demo_debug"
# 隔离 Gosh 配置
# 隔离 Naj 配置
export GOSH_CONFIG_PATH="$BASE_DIR/config"
# 隔离 SSH 密钥目录
SSH_DIR="$BASE_DIR/ssh_keys"
@ -54,8 +54,8 @@ info "Generated Work Key: .../id_work"
ssh-keygen -t ed25519 -C "alice@alice.com" -f "$SSH_DIR/id_personal" -N "" -q
info "Generated Personal Key: .../id_personal"
# --- 3. 使用 Gosh 创建 Profile ---
log "Creating Gosh Profiles..."
# --- 3. 使用 Naj 创建 Profile ---
log "Creating Naj Profiles..."
# 3.1 Work Profile
$GOSH_CMD -c "Alice Work" "alice@contoso.com" "work"
@ -94,8 +94,8 @@ log "Scenario A: Setup Mode (Work Repo)"
cd "$REPO_DIR"
git init --bare --quiet "backend.git"
# 使用 Gosh 克隆
info "Running: gosh work clone ..."
# 使用 Naj 克隆
info "Running: naj work clone ..."
$GOSH_CMD work clone "$REPO_DIR/backend.git" work-backend
cd work-backend
@ -114,7 +114,7 @@ git init --quiet "oss-project"
cd oss-project
# 切换到 Personal
info "Running: gosh personal (Switching...)"
info "Running: naj personal (Switching...)"
$GOSH_CMD personal
# 提交
@ -128,9 +128,9 @@ debug_inspect
# === 场景 C: 临时执行与密钥隔离 (Exec Mode) ===
log "Scenario C: Ephemeral Execution (Security Check)"
info "Current Profile is: Personal (oss-project)"
info "Executing 'gosh work commit' (Should use Work Identity temporarily)..."
info "Executing 'naj work commit' (Should use Work Identity temporarily)..."
# 执行 gosh work commit
# 执行 naj work commit
# 注意:这里我们不再重定向到 /dev/null我们要看 git 的原生输出
$GOSH_CMD work commit --allow-empty -m "Hotfix via Exec (Scenario C)"

View file

@ -2,10 +2,10 @@
set -e
# --- 0. 全局配置 ---
GOSH_CMD="gosh"
BASE_DIR="/tmp/gosh_collab_demo"
GOSH_CMD="naj"
BASE_DIR="/tmp/naj_collab_demo"
# 隔离 Gosh 配置
# 隔离 Naj 配置
export GOSH_CONFIG_PATH="$BASE_DIR/config"
# 隔离 SSH 密钥目录
SSH_DIR="$BASE_DIR/ssh_keys"
@ -79,8 +79,8 @@ ssh-keygen -t ed25519 -C "bob@partner.org" -f "$SSH_DIR/id_bob" -N "" -q
echo "bob@partner.org $(cat $SSH_DIR/id_bob.pub)" >> "$ALLOWED_SIGNERS"
ok "Generated Bob's Key & Added to Trust Store"
# --- 3. 配置 Gosh Profiles ---
log "Configuring Gosh Profiles..."
# --- 3. 配置 Naj Profiles ---
log "Configuring Naj Profiles..."
# --> Alice Profile
$GOSH_CMD -c "Alice Work" "alice@contoso.com" "alice_work"
@ -157,7 +157,7 @@ verify_last_commit "bob@partner.org" "bob_partner"
# 2. Alice 临时修复 (不切换 Profile直接用 Exec)
# 当前 Profile 依然是 bob_partner (可以通过 .git/config 验证)
# Alice 用 gosh alice_work exec 临时提交
# Alice 用 naj alice_work exec 临时提交
echo ">>> [Commit 4] Alice hotfixes via Exec Mode"
echo "// Hotfix" >> main.rs
git add main.rs

View file

@ -2,8 +2,8 @@
set -e
# --- 配置 ---
GOSH_CMD="gosh"
BASE_DIR="/tmp/gosh_edge_test"
GOSH_CMD="naj"
BASE_DIR="/tmp/naj_edge_test"
export GOSH_CONFIG_PATH="$BASE_DIR/config"
REPO_DIR="$BASE_DIR/repos"
@ -30,7 +30,7 @@ mkdir -p src/deep/level
cd src/deep/level
echo "Current dir: $(pwd)"
echo "Executing 'gosh edge' from subdirectory..."
echo "Executing 'naj edge' from subdirectory..."
# 执行 switch
$GOSH_CMD edge
@ -55,7 +55,7 @@ cd "$DIR_WITH_SPACE"
git init --quiet
echo "Current dir: $(pwd)"
echo "Executing 'gosh edge'..."
echo "Executing 'naj edge'..."
$GOSH_CMD edge

View file

@ -1,8 +1,8 @@
#!/bin/bash
# --- 准备 ---
GOSH_CMD="gosh" # 确保已编译或 alias
BASE_DIR="/tmp/gosh_security_test"
GOSH_CMD="naj" # 确保已编译或 alias
BASE_DIR="/tmp/naj_security_test"
UNSAFE_REPO="$BASE_DIR/root_owned_repo"
# 1. 初始化一个归属于 root 的仓库 (对当前用户来说是不安全的)
@ -17,14 +17,14 @@ sudo touch "$UNSAFE_REPO/testfile"
# 确保当前用户对目录有读写权限(以便能进入),但 .git 依然属于 root
sudo chmod -R 777 "$UNSAFE_REPO"
echo "[TEST] Running 'gosh' in a dubious ownership repo..."
echo "[TEST] Running 'naj' in a dubious ownership repo..."
cd "$UNSAFE_REPO"
# 2. 尝试运行 gosh (期望失败)
# 2. 尝试运行 naj (期望失败)
if $GOSH_CMD -l > /dev/null 2>&1; then
# 注意:gosh -l 不需要 git 仓库,所以应该成功。
# 注意:naj -l 不需要 git 仓库,所以应该成功。
# 我们需要测 switch 或 exec这需要 git 上下文
echo " (gosh list works, which is fine)"
echo " (naj list works, which is fine)"
fi
echo "Attempting to switch profile..."
@ -33,13 +33,13 @@ OUTPUT=$($GOSH_CMD testprofile 2>&1 || true)
# 3. 验证结果
if echo "$OUTPUT" | grep -q "fatal: detected dubious ownership"; then
echo "✅ PASS: Gosh propagated Git's security error."
echo "✅ PASS: Naj propagated Git's security error."
echo " Git said: 'detected dubious ownership'"
echo " Gosh refused to act."
echo " Naj refused to act."
elif echo "$OUTPUT" | grep -q "Not a git repository"; then
echo "✅ PASS: Gosh treated it as invalid (Git rev-parse failed)."
echo "✅ PASS: Naj treated it as invalid (Git rev-parse failed)."
else
echo "❌ FAIL: Gosh tried to execute! This is dangerous."
echo "❌ FAIL: Naj tried to execute! This is dangerous."
echo "Output was: $OUTPUT"
exit 1
fi

View file

@ -1,7 +1,7 @@
use clap::{Parser, Args};
#[derive(Parser, Debug)]
#[command(name = "gosh")]
#[command(name = "naj")]
#[command(about = "Git Operations Shell", long_about = None)]
pub struct Cli {
#[command(flatten)]

View file

@ -11,19 +11,19 @@ pub struct Strategies {
}
#[derive(Serialize, Deserialize, Debug)]
pub struct GoshConfig {
pub struct NajConfig {
pub strategies: Strategies,
pub profile_dir: String,
}
impl Default for GoshConfig {
impl Default for NajConfig {
fn default() -> Self {
GoshConfig {
NajConfig {
strategies: Strategies {
clone: "INCLUDE".to_string(),
switch: "include".to_string(),
},
profile_dir: "~/.config/gosh/profiles".to_string(),
profile_dir: "~/.config/naj/profiles".to_string(),
}
}
}
@ -33,23 +33,23 @@ pub fn get_config_root() -> Result<PathBuf> {
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"))
Ok(config_dir.join("naj"))
}
pub fn load_config() -> Result<GoshConfig> {
pub fn load_config() -> Result<NajConfig> {
let root = get_config_root()?;
let config_path = root.join("gosh.toml");
let config_path = root.join("naj.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")?;
let config: NajConfig = toml::from_str(&content).context("Failed to parse config file")?;
Ok(config)
}
fn initialize_config(root: &Path, config_path: &Path) -> Result<GoshConfig> {
fn initialize_config(root: &Path, config_path: &Path) -> Result<NajConfig> {
// Ensure root exists
fs::create_dir_all(root).context("Failed to create config root")?;
@ -62,7 +62,7 @@ fn initialize_config(root: &Path, config_path: &Path) -> Result<GoshConfig> {
// 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()
"~/.config/naj/profiles".to_string()
};
// Use toml serialization to ensure string is escaped properly?
@ -70,7 +70,7 @@ fn initialize_config(root: &Path, config_path: &Path) -> Result<GoshConfig> {
// 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
let generated_toml = format!(r#"# Naj Configuration
profile_dir = "{}"
@ -85,6 +85,6 @@ switch = "include" # Soft strategy
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)?;
let config: NajConfig = toml::from_str(&generated_toml)?;
Ok(config)
}

View file

@ -1,4 +1,4 @@
use crate::config::GoshConfig;
use crate::config::NajConfig;
use crate::sanitizer;
use crate::utils::expand_path;
use anyhow::{Result, anyhow, Context};
@ -11,7 +11,7 @@ enum Action {
Switch,
}
pub fn run(config: &GoshConfig, profile_id: &str, args: &[String], force: bool) -> Result<()> {
pub fn run(config: &NajConfig, profile_id: &str, args: &[String], force: bool) -> Result<()> {
// Determine Action
let action = if args.is_empty() {
Action::Switch
@ -28,7 +28,7 @@ pub fn run(config: &GoshConfig, profile_id: &str, args: &[String], force: bool)
}
}
fn get_profile_path(config: &GoshConfig, id: &str) -> Result<PathBuf> {
fn get_profile_path(config: &NajConfig, id: &str) -> Result<PathBuf> {
let profile_dir = expand_path(&config.profile_dir)?;
let p = profile_dir.join(format!("{}.gitconfig", id));
if !p.exists() {
@ -70,7 +70,7 @@ fn run_command(cmd: &mut Command) -> Result<()> {
Ok(())
}
fn get_profile_dir(config: &GoshConfig) -> Result<PathBuf> {
fn get_profile_dir(config: &NajConfig) -> Result<PathBuf> {
expand_path(&config.profile_dir)
}
@ -90,21 +90,25 @@ fn clean_existing_profiles(profile_dir: &Path) -> Result<()> {
// Check if the path belongs to our profile directory
// We use string containment as a heuristic since paths might be canonicalized differently
// but typically we add absolute paths.
let is_gosh_profile = val.contains(&profile_dir.to_string_lossy().to_string())
let is_naj_profile = val.contains(&profile_dir.to_string_lossy().to_string())
|| (val.contains("/profiles/") && val.ends_with(".gitconfig"));
if is_gosh_profile {
if is_naj_profile {
let mut cmd = Command::new("git");
cmd.args(&["config", "--local", "--unset", "include.path", val]);
// We tolerate failure here (e.g. if key doesn't exist anymore for some reason)
let _ = cmd.output();
if is_mocking() {
eprintln!("[DRY-RUN] {:?}", cmd);
} else {
let _ = cmd.output();
}
}
}
Ok(())
}
fn run_exec(config: &GoshConfig, profile_id: &str, args: &[String]) -> Result<()> {
fn run_exec(config: &NajConfig, profile_id: &str, args: &[String]) -> Result<()> {
let profile_path = get_profile_path(config, profile_id)?;
let injections = sanitizer::BLIND_INJECTIONS;
@ -128,7 +132,7 @@ fn run_exec(config: &GoshConfig, profile_id: &str, args: &[String]) -> Result<()
run_command(&mut cmd)
}
fn run_switch(config: &GoshConfig, profile_id: &str, force: bool) -> Result<()> {
fn run_switch(config: &NajConfig, profile_id: &str, force: bool) -> Result<()> {
let status = Command::new("git")
.args(&["rev-parse", "--is-inside-work-tree"])
.stdout(std::process::Stdio::null())
@ -165,7 +169,11 @@ fn run_switch(config: &GoshConfig, profile_id: &str, force: bool) -> Result<()>
let mut cmd = Command::new("git");
cmd.args(&["config", "--remove-section", section]);
// Ignore errors
let _ = cmd.output();
if is_mocking() {
eprintln!("[DRY-RUN] {:?}", cmd);
} else {
let _ = cmd.output();
}
}
// Unset keys
@ -173,11 +181,15 @@ fn run_switch(config: &GoshConfig, profile_id: &str, force: bool) -> Result<()>
for key in keys {
let mut cmd = Command::new("git");
cmd.args(&["config", "--unset", key]);
let _ = cmd.output();
if is_mocking() {
eprintln!("[DRY-RUN] {:?}", cmd);
} else {
let _ = cmd.output();
}
}
}
// 0. Clean existing gosh profiles
// 0. Clean existing naj profiles
let profiles_dir = get_profile_dir(config)?;
clean_existing_profiles(&profiles_dir)?;
@ -193,7 +205,7 @@ fn run_switch(config: &GoshConfig, profile_id: &str, force: bool) -> Result<()>
Ok(())
}
fn run_setup(config: &GoshConfig, profile_id: &str, args: &[String]) -> Result<()> {
fn run_setup(config: &NajConfig, profile_id: &str, args: &[String]) -> Result<()> {
// 1. Pass raw args to git (no injection)
let mut cmd = Command::new("git");
cmd.args(args);
@ -294,7 +306,7 @@ fn warn_if_dirty_config(profile_id: &str) -> Result<()> {
println!(" These settings (like signing keys) are merging with your profile");
println!(" and causing a \"Frankenstein Identity\".");
println!("");
println!(" 👉 Fix it: Run 'gosh {} -f' to force clean.\n", profile_id);
println!(" 👉 Fix it: Run 'naj {} -f' to force clean.\n", profile_id);
}
}
Ok(())

View file

@ -2,15 +2,15 @@ use anyhow::{bail, Context, Result};
use std::fs;
use std::path::PathBuf;
use std::process::Command;
use crate::config::GoshConfig;
use crate::config::NajConfig;
use crate::utils::expand_path;
fn get_profile_path(config: &GoshConfig, id: &str) -> Result<PathBuf> {
fn get_profile_path(config: &NajConfig, 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<()> {
pub fn create_profile(config: &NajConfig, name: &str, email: &str, id: &str) -> Result<()> {
let file_path = get_profile_path(config, id)?;
if file_path.exists() {
@ -28,7 +28,7 @@ pub fn create_profile(config: &GoshConfig, name: &str, email: &str, id: &str) ->
Ok(())
}
pub fn remove_profile(config: &GoshConfig, id: &str) -> Result<()> {
pub fn remove_profile(config: &NajConfig, id: &str) -> Result<()> {
let file_path = get_profile_path(config, id)?;
if !file_path.exists() {
@ -40,7 +40,7 @@ pub fn remove_profile(config: &GoshConfig, id: &str) -> Result<()> {
Ok(())
}
pub fn edit_profile(config: &GoshConfig, id: &str) -> Result<()> {
pub fn edit_profile(config: &NajConfig, id: &str) -> Result<()> {
let file_path = get_profile_path(config, id)?;
if !file_path.exists() {
@ -60,7 +60,7 @@ pub fn edit_profile(config: &GoshConfig, id: &str) -> Result<()> {
Ok(())
}
pub fn list_profiles(config: &GoshConfig) -> Result<()> {
pub fn list_profiles(config: &NajConfig) -> Result<()> {
let profile_dir = expand_path(&config.profile_dir)?;
if !profile_dir.exists() {

View file

@ -7,15 +7,15 @@ 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"));
// Run naj with GOSH_CONFIG_PATH set to temp dir
let mut cmd = Command::new(env!("CARGO_BIN_EXE_naj"));
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("naj.toml").exists());
assert!(config_path.join("profiles").exists());
Ok(())
@ -26,7 +26,7 @@ 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"));
let mut cmd = Command::new(env!("CARGO_BIN_EXE_naj"));
cmd.env("GOSH_CONFIG_PATH", config_path)
.args(&["-c", "Test User", "test@example.com", "test_user"])
.assert()
@ -40,7 +40,7 @@ fn test_profile_creation_and_listing() -> Result<(), Box<dyn std::error::Error>>
assert!(content.contains("email = test@example.com"));
// Verify list
let mut cmd_list = Command::new(env!("CARGO_BIN_EXE_gosh"));
let mut cmd_list = Command::new(env!("CARGO_BIN_EXE_naj"));
cmd_list.env("GOSH_CONFIG_PATH", config_path)
.arg("-l")
.assert()
@ -56,14 +56,14 @@ fn test_duplicate_creation_failure() -> Result<(), Box<dyn std::error::Error>> {
let config_path = temp_dir.path();
// Create first
Command::new(env!("CARGO_BIN_EXE_gosh"))
Command::new(env!("CARGO_BIN_EXE_naj"))
.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"))
Command::new(env!("CARGO_BIN_EXE_naj"))
.env("GOSH_CONFIG_PATH", config_path)
.args(&["-c", "User2", "u2@e.com", "dup_test"])
.assert()
@ -79,7 +79,7 @@ fn test_remove_profile() -> Result<(), Box<dyn std::error::Error>> {
let profile_path = config_path.join("profiles").join("rem_test.gitconfig");
// Create
Command::new(env!("CARGO_BIN_EXE_gosh"))
Command::new(env!("CARGO_BIN_EXE_naj"))
.env("GOSH_CONFIG_PATH", config_path)
.args(&["-c", "User", "u@e.com", "rem_test"])
.assert()
@ -87,7 +87,7 @@ fn test_remove_profile() -> Result<(), Box<dyn std::error::Error>> {
assert!(profile_path.exists());
// Remove
Command::new(env!("CARGO_BIN_EXE_gosh"))
Command::new(env!("CARGO_BIN_EXE_naj"))
.env("GOSH_CONFIG_PATH", config_path)
.args(&["-r", "rem_test"])
.assert()
@ -95,7 +95,7 @@ fn test_remove_profile() -> Result<(), Box<dyn std::error::Error>> {
assert!(!profile_path.exists());
// Remove non-existent
Command::new(env!("CARGO_BIN_EXE_gosh"))
Command::new(env!("CARGO_BIN_EXE_naj"))
.env("GOSH_CONFIG_PATH", config_path)
.args(&["-r", "rem_test"])
.assert()
@ -110,14 +110,14 @@ fn test_exec_dry_run_injection_strict() -> Result<(), Box<dyn std::error::Error>
let config_path = temp_dir.path();
// Create a profile first
Command::new(env!("CARGO_BIN_EXE_gosh"))
Command::new(env!("CARGO_BIN_EXE_naj"))
.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"));
let mut cmd = Command::new(env!("CARGO_BIN_EXE_naj"));
cmd.env("GOSH_CONFIG_PATH", config_path)
.env("GOSH_MOCKING", "1")
.args(&["p1", "commit", "-m", "foo"])
@ -150,14 +150,14 @@ fn test_switch_mode_persistent() -> Result<(), Box<dyn std::error::Error>> {
.output()?;
// Create profile
Command::new(env!("CARGO_BIN_EXE_gosh"))
Command::new(env!("CARGO_BIN_EXE_naj"))
.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"))
Command::new(env!("CARGO_BIN_EXE_naj"))
.env("GOSH_CONFIG_PATH", &config_path)
.current_dir(&repo_dir)
.arg("switch_test")
@ -197,14 +197,14 @@ fn test_switch_force_mode_sanitization() -> Result<(), Box<dyn std::error::Error
fs::write(&config_file, current_config)?;
// Create profile
Command::new(env!("CARGO_BIN_EXE_gosh"))
Command::new(env!("CARGO_BIN_EXE_naj"))
.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"))
Command::new(env!("CARGO_BIN_EXE_naj"))
.env("GOSH_CONFIG_PATH", &config_path)
.current_dir(&repo_dir)
.args(&["force_test", "-f"])
@ -240,21 +240,21 @@ fn test_setup_mode_local_clone() -> Result<(), Box<dyn std::error::Error>> {
// Git allows cloning an empty repo, so this is enough
// 2. Create Profile
Command::new(env!("CARGO_BIN_EXE_gosh"))
Command::new(env!("CARGO_BIN_EXE_naj"))
.env("GOSH_CONFIG_PATH", &config_path)
.args(&["-c", "CloneUser", "c@e.com", "clone_test"])
.assert()
.success();
// 3. Run Gosh Clone (Setup Mode)
// 3. Run Naj Clone (Setup Mode)
// We clone from local source to a folder named "dest_repo"
// Gosh should:
// Naj 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"))
Command::new(env!("CARGO_BIN_EXE_naj"))
.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])
@ -270,7 +270,7 @@ fn test_setup_mode_local_clone() -> Result<(), Box<dyn std::error::Error>> {
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
// Verify that naj automatically switched the profile after cloning
assert!(content.contains("[include]"));
assert!(content.contains("clone_test.gitconfig"));

View file

@ -0,0 +1,38 @@
use assert_cmd::Command;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_switch_force_mocking() -> 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");
fs::create_dir_all(&repo_dir)?;
std::process::Command::new("git")
.arg("init")
.current_dir(&repo_dir)
.output()?;
// Create profile
Command::new(env!("CARGO_BIN_EXE_naj"))
.env("GOSH_CONFIG_PATH", &config_path)
.args(&["-c", "User", "u@e.com", "mock_test"])
.assert()
.success();
// Run force switch with mocking
Command::new(env!("CARGO_BIN_EXE_naj"))
.env("GOSH_CONFIG_PATH", &config_path)
.env("GOSH_MOCKING", "1")
.current_dir(&repo_dir)
.args(&["mock_test", "-f"])
.assert()
.success()
// Check for dry-run output of cleanup commands
.stderr(predicates::str::contains("config"))
.stderr(predicates::str::contains("--remove-section"))
.stderr(predicates::str::contains("user"));
Ok(())
}