gitlogue 0.11.0

A Git history screensaver - watch your code rewrite itself
Documentation
use anyhow::Result;
use git2::{Repository, Signature, Time};
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};

fn unique_path(prefix: &str) -> PathBuf {
    static COUNTER: AtomicU64 = AtomicU64::new(0);

    std::env::temp_dir().join(format!(
        "{prefix}-{}-{}-{}",
        std::process::id(),
        SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_nanos(),
        COUNTER.fetch_add(1, Ordering::SeqCst)
    ))
}

struct TempHome {
    path: PathBuf,
}

struct TestRepo {
    path: PathBuf,
    repo: Repository,
}

impl TempHome {
    fn new() -> Result<Self> {
        let path = unique_path("gitlogue-cli-home");
        fs::create_dir_all(&path)?;
        Ok(Self { path })
    }

    fn config_path(&self) -> PathBuf {
        self.path.join(".config/gitlogue/config.toml")
    }
}

impl TestRepo {
    fn new() -> Result<Self> {
        let path = unique_path("gitlogue-cli-repo");
        fs::create_dir_all(&path)?;
        let repo = Repository::init(&path)?;
        Ok(Self { path, repo })
    }

    fn write_file(&self, relative_path: &str, content: &str) -> Result<()> {
        let file_path = self.path.join(relative_path);
        if let Some(parent) = file_path.parent() {
            fs::create_dir_all(parent)?;
        }
        fs::write(file_path, content)?;
        Ok(())
    }

    fn stage_file(&self, relative_path: &str) -> Result<()> {
        let mut index = self.repo.index()?;
        index.add_path(Path::new(relative_path))?;
        index.write()?;
        Ok(())
    }

    fn commit_file(
        &self,
        relative_path: &str,
        content: &str,
        message: &str,
        timestamp: i64,
    ) -> Result<String> {
        self.write_file(relative_path, content)?;
        self.stage_file(relative_path)?;

        let mut index = self.repo.index()?;
        let tree_id = index.write_tree()?;
        let tree = self.repo.find_tree(tree_id)?;
        let signature = Signature::new("Test User", "test@example.com", &Time::new(timestamp, 0))?;
        let parent = self
            .repo
            .head()
            .ok()
            .and_then(|head| head.peel_to_commit().ok());
        let oid = match parent.as_ref() {
            Some(parent_commit) => self.repo.commit(
                Some("HEAD"),
                &signature,
                &signature,
                message,
                &tree,
                &[parent_commit],
            )?,
            None => self
                .repo
                .commit(Some("HEAD"), &signature, &signature, message, &tree, &[])?,
        };

        Ok(oid.to_string())
    }
}

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

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

fn gitlogue_command() -> Command {
    Command::new(env!("CARGO_BIN_EXE_gitlogue"))
}

fn command_with_home(home: &TempHome) -> Command {
    let mut command = gitlogue_command();
    command
        .env("HOME", &home.path)
        .env("USERPROFILE", &home.path)
        .env_remove("HOMEDRIVE")
        .env_remove("HOMEPATH");
    command
}

fn run_command(command: &mut Command) -> Result<Output> {
    Ok(command.output()?)
}

fn shell_quote(value: &str) -> String {
    format!("'{}'", value.replace('\'', "'\\''"))
}

fn run_in_pseudo_tty(home: &TempHome, args: &[&str], input: &[u8]) -> Result<Output> {
    let command = std::iter::once(shell_quote(env!("CARGO_BIN_EXE_gitlogue")))
        .chain(args.iter().map(|arg| shell_quote(arg)))
        .collect::<Vec<_>>()
        .join(" ");
    let mut child = Command::new("script")
        .args(["-qefc", &command, "/dev/null"])
        .env("HOME", &home.path)
        .env("USERPROFILE", &home.path)
        .env_remove("HOMEDRIVE")
        .env_remove("HOMEPATH")
        .env("TERM", "xterm-256color")
        .env("COLUMNS", "100")
        .env("LINES", "40")
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()?;
    child
        .stdin
        .as_mut()
        .expect("script stdin should be piped")
        .write_all(input)?;
    let _ = child.stdin.take();
    Ok(child.wait_with_output()?)
}

fn stdout(output: &Output) -> String {
    String::from_utf8(output.stdout.clone()).unwrap()
}

fn stderr(output: &Output) -> String {
    String::from_utf8(output.stderr.clone()).unwrap()
}

fn repo_path(repo: &TestRepo) -> &Path {
    repo.path.as_path()
}

#[test]
fn license_flag_prints_third_party_licenses() -> Result<()> {
    let output = run_command(gitlogue_command().arg("--license"))?;
    assert!(output.status.success());
    let stdout = stdout(&output);

    assert!(stdout.starts_with(include_str!("../LICENSE-THIRD-PARTY")));

    Ok(())
}

#[test]
fn theme_subcommands_list_and_set_default_theme() -> Result<()> {
    let home = TempHome::new()?;

    let list_output = run_command(command_with_home(&home).args(["theme", "list"]))?;
    assert!(list_output.status.success());
    let list_stdout = stdout(&list_output);
    assert!(list_stdout.contains("Available themes:"));
    assert!(list_stdout.contains("  - nord"));

    let set_output = run_command(command_with_home(&home).args(["theme", "set", "nord"]))?;
    assert!(set_output.status.success());
    let set_stdout = stdout(&set_output);
    let config = fs::read_to_string(home.config_path())?;

    assert!(set_stdout.contains("Theme set to 'nord'"));
    assert!(config.contains("theme = \"nord\""));

    Ok(())
}

