rtb-forge 0.7.1

Release-provider abstractions and backends (GitHub, GitLab, Bitbucket, Gitea, Codeberg, Direct) plus an async git-operations Repo built on gix. Part of the phpboyscout Rust toolkit.
Documentation
//! Unit tests for v0.5 commit 7 — `Repo::push`.
//!
//! Tests push against a local bare remote (`git init --bare`) which
//! accepts pushes via `file://` without authentication.

#![cfg(feature = "git")]
#![allow(missing_docs)]
// Auth tests seed env vars; same pattern as repo_auth_unit.rs.
#![allow(unsafe_code)]

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

use rtb_credentials::CredentialRef;
use rtb_forge::git::{CloneOptions, PushOptions, Repo, RepoError};

// ---------------------------------------------------------------------
// P1 — PushOptions::with_credential builder composes correctly
// ---------------------------------------------------------------------

#[test]
fn p1_push_options_with_credential_builder() {
    let cref = CredentialRef { env: Some("RTB_TEST_TOKEN".to_string()), ..Default::default() };
    let opts = PushOptions::default().with_credential(cref);
    assert!(opts.credential.is_some(), "credential should be Some after builder");
}

// ---------------------------------------------------------------------
// P2 — anonymous push to a local bare remote succeeds
// ---------------------------------------------------------------------

#[tokio::test]
async fn p2_push_anonymous_to_bare_remote_succeeds() {
    let upstream = build_three_commit_repo();
    let bare = build_bare_remote();
    let bare_url = format!("file://{}", bare.path().display());

    // Add a `bare` remote on the upstream so we have something to push to.
    Command::new("git")
        .args(["remote", "add", "bare", &bare_url])
        .current_dir(upstream.path())
        .output()
        .expect("add remote");

    let repo = Repo::open(upstream.path()).await.expect("open");
    repo.push("bare", "main", PushOptions::default()).await.expect("push");

    // Bare remote's HEAD should now match the upstream HEAD.
    let upstream_head = git_command(upstream.path(), &["rev-parse", "HEAD"]).trim().to_string();
    let bare_main = git_command(bare.path(), &["rev-parse", "main"]).trim().to_string();
    assert_eq!(bare_main, upstream_head, "pushed ref should match local HEAD");
}

// ---------------------------------------------------------------------
// P3 — push to an unknown remote returns PushFailed
// ---------------------------------------------------------------------

#[tokio::test]
async fn p3_push_unknown_remote_errors() {
    let upstream = build_three_commit_repo();
    let repo = Repo::open(upstream.path()).await.expect("open");

    let err =
        repo.push("no-such-remote", "main", PushOptions::default()).await.expect_err("must fail");
    assert!(
        matches!(err, RepoError::PushFailed { ref remote, .. } if remote == "no-such-remote"),
        "got {err:?}"
    );
}

// ---------------------------------------------------------------------
// P4 — push with unresolvable credential surfaces RepoError::Auth
// ---------------------------------------------------------------------

#[tokio::test]
async fn p4_push_unresolvable_credential_errors() {
    let upstream = build_three_commit_repo();
    let bare = build_bare_remote();
    let bare_url = format!("file://{}", bare.path().display());
    Command::new("git")
        .args(["remote", "add", "bare", &bare_url])
        .current_dir(upstream.path())
        .output()
        .expect("add remote");

    let repo = Repo::open(upstream.path()).await.expect("open");

    let cref = CredentialRef { env: Some("RTB_UNSET_TOKEN_P4".to_string()), ..Default::default() };
    unsafe {
        std::env::remove_var("RTB_UNSET_TOKEN_P4");
    }

    let opts = PushOptions::default().with_credential(cref);
    let err = repo.push("bare", "main", opts).await.expect_err("must fail");
    assert!(matches!(err, RepoError::Auth(_)), "got {err:?}");
}

// ---------------------------------------------------------------------
// P5 — push with resolvable credential against file:// succeeds
// ---------------------------------------------------------------------

#[tokio::test]
async fn p5_push_with_resolved_credential_against_file_succeeds() {
    let upstream = build_three_commit_repo();
    let bare = build_bare_remote();
    let bare_url = format!("file://{}", bare.path().display());
    Command::new("git")
        .args(["remote", "add", "bare", &bare_url])
        .current_dir(upstream.path())
        .output()
        .expect("add remote");

    let repo = Repo::open(upstream.path()).await.expect("open");

    unsafe {
        std::env::set_var("RTB_TEST_TOKEN_P5", "dummy-token-value");
    }
    let cref = CredentialRef { env: Some("RTB_TEST_TOKEN_P5".to_string()), ..Default::default() };
    let opts = PushOptions::default().with_credential(cref);
    repo.push("bare", "main", opts).await.expect("push with auth env set");

    unsafe {
        std::env::remove_var("RTB_TEST_TOKEN_P5");
    }

    // Verify the push actually landed on the bare.
    let upstream_head = git_command(upstream.path(), &["rev-parse", "HEAD"]).trim().to_string();
    let bare_main = git_command(bare.path(), &["rev-parse", "main"]).trim().to_string();
    assert_eq!(bare_main, upstream_head);
}

