git-meta-cli 0.1.9

Command-line tool for structured Git metadata (get/set, serialize, materialize, push/pull). Installs the `git-meta` binary.
use predicates::prelude::*;

use crate::harness;

#[cfg(not(windows))]
#[test]
fn import_gh_imports_pr_metadata_and_is_idempotent() {
    use std::os::unix::fs::PermissionsExt;

    let (dir, sha) = harness::setup_repo();
    let fake_bin = tempfile::TempDir::new().unwrap();
    let gh_path = fake_bin.path().join("gh");
    let script = format!(
        r#"#!/bin/sh
if [ "$1" = "auth" ] && [ "$2" = "status" ]; then
  exit 0
fi
if [ "$1" = "pr" ] && [ "$2" = "list" ]; then
  case "$*" in
    *closingIssuesReferences*|*comments*|*commits*|*reviews*)
      echo 'unexpected nested field in pr list' >&2
      exit 1
      ;;
  esac
  cat <<'JSON'
[{{"number":42,"title":"Add metadata import","body":"Closes #25","url":"https://github.com/owner/repo/pull/42","headRefName":"feature/import","baseRefName":"main","mergedAt":"2026-04-01T12:00:00Z","mergeCommit":{{"oid":"{sha}","messageHeadline":"feat(import): add GitHub import"}}}}]
JSON
  exit 0
fi
if [ "$1" = "pr" ] && [ "$2" = "view" ]; then
  cat <<'JSON'
{{"number":42,"title":"Add metadata import","body":"Closes #25","url":"https://github.com/owner/repo/pull/42","headRefName":"feature/import","baseRefName":"main","mergedAt":"2026-04-01T12:00:00Z","mergeCommit":{{"oid":"{sha}","messageHeadline":"feat(import): add GitHub import"}},"commits":[{{"oid":"{sha}","messageHeadline":"feat(import): add GitHub import"}}],"comments":[{{"author":{{"login":"alice"}},"body":"looks good","url":"https://github.com/owner/repo/pull/42#issuecomment-1","createdAt":"2026-04-01T11:00:00Z"}}],"reviews":[{{"author":{{"login":"bob"}},"state":"APPROVED","body":"approved","url":"https://github.com/owner/repo/pull/42#pullrequestreview-1","submittedAt":"2026-04-01T11:30:00Z"}}]}}
JSON
  exit 0
fi
echo "unexpected gh args: $@" >&2
exit 1
"#,
    );
    std::fs::write(&gh_path, script).unwrap();
    let mut permissions = std::fs::metadata(&gh_path).unwrap().permissions();
    permissions.set_mode(0o755);
    std::fs::set_permissions(&gh_path, permissions).unwrap();

    let path = format!(
        "{}:{}",
        fake_bin.path().display(),
        std::env::var("PATH").unwrap_or_default()
    );

    git(dir.path(), &["tag", "v1.0.0", &sha]);
    harness::git_meta(dir.path())
        .args(["set", &format!("commit:{sha}"), "change-id", "Iabcdef"])
        .assert()
        .success();

    harness::git_meta(dir.path())
        .env("PATH", &path)
        .args(["import", "gh", "--repo", "owner/repo", "--limit", "1"])
        .assert()
        .success()
        .stderr(predicate::str::contains(
            "fetching up to 1 merged PRs with `gh pr list`",
        ))
        .stderr(predicate::str::contains("fetching PR #42 details"))
        .stderr(predicate::str::contains("imported 1 PRs"));

    harness::git_meta(dir.path())
        .args(["get", &format!("commit:{sha}"), "branch-id"])
        .assert()
        .success()
        .stdout(predicate::str::contains("feature-import#42"));

    harness::git_meta(dir.path())
        .args(["get", "branch:feature-import#42"])
        .assert()
        .success()
        .stdout(predicate::str::contains("Add metadata import"))
        .stdout(predicate::str::contains("review:approved"))
        .stdout(predicate::str::contains("bob"))
        .stdout(predicate::str::contains("commits:author"))
        .stdout(predicate::str::contains("Test User <test@example.com>"))
        .stdout(predicate::str::contains("commits:author-date"))
        .stdout(predicate::str::contains("946684800"))
        .stdout(predicate::str::contains("released-in"))
        .stdout(predicate::str::contains("v1.0.0"))
        .stdout(predicate::str::contains("issue:id"))
        .stdout(predicate::str::contains("#25"));

    harness::git_meta(dir.path())
        .args(["get", "change-id:Iabcdef", "released-in"])
        .assert()
        .success()
        .stdout(predicate::str::contains("v1.0.0"));

    harness::git_meta(dir.path())
        .env("PATH", path)
        .args(["import", "gh", "--repo", "owner/repo", "--limit", "1"])
        .assert()
        .success()
        .stderr(predicate::str::contains("imported 0 PRs"));

    let path = format!(
        "{}:{}",
        fake_bin.path().display(),
        std::env::var("PATH").unwrap_or_default()
    );
    harness::git_meta(dir.path())
        .env("PATH", path)
        .args([
            "import",
            "gh",
            "--repo",
            "owner/repo",
            "--limit",
            "1",
            "--force",
        ])
        .assert()
        .success()
        .stderr(predicate::str::contains("imported 1 PRs"));
}

