shackle-shell 0.4.1

A shell for restricting access on a version control server
Documentation
mod cli_test_utils;

use anyhow::Result;
use cli_test_utils::*;
use std::os::unix::fs::MetadataExt;

const REPO_NAME: &str = "my-repository";
const REPO_NAME_2: &str = "my-other-repository";
const DEFAULT_DESCRIPTION: &str =
    "Unnamed repository; edit this file 'description' to name the repository.";

#[test]
fn can_init_a_new_git_repo() -> Result<()> {
    let mut c = TestContext::new_interactive()?;
    c.p.send_line(&format!("init {}", REPO_NAME))?;
    c.expect_successful_init_message(&personal_repo_path(REPO_NAME))?;

    let repo_dir = c.personal_repo_dir(REPO_NAME);
    verify_repo_exists(&repo_dir);
    verify_current_branch(&repo_dir, "refs/heads/main");
    verify_repo_config_value(&repo_dir, "core.sharedrepository", None);

    Ok(())
}

#[test]
fn can_init_a_new_shared_git_repo() -> Result<()> {
    let mut c = TestContext::new_interactive()?;
    let group = arbitrary_user_group();
    c.p.send_line(&format!("init --group {} {}", group, REPO_NAME))?;
    c.expect_successful_init_message(&group_repo_path(&group, REPO_NAME))?;

    let repo_dir = c.group_repo_dir(&group, REPO_NAME);
    verify_repo_exists(&repo_dir);
    verify_repo_config_value(&repo_dir, "core.sharedrepository", Some("1"));

    let expected_gid = nix::unistd::Group::from_name(&group)
        .unwrap()
        .unwrap()
        .gid
        .as_raw();
    let group_dir = repo_dir.parent().unwrap();
    let group_dir_metadata = group_dir.metadata().unwrap();
    assert_eq!(group_dir_metadata.gid(), expected_gid);
    assert_eq!(
        group_dir_metadata.mode(),
        0o42770,
        "Mode is {:o}",
        group_dir_metadata.mode()
    );

    assert_eq!(repo_dir.metadata().unwrap().gid(), expected_gid);

    Ok(())
}

#[test]
fn does_not_init_shared_repo_if_the_user_isnt_in_the_group() -> Result<()> {
    let mut c = TestContext::new_interactive()?;
    let group = "not-a-real-group";
    c.p.send_line(&format!("init --group {} {}", group, REPO_NAME))?;
    c.p.exp_string("Unknown group")?;

    Ok(())
}

#[test]
fn list_can_print_an_empty_list() -> Result<()> {
    let mut c = TestContext::new_interactive()?;
    c.expect_list_table(&[])?;

    Ok(())
}

#[test]
fn list_can_print_a_list_of_personal_repos_with_descriptions() -> Result<()> {
    let mut c = TestContext::new_interactive()?;
    c.p.send_line(&format!("init {}", REPO_NAME))?;
    c.expect_successful_init_message(&personal_repo_path(REPO_NAME))?;

    c.expect_list_table(&[(&personal_repo_path(REPO_NAME), DEFAULT_DESCRIPTION)])?;

    Ok(())
}

#[test]
fn list_can_print_a_list_of_all_repos_with_descriptions() -> Result<()> {
    let mut c = TestContext::new_interactive()?;
    c.p.send_line(&format!("init {}", REPO_NAME))?;
    c.expect_successful_init_message(&personal_repo_path(REPO_NAME))?;

    let group = arbitrary_user_group();
    c.p.send_line(&format!("init --group {} {}", group, REPO_NAME_2))?;
    c.expect_successful_init_message(&group_repo_path(&group, REPO_NAME_2))?;

    c.expect_list_table(&[
        (&personal_repo_path(REPO_NAME), DEFAULT_DESCRIPTION),
        (&group_repo_path(&group, REPO_NAME_2), DEFAULT_DESCRIPTION),
    ])?;

    Ok(())
}

#[test]
fn list_can_print_a_verbose_list_of_all_repos() -> Result<()> {
    let mut c = TestContext::new_interactive()?;
    c.p.send_line(&format!("init {}", REPO_NAME))?;
    c.expect_successful_init_message(&personal_repo_path(REPO_NAME))?;

    let group = arbitrary_user_group();
    c.p.send_line(&format!("init --group {} {}", group, REPO_NAME_2))?;
    c.expect_successful_init_message(&group_repo_path(&group, REPO_NAME_2))?;

    c.expect_list_table_verbose(&[
        (&personal_repo_path(REPO_NAME), DEFAULT_DESCRIPTION),
        (&group_repo_path(&group, REPO_NAME_2), DEFAULT_DESCRIPTION),
    ])?;

    Ok(())
}

