sem-cli 0.10.1

Semantic version control CLI. Shows what entities changed (functions, classes, methods) instead of lines.
#![cfg(unix)]

use std::env;
use std::ffi::OsString;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};

static NEXT_ID: AtomicUsize = AtomicUsize::new(0);

struct TempDir {
    path: PathBuf,
}

impl TempDir {
    fn new(name: &str) -> Self {
        let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
        let nanos = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let path = env::temp_dir().join(format!(
            "sem-cli-{name}-{}-{nanos}-{id}",
            std::process::id()
        ));
        fs::create_dir_all(&path).unwrap();
        Self { path }
    }

    fn path(&self) -> &Path {
        &self.path
    }
}

impl Drop for TempDir {
    fn drop(&mut self) {
        let _ = fs::remove_dir_all(&self.path);
    }
}

struct IsolatedEnv {
    _tmp: TempDir,
    home: PathBuf,
    gitconfig: PathBuf,
    path: OsString,
}

impl IsolatedEnv {
    fn new(name: &str) -> Self {
        let tmp = TempDir::new(name);
        let home = tmp.path().join("home");
        fs::create_dir_all(&home).unwrap();

        let sem_dir = sem_bin().parent().unwrap().to_path_buf();
        let path =
            env::join_paths([sem_dir, PathBuf::from("/usr/bin"), PathBuf::from("/bin")]).unwrap();

        Self {
            gitconfig: tmp.path().join("gitconfig"),
            _tmp: tmp,
            home,
            path,
        }
    }

    fn apply(&self, command: &mut Command) {
        command
            .env("HOME", &self.home)
            .env("GIT_CONFIG_GLOBAL", &self.gitconfig)
            .env("PATH", &self.path)
            .env("NO_COLOR", "1")
            .env_remove("GIT_EXTERNAL_DIFF");
    }

    fn wrapper_path(&self) -> PathBuf {
        self.home.join(".local/bin/sem-diff-wrapper")
    }
}

fn sem_bin() -> PathBuf {
    PathBuf::from(env!("CARGO_BIN_EXE_sem"))
}

