grit-lib 0.5.20

Core library for the grit Git implementation
Documentation
//! SSH commit signing round-trip with the system `git` binary.

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

use grit_lib::config::environment::RepositoryOptions;
use grit_lib::porcelain::add::{stage, StageOptions};
use grit_lib::porcelain::commit::{create_commit, CommitRequest, HookPolicy};
use grit_lib::progress::NullProgress;
use grit_lib::repo::Repository;
use grit_test_support::git;

fn ssh_keygen_available() -> bool {
    Command::new("ssh-keygen").arg("-V").output().is_ok()
}

fn git_out(dir: &Path, args: &[&str]) -> String {
    let out = Command::new("git")
        .current_dir(dir)
        .args(args)
        .env("GIT_CONFIG_GLOBAL", "/dev/null")
        .env("GIT_CONFIG_NOSYSTEM", "1")
        .output()
        .expect("spawn git");
    assert!(
        out.status.success(),
        "git {:?} failed: {}",
        args,
        String::from_utf8_lossy(&out.stderr)
    );
    String::from_utf8_lossy(&out.stdout).trim().to_owned()
}

#[test]
fn create_commit_ssh_signs_when_gpgsign_true() {
    if !ssh_keygen_available() {
        eprintln!("skipping: ssh-keygen not available");
        return;
    }

    let tmp = tempfile::tempdir().expect("tempdir");
    let root = tmp.path();
    let key = root.join("key");
    let allowed = root.join("allowed_signers");

    Command::new("ssh-keygen")
        .args(["-q", "-t", "ed25519", "-N", "", "-f"])
        .arg(&key)
        .status()
        .expect("ssh-keygen");

    git(root, &["init", "-q", "-b", "main", "."]);
    git(root, &["config", "core.logAllRefUpdates", "true"]);
    git(root, &["config", "user.name", "T"]);
    git(root, &["config", "user.email", "t@example.com"]);
    git(root, &["config", "gpg.format", "ssh"]);
    git(
        root,
        &[
            "config",
            "user.signingkey",
            &key.with_extension("pub").to_string_lossy(),
        ],
    );
    git(root, &["config", "commit.gpgsign", "true"]);

    let pub_key = std::fs::read_to_string(key.with_extension("pub")).expect("read pub");
    std::fs::write(&allowed, format!("t@example.com {pub_key}")).expect("allowed_signers");
    git(
        root,
        &[
            "config",
            "gpg.ssh.allowedSignersFile",
            &allowed.to_string_lossy(),
        ],
    );

    std::fs::write(root.join("a"), b"a\n").expect("write");
    let repo = Repository::discover(Some(root), &RepositoryOptions::empty()).expect("open");
    stage(&repo, &StageOptions::default(), &mut NullProgress).expect("stage");

    let ident = "T <t@example.com> 1700000000 +0000".to_owned();
    create_commit(
        &repo,
        &CommitRequest {
            message: "grit signed".to_owned(),
            author: ident.clone(),
            committer: ident,
            allow_empty: false,
            sign_override: None,
            amend: false,
            assume_worktree_synced: false,
            skip_pre_commit_tree_match: false,
            staged_change_count: None,
            ..Default::default()
        },
        &mut NullProgress,
    )
    .expect("create_commit");

    let sig_status = git_out(root, &["log", "-1", "--format=%G?"]);
    assert_eq!(
        sig_status, "G",
        "git should report a good signature (%G? = G)"
    );

    let cat = git_out(root, &["cat-file", "-p", "HEAD"]);
    assert!(
        cat.contains("gpgsig "),
        "commit object should carry a gpgsig header"
    );

    git_out(root, &["fsck", "--strict"]);
}

fn count_commit_signature_headers(raw: &str) -> usize {
    let header = raw.split("\n\n").next().unwrap_or(raw);
    header
        .lines()
        .filter(|line| line.starts_with("gpgsig ") || line.starts_with("gpgsig-sha256 "))
        .count()
}