#[test]
fn can_set_the_description_on_a_repo_during_init() -> Result<()> {
    let mut c = TestContext::new_interactive()?;
    let description = "A cool repo that does cool things";
    c.p.send_line(&format!("init --description \"{description}\" {REPO_NAME}"))?;
    c.expect_successful_init_message(&personal_repo_path(REPO_NAME))?;

    c.expect_list_table(&[(&personal_repo_path(REPO_NAME), description)])?;

    Ok(())
}

#[test]
fn can_change_the_description_on_a_repo() -> Result<()> {
    let mut c = TestContext::new_interactive()?;
    let description = "A cool repo that does cool things";
    let repo_path = personal_repo_path(REPO_NAME);
    c.p.send_line(&format!("init {REPO_NAME}"))?;
    c.p.exp_string(&format!("Successfully created \"{repo_path}\"",))?;
    c.p.send_line(&format!(
        "set-description \"{repo_path}\" \"{description}\""
    ))?;
    c.p.exp_string("Successfully updated description")?;
    c.expect_prompt()?;
    c.expect_list_table(&[(&repo_path, description)])?;

    Ok(())
}

#[test]
fn can_set_the_main_branch_of_a_new_git_repo() -> Result<()> {
    let mut c = TestContext::new_interactive()?;
    let main_branch = "foobar";
    c.p.send_line(&format!("init --branch {} {}", main_branch, REPO_NAME))?;
    c.expect_successful_init_message(&personal_repo_path(REPO_NAME))?;

    let repo_dir = c.personal_repo_dir(REPO_NAME);
    verify_current_branch(&repo_dir, &format!("refs/heads/{main_branch}"));

    Ok(())
}

#[test]
fn can_change_the_main_branch_on_a_repo() -> Result<()> {
    let mut c = TestContext::new_interactive()?;
    let main_branch = "foobar";
    let repo_path = personal_repo_path(REPO_NAME);

    c.p.send_line(&format!("init {}", REPO_NAME))?;
    c.expect_successful_init_message(&repo_path)?;

    c.p.send_line(&format!("set-branch \"{repo_path}\" \"{main_branch}\""))?;
    c.p.exp_string("Successfully updated branch")?;

    let repo_dir = c.personal_repo_dir(REPO_NAME);
    verify_current_branch(&repo_dir, &format!("refs/heads/{main_branch}"));

    Ok(())
}

#[test]
fn can_delete_a_repo() -> Result<()> {
    let mut c = TestContext::new_interactive()?;
    let repo_path = personal_repo_path(REPO_NAME);
    let repo_dir = c.personal_repo_dir(REPO_NAME);

    c.p.send_line(&format!("init {}", REPO_NAME))?;
    c.expect_successful_init_message(&repo_path)?;
    verify_repo_exists(&repo_dir);

    c.p.send_line(&format!("delete \"{repo_path}\""))?;
    c.p.exp_string(&format!(
        "Are you sure you want to delete \"{repo_path}\"? (yes/no)"
    ))?;

    c.p.send_line("yes")?;
    c.p.exp_string(&format!("Successfully deleted \"{repo_path}\""))?;
    verify_repo_does_not_exist(&repo_dir);

    Ok(())
}

#[test]
fn repo_is_not_deleted_if_you_say_youre_not_sure() -> Result<()> {
    let mut c = TestContext::new_interactive()?;
    let repo_path = personal_repo_path(REPO_NAME);
    let repo_dir = c.personal_repo_dir(REPO_NAME);

    c.p.send_line(&format!("init {}", REPO_NAME))?;
    c.expect_successful_init_message(&repo_path)?;
    verify_repo_exists(&repo_dir);

    c.p.send_line(&format!("delete \"{repo_path}\""))?;
    c.p.exp_string(&format!(
        "Are you sure you want to delete \"{repo_path}\"? (yes/no)"
    ))?;

    c.p.send_line("no")?;
    c.p.exp_string(&format!("Action cancelled"))?;
    verify_repo_exists(&repo_dir);

    Ok(())
}