#[test]
fn theme_set_rejects_unknown_theme_without_writing_config() -> Result<()> {
    let home = TempHome::new()?;

    let output = run_command(command_with_home(&home).args(["theme", "set", "missing-theme"]))?;

    assert!(!output.status.success());
    assert_eq!(stdout(&output), "");
    assert!(stderr(&output).contains("Unknown theme: missing-theme"));
    assert!(stderr(&output).contains("Available themes:"));
    assert!(!home.config_path().exists());

    Ok(())
}

#[test]
fn diff_subcommand_reports_no_changes_for_clean_repo() -> Result<()> {
    let repo = TestRepo::new()?;

    let output = run_command(gitlogue_command().args([
        "--path",
        repo_path(&repo).to_str().unwrap(),
        "diff",
    ]))?;
    assert!(output.status.success());

    assert_eq!(stdout(&output), "No changes to display\n");

    Ok(())
}

#[test]
fn diff_subcommand_with_staged_changes_fails_only_after_ui_startup_without_tty() -> Result<()> {
    let repo = TestRepo::new()?;
    repo.commit_file("src/lib.rs", "fn clean() {}\n", "initial", 1)?;
    repo.write_file("src/lib.rs", "fn staged() {}\n")?;
    repo.stage_file("src/lib.rs")?;

    let output = run_command(gitlogue_command().args([
        "--path",
        repo_path(&repo).to_str().unwrap(),
        "diff",
    ]))?;

    assert!(!output.status.success());
    assert_eq!(stdout(&output), "");
    assert!(stderr(&output).contains("No such device or address"));

    Ok(())
}

#[test]
fn diff_subcommand_with_invalid_theme_fails_before_ui_startup() -> Result<()> {
    let repo = TestRepo::new()?;
    repo.commit_file("src/lib.rs", "fn clean() {}\n", "initial", 1)?;
    repo.write_file("src/lib.rs", "fn staged() {}\n")?;
    repo.stage_file("src/lib.rs")?;

    let output = run_command(gitlogue_command().args([
        "--path",
        repo_path(&repo).to_str().unwrap(),
        "diff",
        "--theme",
        "missing-theme",
    ]))?;

    assert!(!output.status.success());
    assert_eq!(stdout(&output), "");
    assert!(stderr(&output).contains("Unknown theme: missing-theme"));
    assert!(stderr(&output).contains("Available themes:"));
    assert!(!stderr(&output).contains("No such device or address"));

    Ok(())
}

#[test]
fn default_playback_fails_only_after_ui_startup_without_tty() -> Result<()> {
    let repo = TestRepo::new()?;
    repo.commit_file("src/main.rs", "fn main() {}\n", "initial", 1)?;

    let output =
        run_command(gitlogue_command().args(["--path", repo_path(&repo).to_str().unwrap()]))?;

    assert!(!output.status.success());
    assert_eq!(stdout(&output), "");
    assert!(stderr(&output).contains("No such device or address"));

    Ok(())
}

#[test]
fn default_playback_with_invalid_theme_fails_before_ui_startup() -> Result<()> {
    let repo = TestRepo::new()?;
    repo.commit_file("src/main.rs", "fn main() {}\n", "initial", 1)?;

    let output = run_command(gitlogue_command().args([
        "--path",
        repo_path(&repo).to_str().unwrap(),
        "--theme",
        "missing-theme",
    ]))?;

    assert!(!output.status.success());
    assert_eq!(stdout(&output), "");
    assert!(stderr(&output).contains("Unknown theme: missing-theme"));
    assert!(stderr(&output).contains("Available themes:"));
    assert!(!stderr(&output).contains("No such device or address"));

    Ok(())
}

#[test]
fn default_playback_quits_cleanly_in_pseudo_tty() -> Result<()> {
    let home = TempHome::new()?;
    let repo = TestRepo::new()?;
    repo.commit_file("src/main.rs", "fn main() {}\n", "initial", 1)?;

    let output = run_in_pseudo_tty(&home, &["--path", repo_path(&repo).to_str().unwrap()], b"q")?;

    assert!(output.status.success());
    assert_eq!(stderr(&output), "");

    Ok(())
}

#[test]
fn diff_subcommand_with_staged_changes_quits_cleanly_in_pseudo_tty() -> Result<()> {
    let home = TempHome::new()?;
    let repo = TestRepo::new()?;
    repo.commit_file("src/lib.rs", "fn clean() {}\n", "initial", 1)?;
    repo.write_file("src/lib.rs", "fn staged() {}\n")?;
    repo.stage_file("src/lib.rs")?;

    let output = run_in_pseudo_tty(
        &home,
        &["--path", repo_path(&repo).to_str().unwrap(), "diff"],
        b"q",
    )?;

    assert!(output.status.success());
    assert_eq!(stderr(&output), "");

    Ok(())
}