#[test]
fn amend_unsigned_drops_signed_tip_signature() {
    if !ssh_keygen_available() {
        eprintln!("skipping: ssh-keygen not available");
        return;
    }

    let tmp = tempfile::tempdir().expect("tempdir");
    let root = tmp.path();
    let key = root.join("key");
    let allowed = root.join("allowed_signers");

    Command::new("ssh-keygen")
        .args(["-q", "-t", "ed25519", "-N", "", "-f"])
        .arg(&key)
        .status()
        .expect("ssh-keygen");

    git(root, &["init", "-q", "-b", "main", "."]);
    git(root, &["config", "core.logAllRefUpdates", "true"]);
    git(root, &["config", "user.name", "T"]);
    git(root, &["config", "user.email", "t@example.com"]);
    git(root, &["config", "gpg.format", "ssh"]);
    git(
        root,
        &[
            "config",
            "user.signingkey",
            &key.with_extension("pub").to_string_lossy(),
        ],
    );
    git(root, &["config", "commit.gpgsign", "true"]);
    let pub_key = std::fs::read_to_string(key.with_extension("pub")).expect("read pub");
    std::fs::write(&allowed, format!("t@example.com {pub_key}")).expect("allowed_signers");
    git(
        root,
        &[
            "config",
            "gpg.ssh.allowedSignersFile",
            &allowed.to_string_lossy(),
        ],
    );

    std::fs::write(root.join("a"), b"a\n").expect("write");
    let repo = Repository::discover(Some(root), &RepositoryOptions::empty()).expect("open");
    stage(&repo, &StageOptions::default(), &mut NullProgress).expect("stage");

    let ident = "T <t@example.com> 1700000000 +0000".to_owned();
    create_commit(
        &repo,
        &CommitRequest {
            message: "signed tip".to_owned(),
            author: ident.clone(),
            committer: ident.clone(),
            allow_empty: false,
            sign_override: None,
            amend: false,
            assume_worktree_synced: false,
            skip_pre_commit_tree_match: false,
            staged_change_count: None,
            ..Default::default()
        },
        &mut NullProgress,
    )
    .expect("signed create");

    let before = git_out(root, &["cat-file", "-p", "HEAD"]);
    assert_eq!(count_commit_signature_headers(&before), 1);

    create_commit(
        &repo,
        &CommitRequest {
            message: "signed tip amended".to_owned(),
            author: ident.clone(),
            committer: ident.clone(),
            allow_empty: false,
            sign_override: Some(false),
            amend: true,
            assume_worktree_synced: false,
            skip_pre_commit_tree_match: false,
            staged_change_count: None,
            ..Default::default()
        },
        &mut NullProgress,
    )
    .expect("unsigned amend");

    let after = git_out(root, &["cat-file", "-p", "HEAD"]);
    assert_eq!(
        count_commit_signature_headers(&after),
        0,
        "unsigned amend must drop the old gpgsig header"
    );
    git_out(root, &["fsck", "--strict"]);
}

#[test]
fn amend_resigns_with_single_fresh_signature() {
    if !ssh_keygen_available() {
        eprintln!("skipping: ssh-keygen not available");
        return;
    }

    let tmp = tempfile::tempdir().expect("tempdir");
    let root = tmp.path();
    let key = root.join("key");
    let allowed = root.join("allowed_signers");

    Command::new("ssh-keygen")
        .args(["-q", "-t", "ed25519", "-N", "", "-f"])
        .arg(&key)
        .status()
        .expect("ssh-keygen");

    git(root, &["init", "-q", "-b", "main", "."]);
    git(root, &["config", "core.logAllRefUpdates", "true"]);
    git(root, &["config", "user.name", "T"]);
    git(root, &["config", "user.email", "t@example.com"]);
    git(root, &["config", "gpg.format", "ssh"]);
    git(
        root,
        &[
            "config",
            "user.signingkey",
            &key.with_extension("pub").to_string_lossy(),
        ],
    );
    git(root, &["config", "commit.gpgsign", "true"]);
    let pub_key = std::fs::read_to_string(key.with_extension("pub")).expect("read pub");
    std::fs::write(&allowed, format!("t@example.com {pub_key}")).expect("allowed_signers");
    git(
        root,
        &[
            "config",
            "gpg.ssh.allowedSignersFile",
            &allowed.to_string_lossy(),
        ],
    );

    std::fs::write(root.join("a"), b"a\n").expect("write");
    let repo = Repository::discover(Some(root), &RepositoryOptions::empty()).expect("open");
    stage(&repo, &StageOptions::default(), &mut NullProgress).expect("stage");

    let ident = "T <t@example.com> 1700000000 +0000".to_owned();
    create_commit(
        &repo,
        &CommitRequest {
            message: "signed tip".to_owned(),
            author: ident.clone(),
            committer: ident.clone(),
            allow_empty: false,
            sign_override: None,
            amend: false,
            assume_worktree_synced: false,
            skip_pre_commit_tree_match: false,
            staged_change_count: None,
            ..Default::default()
        },
        &mut NullProgress,
    )
    .expect("signed create");

    std::fs::write(root.join("a"), b"b\n").expect("write");
    stage(&repo, &StageOptions::default(), &mut NullProgress).expect("stage");
    create_commit(
        &repo,
        &CommitRequest {
            message: "signed amend".to_owned(),
            author: ident.clone(),
            committer: ident,
            allow_empty: false,
            sign_override: None,
            amend: true,
            assume_worktree_synced: false,
            skip_pre_commit_tree_match: false,
            staged_change_count: None,
            ..Default::default()
        },
        &mut NullProgress,
    )
    .expect("signed amend");

    let after = git_out(root, &["cat-file", "-p", "HEAD"]);
    assert_eq!(
        count_commit_signature_headers(&after),
        1,
        "signed amend must carry exactly one signature header"
    );
    assert_eq!(git_out(root, &["log", "-1", "--format=%G?"]), "G");
    git_out(root, &["fsck", "--strict"]);
}