#[cfg(not(windows))]
#[test]
fn import_gh_without_limit_fetches_all_pages() {
    use std::os::unix::fs::PermissionsExt;

    let (dir, _sha) = harness::setup_repo();
    let fake_bin = tempfile::TempDir::new().unwrap();
    let gh_path = fake_bin.path().join("gh");
    let script = r#"#!/bin/sh
if [ "$1" = "auth" ] && [ "$2" = "status" ]; then
  exit 0
fi
if [ "$1" = "api" ] && [ "$2" = "graphql" ]; then
  case "$*" in
    *cursor=CURSOR1*)
      cat <<'JSON'
{"data":{"repository":{"pullRequests":{"nodes":[{"number":100,"title":"Older PR","body":"","url":"https://github.com/owner/repo/pull/100","headRefName":"older","baseRefName":"main","mergedAt":"2026-03-31T12:00:00Z","mergeCommit":null}],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}
JSON
      ;;
    *)
      cat <<'JSON'
{"data":{"repository":{"pullRequests":{"nodes":[{"number":101,"title":"Newer PR","body":"","url":"https://github.com/owner/repo/pull/101","headRefName":"newer","baseRefName":"main","mergedAt":"2026-04-01T12:00:00Z","mergeCommit":null}],"pageInfo":{"hasNextPage":true,"endCursor":"CURSOR1"}}}}}
JSON
      ;;
  esac
  exit 0
fi
if [ "$1" = "pr" ] && [ "$2" = "view" ]; then
  if [ "$3" = "101" ]; then
    cat <<'JSON'
{"number":101,"title":"Newer PR","body":"","url":"https://github.com/owner/repo/pull/101","headRefName":"newer","baseRefName":"main","mergedAt":"2026-04-01T12:00:00Z","mergeCommit":null,"commits":[],"comments":[],"reviews":[]}
JSON
    exit 0
  fi
  if [ "$3" = "100" ]; then
    cat <<'JSON'
{"number":100,"title":"Older PR","body":"","url":"https://github.com/owner/repo/pull/100","headRefName":"older","baseRefName":"main","mergedAt":"2026-03-31T12:00:00Z","mergeCommit":null,"commits":[],"comments":[],"reviews":[]}
JSON
    exit 0
  fi
fi
echo "unexpected gh args: $@" >&2
exit 1
"#;
    std::fs::write(&gh_path, script).unwrap();
    let mut permissions = std::fs::metadata(&gh_path).unwrap().permissions();
    permissions.set_mode(0o755);
    std::fs::set_permissions(&gh_path, permissions).unwrap();

    let path = format!(
        "{}:{}",
        fake_bin.path().display(),
        std::env::var("PATH").unwrap_or_default()
    );

    harness::git_meta(dir.path())
        .env("PATH", path)
        .args([
            "import",
            "gh",
            "--repo",
            "owner/repo",
            "--dry-run",
            "--no-tags",
        ])
        .assert()
        .success()
        .stderr(predicate::str::contains("fetching merged PR page 1"))
        .stderr(predicate::str::contains(
            "fetched merged PR page 2: 1 PRs (2 total)",
        ))
        .stderr(predicate::str::contains("fetched 2 merged PR summaries"))
        .stderr(predicate::str::contains("importing PR #101: Newer PR"))
        .stderr(predicate::str::contains("importing PR #100: Older PR"))
        .stderr(predicate::str::contains(
            "dry-run: would import 2 PRs (2 fetched",
        ));
}

#[test]
fn blame_groups_lines_by_branch_metadata() {
    let (dir, _sha) = harness::setup_repo();
    std::fs::write(dir.path().join("file.txt"), "one\n").unwrap();
    git(dir.path(), &["add", "file.txt"]);
    git(dir.path(), &["commit", "-m", "feat: first"]);
    let first = git_stdout(dir.path(), &["rev-parse", "HEAD"]);

    std::fs::write(dir.path().join("file.txt"), "one\ntwo\n").unwrap();
    git(dir.path(), &["add", "file.txt"]);
    git(dir.path(), &["commit", "-m", "fix: second"]);
    let second = git_stdout(dir.path(), &["rev-parse", "HEAD"]);

    harness::git_meta(dir.path())
        .args(["set", &format!("commit:{first}"), "branch-id", "feature#1"])
        .assert()
        .success();
    harness::git_meta(dir.path())
        .args(["set", "branch:feature#1", "title", "Add first line"])
        .assert()
        .success();
    harness::git_meta(dir.path())
        .args(["set", "branch:feature#1", "review:number", "1"])
        .assert()
        .success();
    harness::git_meta(dir.path())
        .args([
            "set",
            "branch:feature#1",
            "review:url",
            "https://github.com/owner/repo/pull/1",
        ])
        .assert()
        .success();
    harness::git_meta(dir.path())
        .args([
            "set:add",
            "branch:feature#1",
            "commits:author",
            "Alice <alice@example.com>",
        ])
        .assert()
        .success();
    harness::git_meta(dir.path())
        .args([
            "set:add",
            "branch:feature#1",
            "commits:author",
            "Bob <bob@example.com>",
        ])
        .assert()
        .success();
    harness::git_meta(dir.path())
        .args([
            "set:add",
            "branch:feature#1",
            "commits:author-date",
            "1775001600",
        ])
        .assert()
        .success();
    harness::git_meta(dir.path())
        .args([
            "set:add",
            "branch:feature#1",
            "commits:author-date",
            "1775174400",
        ])
        .assert()
        .success();
    harness::git_meta(dir.path())
        .args(["set", &format!("commit:{second}"), "branch-id", "fix#2"])
        .assert()
        .success();
    harness::git_meta(dir.path())
        .args(["set", "branch:fix#2", "title", "Add second line"])
        .assert()
        .success();
    harness::git_meta(dir.path())
        .args(["set", "branch:fix#2", "review:number", "2"])
        .assert()
        .success();

    harness::git_meta(dir.path())
        .args(["blame", "--porcelain", "file.txt"])
        .assert()
        .success()
        .stdout(predicate::str::contains("\"branch_id\": \"feature#1\""))
        .stdout(predicate::str::contains("\"branch_id\": \"fix#2\""))
        .stdout(predicate::str::contains("Add first line"))
        .stdout(predicate::str::contains("Add second line"));

    harness::git_meta(dir.path())
        .args(["blame", "--json", "file.txt"])
        .assert()
        .success()
        .stdout(predicate::str::contains("\"start_line\": 1"))
        .stdout(predicate::str::contains("\"end_line\": 1"))
        .stdout(predicate::str::contains("\"number\": \"1\""))
        .stdout(predicate::str::contains("\"title\": \"Add first line\""))
        .stdout(predicate::str::contains(
            "\"url\": \"https://github.com/owner/repo/pull/1\"",
        ))
        .stdout(predicate::str::contains("\"commit_authors\": ["))
        .stdout(predicate::str::contains("Alice <alice@example.com>"))
        .stdout(predicate::str::contains("Bob <bob@example.com>"))
        .stdout(predicate::str::contains("\"commit_author_dates\": ["))
        .stdout(predicate::str::contains("\"1775001600\""))
        .stdout(predicate::str::contains("\"1775174400\""))
        .stdout(predicate::str::contains(
            "\"commit_author_date_range\": \"2026-04-01..2026-04-03\"",
        ))
        .stdout(predicate::str::contains("\"lines\"").not());

    harness::git_meta(dir.path())
        .env_remove("NO_COLOR")
        .env("CLICOLOR_FORCE", "1")
        .env("COLUMNS", "80")
        .args(["blame", "file.txt"])
        .assert()
        .success()
        .stdout(predicate::str::contains("\x1b["))
        .stdout(predicate::str::contains(
            "https://github.com/owner/repo/pull/1",
        ))
        .stdout(predicate::str::contains(
            "alice@example.com, bob@example.com",
        ))
        .stdout(predicate::str::contains("dates:"))
        .stdout(predicate::str::contains("2026-04-01..2026-04-03"));
}

#[test]
fn blame_without_branch_metadata_shows_commit_details() {
    let (dir, _sha) = harness::setup_repo();
    std::fs::write(dir.path().join("file.txt"), "one\n").unwrap();
    git(dir.path(), &["add", "file.txt"]);
    git(dir.path(), &["commit", "-m", "feat: no pr"]);
    let commit = git_stdout(dir.path(), &["rev-parse", "--short=8", "HEAD"]);

    harness::git_meta(dir.path())
        .env_remove("NO_COLOR")
        .env("NO_COLOR", "1")
        .env("COLUMNS", "80")
        .args(["blame", "file.txt"])
        .assert()
        .success()
        .stdout(predicate::str::contains("feat: no pr"))
        .stdout(predicate::str::contains(format!("commit: {commit}")))
        .stdout(predicate::str::contains("author: Test User"))
        .stdout(predicate::str::contains("date:"))
        .stdout(predicate::str::contains("http").not())
        .stderr(predicate::str::contains("No PR metadata found").not());
}

fn git(repo: &std::path::Path, args: &[&str]) {
    let output = std::process::Command::new("git")
        .args(["-C", &repo.to_string_lossy()])
        .args(args)
        .env("GIT_AUTHOR_NAME", "Test User")
        .env("GIT_AUTHOR_EMAIL", "test@example.com")
        .env("GIT_COMMITTER_NAME", "Test User")
        .env("GIT_COMMITTER_EMAIL", "test@example.com")
        .output()
        .unwrap();
    assert!(
        output.status.success(),
        "git {:?} failed: {}",
        args,
        String::from_utf8_lossy(&output.stderr)
    );
}

fn git_stdout(repo: &std::path::Path, args: &[&str]) -> String {
    let output = std::process::Command::new("git")
        .args(["-C", &repo.to_string_lossy()])
        .args(args)
        .output()
        .unwrap();
    assert!(
        output.status.success(),
        "git {:?} failed: {}",
        args,
        String::from_utf8_lossy(&output.stderr)
    );
    String::from_utf8_lossy(&output.stdout).trim().to_string()
}