outpost-core 0.1.1

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, PathBuf};

use common::fixture::AbcFixture;
use outpost_core::ops::remove;
use outpost_core::{
    BranchName, OutpostError, OutpostResult, RegistryEntry, RemoteName, SourceRepo,
};

#[test]
fn remove_clean_fully_pushed_outpost_deletes_dir_and_registry_entry() {
    let fixture = AbcFixture::new();
    let outpost = fixture.add_outpost("C").expect("add C");
    let source = fixture.source_repo().expect("source repo");
    let sentinel = create_unrelated_dir(&fixture, "unrelated-r01");

    remove::run(
        &source,
        remove::RemoveOptions {
            path: outpost.clone(),
            force: false,
        },
    )
    .expect("remove clean outpost");

    assert!(!outpost.exists());
    assert_registry_empty(&source);
    assert!(sentinel.exists());
    assert_source_branch_exists(&source, "main");
}

#[test]
fn remove_dirty_outpost_returns_dirty_tree_with_force_hint() {
    let fixture = AbcFixture::new();
    let outpost = fixture.dirty_outpost("C").expect("dirty C");
    let source = fixture.source_repo().expect("source repo");

    let err = expect_error(
        remove::run(
            &source,
            remove::RemoveOptions {
                path: outpost.clone(),
                force: false,
            },
        ),
        "dirty remove should fail",
    );

    assert!(
        matches!(err, OutpostError::DirtyTree { repo, hint } if repo == canonical(&outpost) && hint == "pass --force")
    );
    assert!(outpost.exists());
    assert_eq!(single_entry(&source).path, canonical(&outpost));
}

#[test]
fn remove_unpushed_outpost_returns_unpushed_commits() {
    let fixture = AbcFixture::new();
    let outpost = fixture.add_outpost("C").expect("add C");
    fixture
        .commit_in_outpost(&outpost, "outpost-only commit")
        .expect("commit in outpost");
    let source = fixture.source_repo().expect("source repo");

    let err = expect_error(
        remove::run(
            &source,
            remove::RemoveOptions {
                path: outpost.clone(),
                force: false,
            },
        ),
        "unpushed remove should fail",
    );

    assert!(
        matches!(err, OutpostError::UnpushedCommits { repo, branch, hint } if repo == canonical(&outpost) && branch == "main" && hint == "pass --force")
    );
    assert!(outpost.exists());
    assert_eq!(single_entry(&source).path, canonical(&outpost));
}

#[test]
fn remove_force_deletes_dirty_outpost() {
    let fixture = AbcFixture::new();
    let outpost = fixture.dirty_outpost("C").expect("dirty C");
    let source = fixture.source_repo().expect("source repo");
    let sentinel = create_unrelated_dir(&fixture, "unrelated-r04");

    remove::run(
        &source,
        remove::RemoveOptions {
            path: outpost.clone(),
            force: true,
        },
    )
    .expect("force remove dirty outpost");

    assert!(!outpost.exists());
    assert_registry_empty(&source);
    assert!(sentinel.exists());
    assert_source_branch_exists(&source, "main");
}

#[test]
fn remove_force_deletes_outpost_with_unpushed_commits() {
    let fixture = AbcFixture::new();
    let outpost = fixture.add_outpost("C").expect("add C");
    fixture
        .commit_in_outpost(&outpost, "outpost-only commit")
        .expect("commit in outpost");
    let source = fixture.source_repo().expect("source repo");
    let sentinel = create_unrelated_dir(&fixture, "unrelated-r05");

    remove::run(
        &source,
        remove::RemoveOptions {
            path: outpost.clone(),
            force: true,
        },
    )
    .expect("force remove unpushed outpost");

    assert!(!outpost.exists());
    assert_registry_empty(&source);
    assert!(sentinel.exists());
    assert_source_branch_exists(&source, "main");
}

#[test]
fn remove_unregistered_path_returns_registry_entry_not_found() {
    let fixture = AbcFixture::new();
    let source = fixture.source_repo().expect("source repo");
    let path = fixture.root.join("unregistered");
    fs::create_dir(&path).expect("unregistered dir");

    let err = expect_error(
        remove::run(
            &source,
            remove::RemoveOptions {
                path: path.clone(),
                force: false,
            },
        ),
        "unregistered remove should fail",
    );

    assert!(
        matches!(err, OutpostError::RegistryEntryNotFound(err_path) if err_path == canonical(&path))
    );
    assert!(path.exists());
    assert_registry_empty(&source);
}

#[test]
fn remove_unlocked_missing_registered_path_deregisters_without_rmtree() {
    let fixture = AbcFixture::new();
    let outpost = fixture.add_outpost("C").expect("add C");
    let source = fixture.source_repo().expect("source repo");
    let sentinel = create_unrelated_dir(&fixture, "unrelated-r07");
    fs::remove_dir_all(&outpost).expect("remove outpost dir");

    remove::run(
        &source,
        remove::RemoveOptions {
            path: outpost.clone(),
            force: false,
        },
    )
    .expect("remove missing registered path");

    assert!(!outpost.exists());
    assert_registry_empty(&source);
    assert!(sentinel.exists());
}

#[test]
fn remove_registry_entry_pointing_at_unrelated_dir_returns_not_managed() {
    let fixture = AbcFixture::new();
    let source = fixture.source_repo().expect("source repo");
    let unrelated = fixture.root.join("unrelated");
    fs::create_dir(&unrelated).expect("unrelated dir");
    let sentinel = unrelated.join("keep.txt");
    fs::write(&sentinel, "keep").expect("unrelated file");
    register_existing_path(&source, &unrelated).expect("register unrelated path");

    let err = expect_error(
        remove::run(
            &source,
            remove::RemoveOptions {
                path: unrelated.clone(),
                force: true,
            },
        ),
        "unrelated registered path should fail",
    );

    assert!(
        matches!(err, OutpostError::RegistryEntryNotManaged(path) if path == canonical(&unrelated))
    );
    assert!(unrelated.exists());
    assert!(sentinel.exists());
    assert_eq!(single_entry(&source).path, canonical(&unrelated));
}

