tix-tracker 0.5.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! Best-effort git plumbing for recording the commit that closes an issue.
//!
//! A commit cannot contain its own hash: its hash is derived from its tree
//! *and* its message, so a ticket can never store the hash of the very commit
//! that writes it. `tix` therefore records either the *work* commit that was
//! already made (auto-captured from `HEAD` when a ticket is closed) or, after
//! the closure commit lands, resolves the commit that last touched the issue
//! file. Both are best-effort: a missing `git`, a non-repository directory, or
//! an uncommitted file yields [`None`] and never an error.

use std::path::Path;
use std::process::Command;

/// The current `HEAD` commit hash, best-effort.
pub fn current_head(cwd: &Path) -> Option<String> {
    current_head_with(&["git"], cwd)
}

/// The most recent commit that touched `path`, best-effort.
pub fn last_commit_for_path(cwd: &Path, path: &Path) -> Option<String> {
    last_commit_for_path_with(&["git"], cwd, path)
}

/// The hash to record for automatic resolution (`--commit auto`): the commit
/// that last touched the issue file, falling back to `HEAD`.
pub fn resolve_closing_commit(cwd: &Path, issue_path: &Path) -> Option<String> {
    last_commit_for_path(cwd, issue_path).or_else(|| current_head(cwd))
}

/// The spawn seam behind [`current_head`]: `program` defaults to `["git"]` but
/// tests point it at `["sh", <fake-git>]`. Failures surface as [`None`].
fn current_head_with(program: &[&str], cwd: &Path) -> Option<String> {
    non_empty(&run_git(program, cwd, &["rev-parse", "HEAD"])?)
}

/// The spawn seam behind [`last_commit_for_path`].
fn last_commit_for_path_with(program: &[&str], cwd: &Path, path: &Path) -> Option<String> {
    let path = path.to_string_lossy();
    non_empty(&run_git(
        program,
        cwd,
        &["log", "-1", "--format=%H", "--", &path],
    )?)
}

/// Trim `stdout`, mapping an empty result to [`None`].
fn non_empty(stdout: &str) -> Option<String> {
    let value = stdout.trim();
    if value.is_empty() {
        None
    } else {
        Some(value.to_string())
    }
}

/// The injectable subprocess runner: any non-zero status or spawn failure is
/// treated as "no value". Reads stdout lossily and untrimmed; trimming is the
/// caller's job.
fn run_git(program: &[&str], cwd: &Path, args: &[&str]) -> Option<String> {
    let (command, prefix) = program.split_first()?;
    let output = Command::new(command)
        .args(prefix)
        .args(args)
        .current_dir(cwd)
        .output()
        .ok()?;
    if !output.status.success() {
        return None;
    }
    Some(String::from_utf8_lossy(&output.stdout).into_owned())
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::os::unix::fs::PermissionsExt;
    use tempfile::TempDir;

    /// Write a fake `git` script into `dir` and return the `["sh", <path>]`
    /// command prefix that runs it. Running the script through `sh` (rather
    /// than exec'ing it directly) avoids the `ETXTBSY` race on overlay
    /// filesystems where a just-written file cannot be executed.
    fn fake_git(dir: &TempDir, script: &str) -> Vec<String> {
        let path = dir.path().join("fake-git");
        std::fs::write(&path, script).unwrap();
        let mut perms = std::fs::metadata(&path).unwrap().permissions();
        perms.set_mode(0o755);
        std::fs::set_permissions(&path, perms).unwrap();
        vec!["sh".to_string(), path.display().to_string()]
    }

    /// A fake `git` that answers `rev-parse HEAD` and
    /// `log -1 --format=%H -- <path>`.
    fn script(head: Option<&str>, log: Option<&str>) -> String {
        let head_arm = match head {
            Some(h) => format!("  \"rev-parse\") printf '%s\\n' '{h}';;\n"),
            None => "  \"rev-parse\") exit 1;;\n".to_string(),
        };
        let log_arm = match log {
            Some(h) => format!("  \"log\") printf '%s\\n' '{h}';;\n"),
            None => "  \"log\") exit 1;;\n".to_string(),
        };
        format!("#!/bin/sh\ncase \"$1\" in\n{head_arm}{log_arm}  *) exit 1;;\nesac\n")
    }

    fn cmd(parts: &[String]) -> Vec<&str> {
        parts.iter().map(String::as_str).collect()
    }

    #[test]
    fn current_head_reads_and_trims_rev_parse() {
        let tmp = TempDir::new().unwrap();
        let git = fake_git(&tmp, &script(Some("  abc123\n"), None));
        assert_eq!(
            current_head_with(&cmd(&git), tmp.path()),
            Some("abc123".to_string())
        );
    }

    #[test]
    fn current_head_is_none_on_failure() {
        let tmp = TempDir::new().unwrap();
        let git = fake_git(&tmp, &script(None, None));
        assert_eq!(current_head_with(&cmd(&git), tmp.path()), None);
    }

    #[test]
    fn last_commit_for_path_reads_log() {
        let tmp = TempDir::new().unwrap();
        let git = fake_git(&tmp, &script(None, Some("def456\n")));
        let path = tmp.path().join("docs/issues/a.md");
        assert_eq!(
            last_commit_for_path_with(&cmd(&git), tmp.path(), &path),
            Some("def456".to_string())
        );
    }

    #[test]
    fn last_commit_for_path_is_none_on_failure() {
        let tmp = TempDir::new().unwrap();
        let git = fake_git(&tmp, &script(None, None));
        let path = tmp.path().join("docs/issues/a.md");
        assert_eq!(
            last_commit_for_path_with(&cmd(&git), tmp.path(), &path),
            None
        );
    }

    #[test]
    fn resolve_closing_commit_prefers_the_file_commit() {
        let tmp = TempDir::new().unwrap();
        let git = fake_git(&tmp, &script(Some("head999"), Some("file111")));
        let path = tmp.path().join("docs/issues/a.md");
        assert_eq!(
            resolve_closing_commit_with(&cmd(&git), tmp.path(), &path),
            Some("file111".to_string())
        );
    }

    #[test]
    fn resolve_closing_commit_falls_back_to_head() {
        let tmp = TempDir::new().unwrap();
        let git = fake_git(&tmp, &script(Some("head999"), None));
        let path = tmp.path().join("docs/issues/a.md");
        assert_eq!(
            resolve_closing_commit_with(&cmd(&git), tmp.path(), &path),
            Some("head999".to_string())
        );
    }

    #[test]
    fn missing_binary_is_none() {
        let tmp = TempDir::new().unwrap();
        assert_eq!(current_head_with(&["/nonexistent/git"], tmp.path()), None);
        assert_eq!(
            last_commit_for_path_with(&["/nonexistent/git"], tmp.path(), &tmp.path().join("a.md")),
            None
        );
    }

    /// Test-only seam mirroring [`resolve_closing_commit`] with an injectable program.
    fn resolve_closing_commit_with(program: &[&str], cwd: &Path, path: &Path) -> Option<String> {
        last_commit_for_path_with(program, cwd, path).or_else(|| current_head_with(program, cwd))
    }
}