// ---------------------------------------------------------------------
// Cloned-then-pushed roundtrip: push commits made locally after clone
// back to the upstream-bare.
// ---------------------------------------------------------------------

#[tokio::test]
async fn p6_clone_then_commit_then_push_roundtrip() {
    let bare = build_bare_remote();
    let bare_url = format!("file://{}", bare.path().display());

    // Seed the bare with an initial commit pushed from a scratch repo.
    let scratch = build_three_commit_repo();
    Command::new("git")
        .args(["remote", "add", "bare", &bare_url])
        .current_dir(scratch.path())
        .output()
        .expect("add remote");
    Command::new("git")
        .args(["push", "bare", "main"])
        .current_dir(scratch.path())
        .output()
        .expect("seed push");

    // Clone the bare into a new working tree.
    let dst_holder = tempfile::tempdir().expect("dst tempdir");
    let dst = dst_holder.path().join("worker");
    let clone = Repo::clone(&bare_url, &dst, CloneOptions::default()).await.expect("clone");

    // Configure user identity, commit locally, then push back.
    Command::new("git")
        .args(["config", "--local", "user.name", "carol"])
        .current_dir(&dst)
        .output()
        .expect("user.name");
    Command::new("git")
        .args(["config", "--local", "user.email", "carol@example.test"])
        .current_dir(&dst)
        .output()
        .expect("user.email");
    std::fs::write(dst.join("hello.txt"), b"hi\n").expect("write");
    let local_oid = clone.commit(&[Path::new("hello.txt")], "add hello").await.expect("commit");

    clone.push("origin", "main", PushOptions::default()).await.expect("push back");

    // Bare's main should now match the new local commit.
    let bare_main = git_command(bare.path(), &["rev-parse", "main"]).trim().to_string();
    assert_eq!(bare_main, local_oid, "bare ref should match the locally-made commit");
}

// =====================================================================
// Fixture helpers
// =====================================================================

fn build_three_commit_repo() -> tempfile::TempDir {
    let dir = tempfile::tempdir().expect("tempdir");
    let path = dir.path();
    git_init(path);
    write(path, "README.md", "v1\n");
    git_add_all(path);
    git_commit(path, "alice", "initial");
    write(path, "README.md", "v2\n");
    git_add_all(path);
    git_commit(path, "alice", "second");
    write(path, "README.md", "v3\n");
    git_add_all(path);
    git_commit(path, "alice", "third");
    dir
}

fn build_bare_remote() -> tempfile::TempDir {
    let dir = tempfile::tempdir().expect("bare tempdir");
    let path = dir.path();
    Command::new("git")
        .args(["init", "--bare", "-b", "main"])
        .current_dir(path)
        .output()
        .expect("git init --bare");
    dir
}

fn git_init(path: &Path) {
    Command::new("git").args(["init", "-b", "main"]).current_dir(path).output().expect("git init");
}

fn git_add_all(path: &Path) {
    Command::new("git").args(["add", "-A"]).current_dir(path).output().expect("git add");
}

fn git_commit(path: &Path, author: &str, message: &str) {
    Command::new("git")
        .args(["commit", "--allow-empty", "-m", message])
        .env("GIT_AUTHOR_NAME", author)
        .env("GIT_AUTHOR_EMAIL", format!("{author}@example.test"))
        .env("GIT_COMMITTER_NAME", author)
        .env("GIT_COMMITTER_EMAIL", format!("{author}@example.test"))
        .env("GIT_AUTHOR_DATE", "2026-05-19T00:00:00Z")
        .env("GIT_COMMITTER_DATE", "2026-05-19T00:00:00Z")
        .current_dir(path)
        .output()
        .expect("git commit");
}

fn write(path: &Path, file: &str, contents: &str) {
    std::fs::write(path.join(file), contents).expect("write");
}

fn git_command(path: &Path, args: &[&str]) -> String {
    let out = Command::new("git").args(args).current_dir(path).output().expect("git");
    assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
    String::from_utf8(out.stdout).expect("utf8")
}