#[test]
fn remove_wrong_source_outpost_returns_not_managed() {
    let fixture = AbcFixture::new();
    let outpost = fixture.add_outpost("C").expect("add C");
    fs::remove_dir_all(&outpost).expect("remove original outpost");
    let other = AbcFixture::new();
    let other_outpost = other.add_outpost("C").expect("add other C");
    fs::rename(&other_outpost, &outpost).expect("move wrong-source outpost");
    let source = fixture.source_repo().expect("source repo");

    let err = expect_error(
        remove::run(
            &source,
            remove::RemoveOptions {
                path: outpost.clone(),
                force: true,
            },
        ),
        "wrong-source remove should fail",
    );

    assert!(
        matches!(err, OutpostError::RegistryEntryNotManaged(path) if path == canonical(&outpost))
    );
    assert!(outpost.exists());
    assert!(outpost.join(".git").exists());
    assert_eq!(single_entry(&source).path, canonical(&outpost));
}

#[test]
fn remove_refuses_locked_outpost_unless_forced() {
    let fixture = AbcFixture::new();
    let outpost = fixture.add_outpost("C").expect("add C");
    let source = fixture.source_repo().expect("source repo");
    let sentinel = create_unrelated_dir(&fixture, "unrelated-r10");
    lock_registry_entry(&source, &outpost, Some("keep")).expect("lock setup");

    let err = expect_error(
        remove::run(
            &source,
            remove::RemoveOptions {
                path: outpost.clone(),
                force: false,
            },
        ),
        "locked remove should fail",
    );

    assert!(
        matches!(err, OutpostError::OutpostLocked { path, reason } if path == canonical(&outpost) && reason == ": keep")
    );
    assert!(outpost.exists());
    let entry = single_entry(&source);
    assert_eq!(entry.path, canonical(&outpost));
    assert!(entry.locked);

    remove::run(
        &source,
        remove::RemoveOptions {
            path: outpost.clone(),
            force: true,
        },
    )
    .expect("force remove locked outpost");

    assert!(!outpost.exists());
    assert_registry_empty(&source);
    assert!(sentinel.exists());
    assert_source_branch_exists(&source, "main");
}

#[test]
fn remove_locked_missing_path_requires_force_then_deregisters() {
    let fixture = AbcFixture::new();
    let outpost = fixture.add_outpost("C").expect("add C");
    let source = fixture.source_repo().expect("source repo");
    let sentinel = create_unrelated_dir(&fixture, "unrelated-r11");
    lock_registry_entry(&source, &outpost, Some("keep")).expect("lock setup");
    fs::remove_dir_all(&outpost).expect("remove outpost dir");

    let err = expect_error(
        remove::run(
            &source,
            remove::RemoveOptions {
                path: outpost.clone(),
                force: false,
            },
        ),
        "locked missing remove should fail",
    );

    assert!(
        matches!(err, OutpostError::OutpostLocked { path, reason } if path == canonical_missing(&outpost) && reason == ": keep")
    );
    assert!(!outpost.exists());
    let entry = single_entry(&source);
    assert_eq!(entry.path, canonical_missing(&outpost));
    assert!(entry.locked);

    remove::run(
        &source,
        remove::RemoveOptions {
            path: outpost.clone(),
            force: true,
        },
    )
    .expect("force remove locked missing path");

    assert!(!outpost.exists());
    assert_registry_empty(&source);
    assert!(sentinel.exists());
}

fn single_entry(source: &SourceRepo) -> RegistryEntry {
    let registry = source.registry().expect("registry");
    assert_eq!(registry.entries().len(), 1);
    registry.entries()[0].clone()
}

fn assert_registry_empty(source: &SourceRepo) {
    let registry = source.registry().expect("registry");
    assert!(registry.entries().is_empty());
}

fn register_existing_path(source: &SourceRepo, path: &Path) -> OutpostResult<()> {
    let mut registry = source.registry_mut()?;
    registry.add(RegistryEntry::new(
        path.to_path_buf(),
        RemoteName::parse("local")?,
    )?)?;
    registry.save()
}

fn lock_registry_entry(
    source: &SourceRepo,
    path: &Path,
    reason: Option<&str>,
) -> OutpostResult<()> {
    let mut registry = source.registry_mut()?;
    registry.lock(path, reason.map(str::to_owned))?;
    registry.save()
}

fn create_unrelated_dir(fixture: &AbcFixture, name: &str) -> PathBuf {
    let path = fixture.root.join(name);
    fs::create_dir(&path).expect("unrelated dir");
    let sentinel = path.join("keep.txt");
    fs::write(&sentinel, "keep").expect("unrelated file");
    sentinel
}

fn assert_source_branch_exists(source: &SourceRepo, branch: &str) {
    let branch = BranchName::parse(branch.to_owned()).expect("branch name");
    assert!(
        source.branch_exists(&branch).expect("branch exists query"),
        "source branch {} should remain",
        branch.as_str()
    );
}

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

fn canonical(path: &Path) -> PathBuf {
    fs::canonicalize(path).expect("canonical path")
}

fn canonical_missing(path: &Path) -> PathBuf {
    let parent = path.parent().expect("path parent");
    canonical(parent).join(path.file_name().expect("file name"))
}