outpost-core 0.1.2

Core library for Git Outpost, a clone-backed alternative to git worktree workflows.
Documentation
#[allow(dead_code)]
mod common;

use std::fs;
use std::path::Path;

use common::fixture::{AbcFixture, CapturingReporter};
use outpost_core::ops::rebase::{RebaseOptions, run};
use outpost_core::{Outpost, OutpostError, OutpostResult, SourceRemoteRef, StepKind};

#[test]
fn mr02_rebase_fetches_source_branch_and_rebases_current_branch() {
    let fixture = AbcFixture::new();
    let outpost_path = fixture.add_outpost("C").expect("add C");
    fixture
        .invoker(&outpost_path)
        .run_check(["switch", "-c", "feature/rebase-main"])
        .expect("create outpost feature branch");
    let outpost_oid = fixture
        .commit_file_in_outpost(
            &outpost_path,
            "outpost side",
            "outpost.txt",
            "from outpost\n",
        )
        .expect("outpost commit");
    let source_oid = fixture
        .commit_file_in_source("source side", "source.txt", "from source\n")
        .expect("source commit");
    let outpost = outpost(&fixture, &outpost_path);
    let mut reporter = CapturingReporter::default();

    let report = run(
        &outpost,
        RebaseOptions {
            source_ref: source_ref("local/main"),
        },
        &mut reporter,
    )
    .expect("rebase");

    assert_eq!(report.source_ref, source_ref("local/main"));
    assert_eq!(
        fixture
            .rev_parse(&outpost_path, "refs/remotes/local/main")
            .expect("local/main"),
        source_oid
    );
    assert_eq!(
        fixture
            .rev_parse(&outpost_path, "HEAD^")
            .expect("rebased parent"),
        source_oid
    );
    assert_ne!(
        fixture
            .rev_parse(&outpost_path, "HEAD")
            .expect("rebased HEAD"),
        outpost_oid
    );
    assert_eq!(
        fs::read_to_string(outpost_path.join("source.txt")).expect("source file"),
        "from source\n"
    );
    assert_eq!(
        fs::read_to_string(outpost_path.join("outpost.txt")).expect("outpost file"),
        "from outpost\n"
    );
}

#[test]
fn mr03_rebase_uses_custom_source_remote_name() {
    let fixture = AbcFixture::new();
    let outpost_path = fixture
        .add_outpost_with_remote("C", "custom")
        .expect("add custom outpost");
    fixture
        .invoker(&outpost_path)
        .run_check(["switch", "-c", "feature/custom-rebase"])
        .expect("create custom rebase branch");
    fixture
        .commit_file_in_outpost(
            &outpost_path,
            "outpost side",
            "custom-outpost.txt",
            "from outpost\n",
        )
        .expect("outpost commit");
    let source_oid = fixture
        .commit_file_in_source("source side", "custom-source.txt", "from source\n")
        .expect("source commit");
    let outpost = outpost(&fixture, &outpost_path);
    let mut reporter = CapturingReporter::default();

    run(
        &outpost,
        RebaseOptions {
            source_ref: source_ref("custom/main"),
        },
        &mut reporter,
    )
    .expect("custom rebase");

    assert_eq!(
        fixture
            .rev_parse(&outpost_path, "refs/remotes/custom/main")
            .expect("custom/main"),
        source_oid
    );
    assert_eq!(
        fixture
            .rev_parse(&outpost_path, "HEAD^")
            .expect("rebased parent"),
        source_oid
    );
    assert!(
        fixture
            .invoker(&outpost_path)
            .run_capture(["remote", "get-url", "local"])
            .is_err(),
        "custom outpost should not rely on a local remote"
    );
}

#[test]
fn mr04_rebase_rejects_wrong_remote_before_fetching() {
    let fixture = AbcFixture::new();
    let outpost_path = fixture.add_outpost("C").expect("add C");
    fixture
        .invoker(&outpost_path)
        .run_check([
            "remote",
            "add",
            "origin",
            fixture.source.to_str().expect("source path"),
        ])
        .expect("add decoy origin remote");
    let local_main_before = fixture
        .rev_parse(&outpost_path, "refs/remotes/local/main")
        .expect("local/main before");
    fixture
        .commit_file_in_source("source side", "wrong-remote.txt", "from source\n")
        .expect("source commit");
    let outpost = outpost(&fixture, &outpost_path);
    let mut reporter = CapturingReporter::default();

    let err = expect_error(
        run(
            &outpost,
            RebaseOptions {
                source_ref: source_ref("origin/main"),
            },
            &mut reporter,
        ),
        "rebase should reject wrong remote",
    );

    assert!(matches!(err, OutpostError::InvalidRefName { name } if name == "origin/main"));
    assert!(reporter.steps.is_empty());
    assert_eq!(
        fixture
            .rev_parse(&outpost_path, "refs/remotes/local/main")
            .expect("local/main after"),
        local_main_before
    );
    assert!(
        fixture
            .rev_parse(&outpost_path, "refs/remotes/origin/main")
            .is_err(),
        "wrong remote should not be fetched"
    );
}