fn output_text(output: &Output) -> String {
    format!(
        "stdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    )
}

fn assert_success(output: Output, context: &str) -> Output {
    assert!(
        output.status.success(),
        "{context} failed with status {:?}\n{}",
        output.status.code(),
        output_text(&output)
    );
    output
}

fn assert_failure(output: Output, context: &str) -> Output {
    assert!(
        !output.status.success(),
        "{context} unexpectedly succeeded\n{}",
        output_text(&output)
    );
    output
}

fn run_git(repo: &Path, env: &IsolatedEnv, args: &[&str]) -> Output {
    let mut command = Command::new("git");
    command.args(args).current_dir(repo);
    env.apply(&mut command);
    assert_success(
        command.output().unwrap(),
        &format!("git {}", args.join(" ")),
    )
}

fn run_sem(repo: &Path, env: &IsolatedEnv, args: &[&str]) -> Output {
    let mut command = Command::new(sem_bin());
    command.args(args).current_dir(repo);
    env.apply(&mut command);
    assert_success(
        command.output().unwrap(),
        &format!("sem {}", args.join(" ")),
    )
}

fn init_repo(root: &Path, env: &IsolatedEnv) {
    fs::create_dir_all(root).unwrap();
    run_git(root, env, &["init", "-q"]);
    run_git(root, env, &["config", "user.email", "t@t.com"]);
    run_git(root, env, &["config", "user.name", "test"]);
}

fn commit_python_file(repo: &Path, env: &IsolatedEnv) {
    fs::write(repo.join("a.py"), "def foo():\n    return 1\n").unwrap();
    run_git(repo, env, &["add", "a.py"]);
    run_git(repo, env, &["commit", "-q", "-m", "init"]);
}

fn path_from_git(repo: &Path, env: &IsolatedEnv, args: &[&str]) -> PathBuf {
    let output = run_git(repo, env, args);
    let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
    let path = PathBuf::from(raw);
    if path.is_absolute() {
        path
    } else {
        repo.join(path)
    }
}

#[test]
fn setup_writes_absolute_external_and_labels_cached_diff() {
    let tmp = TempDir::new("setup-path");
    let env = IsolatedEnv::new("setup-path-env");
    let repo = tmp.path().join("repo");
    init_repo(&repo, &env);
    commit_python_file(&repo, &env);

    fs::write(repo.join("a.py"), "def foo():\n    return 2\n").unwrap();
    run_git(&repo, &env, &["add", "a.py"]);

    run_sem(&repo, &env, &["setup"]);

    let config = run_git(
        &repo,
        &env,
        &["config", "--global", "--get", "diff.external"],
    );
    let configured = String::from_utf8_lossy(&config.stdout).trim().to_string();
    assert_eq!(configured, env.wrapper_path().display().to_string());
    assert!(Path::new(&configured).is_absolute());
    assert_ne!(configured, "sem-diff-wrapper");

    let wrapper = fs::read_to_string(env.wrapper_path()).unwrap();
    assert!(wrapper.contains("sem diff --label \"$1\" \"$2\" \"$5\""));
    assert!(!wrapper.contains("\"$@\""));

    let diff = run_git(&repo, &env, &["diff", "--cached"]);
    let stdout = String::from_utf8_lossy(&diff.stdout);
    assert!(
        stdout.contains("a.py"),
        "cached diff should use repo path\n{stdout}"
    );
    assert!(
        !stdout.contains("git-blob"),
        "cached diff should not expose temporary blob paths\n{stdout}"
    );
}

#[test]
fn unsetup_keeps_foreign_wrapper_when_external_is_unset() {
    let tmp = TempDir::new("unsetup-foreign");
    let env = IsolatedEnv::new("unsetup-foreign-env");
    fs::create_dir_all(env.wrapper_path().parent().unwrap()).unwrap();
    let foreign = "#!/bin/sh\n# hand edited wrapper\nexec sem diff \"$2\" \"$5\"\n";
    fs::write(env.wrapper_path(), foreign).unwrap();

    run_sem(tmp.path(), &env, &["unsetup"]);

    assert_eq!(fs::read_to_string(env.wrapper_path()).unwrap(), foreign);
}

#[test]
fn unsetup_unsets_external_but_keeps_modified_wrapper() {
    let tmp = TempDir::new("unsetup-modified");
    let env = IsolatedEnv::new("unsetup-modified-env");
    fs::create_dir_all(env.wrapper_path().parent().unwrap()).unwrap();
    let modified = "#!/bin/sh\n# locally modified wrapper\nexec sem diff \"$@\"\n";
    fs::write(env.wrapper_path(), modified).unwrap();
    run_git(
        tmp.path(),
        &env,
        &[
            "config",
            "--global",
            "diff.external",
            env.wrapper_path().to_str().unwrap(),
        ],
    );

    run_sem(tmp.path(), &env, &["unsetup"]);

    assert_eq!(fs::read_to_string(env.wrapper_path()).unwrap(), modified);
    let mut command = Command::new("git");
    command
        .args(["config", "--global", "--get", "diff.external"])
        .current_dir(tmp.path());
    env.apply(&mut command);
    assert_failure(command.output().unwrap(), "git config --get diff.external");
}

#[test]
fn unsetup_removes_owned_wrapper_when_external_points_to_it() {
    let tmp = TempDir::new("unsetup-owned");
    let env = IsolatedEnv::new("unsetup-owned-env");
    let repo = tmp.path().join("repo");
    init_repo(&repo, &env);

    run_sem(&repo, &env, &["setup"]);
    assert!(env.wrapper_path().exists());

    run_sem(&repo, &env, &["unsetup"]);
    assert!(!env.wrapper_path().exists());

    let mut command = Command::new("git");
    command
        .args(["config", "--global", "--get", "diff.external"])
        .current_dir(&repo);
    env.apply(&mut command);
    assert_failure(command.output().unwrap(), "git config --get diff.external");
}

#[test]
fn setup_and_unsetup_use_git_path_for_linked_worktree_hooks() {
    let tmp = TempDir::new("linked-hooks");
    let env = IsolatedEnv::new("linked-hooks-env");
    let repo = tmp.path().join("repo");
    let linked = tmp.path().join("repo-linked");
    init_repo(&repo, &env);
    commit_python_file(&repo, &env);
    run_git(
        &repo,
        &env,
        &[
            "worktree",
            "add",
            "-q",
            "--detach",
            linked.to_str().unwrap(),
            "HEAD",
        ],
    );

    run_sem(&linked, &env, &["setup"]);

    let hook_path = path_from_git(
        &linked,
        &env,
        &["rev-parse", "--git-path", "hooks/pre-commit"],
    );
    let wrong_hook_path =
        path_from_git(&linked, &env, &["rev-parse", "--git-dir"]).join("hooks/pre-commit");

    assert!(
        hook_path.exists(),
        "hook should be installed at {:?}",
        hook_path
    );
    assert_ne!(hook_path, wrong_hook_path);
    assert!(
        !wrong_hook_path.exists(),
        "hook should not be installed in linked worktree private git dir"
    );
    assert!(fs::read_to_string(&hook_path)
        .unwrap()
        .contains("# === sem pre-commit hook ==="));

    run_sem(&linked, &env, &["unsetup"]);
    assert!(
        !hook_path.exists(),
        "unsetup from linked worktree should remove the real hook"
    );
}