#[test]
fn git_housekeeping_repacks_objects() -> Result<()> {
    let mut c = TestContext::new_interactive()?;
    let repo_path = personal_repo_path(REPO_NAME);
    let repo_dir = c.personal_repo_dir(REPO_NAME);

    c.p.send_line(&format!("init {}", REPO_NAME))?;
    c.expect_successful_init_message(&repo_path)?;

    let checkout_dir = create_clone(&c, &repo_dir, REPO_NAME);
    create_commit(&checkout_dir)?;
    push(&checkout_dir, "main");

    let packs_dir = repo_dir.join("objects").join("pack");

    assert_eq!(packs_dir.read_dir()?.count(), 0);
    c.p.send_line(&format!("housekeeping {repo_path}"))?;
    c.p.exp_string(&format!("Successfully did housekeeping on \"{repo_path}\""))?;
    assert!(packs_dir.read_dir()?.count() > 0);

    Ok(())
}

#[test]
fn git_housekeeping_cleans_out_stale_refs() -> Result<()> {
    let mut c = TestContext::new_interactive()?;
    let repo_path = personal_repo_path(REPO_NAME);
    let repo_dir = c.personal_repo_dir(REPO_NAME);

    c.p.send_line(&format!("init {}", REPO_NAME))?;
    c.expect_successful_init_message(&repo_path)?;

    let checkout_dir = create_clone(&c, &repo_dir, REPO_NAME);
    let commit_hash = create_commit(&checkout_dir)?;
    push(&checkout_dir, "main:temporary-branch");
    push(&checkout_dir, ":temporary-branch");

    verify_commit_exists(&repo_dir, &commit_hash);
    c.p.send_line(&format!("housekeeping"))?;
    c.p.exp_string(&format!("Successfully did housekeeping on \"{repo_path}\""))?;
    verify_commit_does_not_exist(&repo_dir, &commit_hash);
    Ok(())
}

#[test]
fn can_mirror_an_existing_git_repo() -> Result<()> {
    let mut c = TestContext::new_interactive()?;
    c.p.send_line(&format!("init {}", REPO_NAME))?;
    c.expect_successful_init_message(&personal_repo_path(REPO_NAME))?;

    let upstream_repo_dir = c.personal_repo_dir(REPO_NAME);
    let upstream_checkout_dir = create_clone(&c, &upstream_repo_dir, REPO_NAME);
    let commit_hash = create_commit(&upstream_checkout_dir)?;
    push(&upstream_checkout_dir, "main");

    c.p.send_line(&format!(
        "init --mirror {} {}",
        upstream_repo_dir.display(),
        REPO_NAME_2
    ))?;
    c.expect_successful_init_message(&personal_repo_path(REPO_NAME_2))?;

    let repo_dir = c.personal_repo_dir(REPO_NAME_2);
    verify_repo_exists(&repo_dir);
    verify_current_branch(&repo_dir, "refs/heads/main");
    verify_commit_exists(&repo_dir, &commit_hash);
    verify_repo_config_value(&repo_dir, "core.sharedrepository", None);
    verify_repo_config_value(&repo_dir, "remote.origin.mirror", Some("true"));

    c.expect_list_table(&[
        (
            &personal_repo_path(REPO_NAME_2),
            &format!("Mirror of {}", upstream_repo_dir.display()),
        ),
        (&personal_repo_path(REPO_NAME), DEFAULT_DESCRIPTION),
    ])?;

    Ok(())
}

#[test]
fn can_mirror_an_existing_git_repo_with_a_custom_description() -> Result<()> {
    let mut c = TestContext::new_interactive()?;
    c.p.send_line(&format!("init {}", REPO_NAME))?;
    c.expect_successful_init_message(&personal_repo_path(REPO_NAME))?;

    let upstream_repo_dir = c.personal_repo_dir(REPO_NAME);
    let upstream_checkout_dir = create_clone(&c, &upstream_repo_dir, REPO_NAME);
    let commit_hash = create_commit(&upstream_checkout_dir)?;
    push(&upstream_checkout_dir, "main");

    let custom_description = "Does all the things";

    c.p.send_line(&format!(
        "init --mirror {} --description \"{}\" {}",
        upstream_repo_dir.display(),
        custom_description,
        REPO_NAME_2
    ))?;
    c.expect_successful_init_message(&personal_repo_path(REPO_NAME_2))?;

    let repo_dir = c.personal_repo_dir(REPO_NAME_2);
    verify_repo_exists(&repo_dir);
    verify_current_branch(&repo_dir, "refs/heads/main");
    verify_commit_exists(&repo_dir, &commit_hash);
    verify_repo_config_value(&repo_dir, "core.sharedrepository", None);
    verify_repo_config_value(&repo_dir, "remote.origin.mirror", Some("true"));

    c.expect_list_table(&[
        (&personal_repo_path(REPO_NAME_2), &custom_description),
        (&personal_repo_path(REPO_NAME), DEFAULT_DESCRIPTION),
    ])?;

    Ok(())
}