#[test]
fn mr05_rebase_records_outpost_fetch_event() {
    let fixture = AbcFixture::new();
    let outpost_path = fixture.add_outpost("C").expect("add C");
    fixture
        .commit_file_in_source("source side", "event.txt", "from source\n")
        .expect("source commit");
    let outpost = outpost(&fixture, &outpost_path);
    let mut reporter = CapturingReporter::default();

    run(
        &outpost,
        RebaseOptions {
            source_ref: source_ref("local/main"),
        },
        &mut reporter,
    )
    .expect("rebase");

    assert_eq!(reporter.step_kinds(), vec![StepKind::OutpostFetch]);
    assert!(
        reporter.warnings.is_empty(),
        "rebase should not warn on baseline path: {:?}",
        reporter.warnings
    );
}

#[test]
fn rebase_uses_full_remote_tracking_ref_when_local_branch_name_collides() {
    let fixture = AbcFixture::new();
    let outpost_path = fixture.add_outpost("C").expect("add C");
    fixture
        .invoker(&outpost_path)
        .run_check(["branch", "local/main"])
        .expect("create colliding local branch");
    fixture
        .invoker(&outpost_path)
        .run_check(["switch", "-c", "feature/colliding-rebase"])
        .expect("create outpost feature branch");
    fixture
        .commit_file_in_outpost(
            &outpost_path,
            "outpost side",
            "colliding-outpost.txt",
            "from outpost\n",
        )
        .expect("outpost commit");
    let source_oid = fixture
        .commit_file_in_source("source side", "colliding-source.txt", "from source\n")
        .expect("source commit");
    let outpost = outpost(&fixture, &outpost_path);
    let mut reporter = CapturingReporter::default();

    run(
        &outpost,
        RebaseOptions {
            source_ref: source_ref("local/main"),
        },
        &mut reporter,
    )
    .expect("rebase");

    assert_eq!(
        fixture
            .rev_parse(&outpost_path, "refs/remotes/local/main")
            .expect("local/main"),
        source_oid
    );
    assert_eq!(
        fixture
            .rev_parse(&outpost_path, "HEAD^")
            .expect("rebased parent"),
        source_oid
    );
}

#[test]
fn mr06_rebase_on_detached_head_returns_attached_branch_error_before_fetching() {
    let fixture = AbcFixture::new();
    let outpost_path = fixture.add_outpost("C").expect("add C");
    let local_main_before = fixture
        .rev_parse(&outpost_path, "refs/remotes/local/main")
        .expect("local/main before");
    fixture
        .commit_file_in_source("source side", "detached.txt", "from source\n")
        .expect("source commit");
    fixture
        .invoker(&outpost_path)
        .run_check(["checkout", "--detach"])
        .expect("detach outpost HEAD");
    let outpost = outpost(&fixture, &outpost_path);
    let mut reporter = CapturingReporter::default();

    let err = expect_error(
        run(
            &outpost,
            RebaseOptions {
                source_ref: source_ref("local/main"),
            },
            &mut reporter,
        ),
        "rebase should reject detached HEAD",
    );

    assert!(matches!(err, OutpostError::NoUpstreamTracking { branch } if branch == "HEAD"));
    assert!(reporter.steps.is_empty());
    assert_eq!(
        fixture
            .rev_parse(&outpost_path, "refs/remotes/local/main")
            .expect("local/main after"),
        local_main_before
    );
}

fn outpost(fixture: &AbcFixture, path: &Path) -> Outpost {
    Outpost::at_with(path, &fixture.git_env).expect("open outpost")
}

fn source_ref(value: &str) -> SourceRemoteRef {
    SourceRemoteRef::parse(value).expect("source remote ref")
}

fn expect_error<T>(result: OutpostResult<T>, message: &str) -> OutpostError {
    match result {
        Ok(_) => panic!("{message}"),
        Err(err) => err,
    }
}