garden-tools 2.6.0

Garden grows and cultivates collections of Git trees Garden lets you define and run commands over collections of configuration-defined multi-worktree Git environments.
Documentation
use garden::cmd;
use garden::display;
use garden::errors;
use garden::model;
use garden::string;

use anyhow::Result;
use escargot::CURRENT_TARGET;

fn initialize_environment() {
    // Simplify testing by using a canned environment.
    std::env::set_var("HOME", "/home/test");
    std::env::set_var("PATH", "/usr/bin:/bin");
    std::env::set_var("EMPTY", "");
    std::env::remove_var("PYTHONPATH");
}

pub fn garden_context_from_string(
    string: &str,
) -> Result<garden::model::ApplicationContext, errors::GardenError> {
    initialize_environment();

    model::ApplicationContext::from_string(string)
}

pub fn garden_context() -> Result<garden::model::ApplicationContext, errors::GardenError> {
    let string = string!(
        r#"
    garden:
        root: ${root}

    variables:
        echo_cmd: echo cmd
        echo_cmd_exec: $ ${echo_cmd}
        test: TEST
        local: ${test}/local
        src: src
        root: ~/${src}

    environment:
        EXAMPLE_VALUE=: ${GARDEN_ROOT}
        PATH: /home/test/bin

    templates:
        makefile:
            variables:
                prefix: ${TREE_PATH}/local
            commands:
                build: make -j prefix=${prefix} all
                install: make -j prefix=${prefix} install
                test: make test
        python:
            environment:
                PYTHONPATH: ${TREE_PATH}
        local:
            url: ${local}/${TREE_NAME}

    trees:
        git:
            url: https://github.com/git/git
            templates: makefile
            variables:
                prefix: ~/.local
            gitconfig:
                user.name: A U Thor
                user.email: author@example.com
        cola:
            url: https://github.com/git-cola/git-cola
            path: git-cola
            templates: [makefile, python]
            variables:
                prefix: ${TREE_PATH}/local
            environment:
                PATH:
                    - ${prefix}/bin
                    - ${TREE_PATH}/bin
                PYTHONPATH: ${GARDEN_ROOT}/python/send2trash
            commands:
                test:
                    - git status --short
                    - make tox
            remotes:
                davvid: git@github.com:davvid/git-cola.git
        python/qtpy:
            url: https://github.com/spider-ide/qtpy.git
            templates: python
        tmp:
            environment:
                EMPTY: [a, b]
                ${TREE_NAME}_VALUE=: ${TREE_PATH}

            path: /tmp
            templates: local
        annex/data:
            url: git@example.com:git-annex/data.git
            gitconfig:
                remote.origin.annex-ignore: true
            remotes:
                local: ${GARDEN_ROOT}/annex/local
        annex/local:
            extend: annex/data
        oneline: git@example.com:example/oneline.git

    groups:
        cola: [git, cola, python/qtpy]
        test: [a, b, c]
        reverse: [cola, git]
        annex: annex/*
        annex-1: annex/data
        annex-2: annex/local

    gardens:
        cola:
            groups: cola
            variables:
                prefix: ~/apps/git-cola/current
            environment:
                GIT_COLA_TRACE=: full
                PATH+: ${prefix}/bin
            commands:
                summary:
                    - git branch
                    - git status --short
        git:
            groups: cola
            trees: gitk
            gitconfig:
                user.name: A U Thor
                user.email: author@example.com
        annex/group:
            groups: annex
        annex/wildcard-groups:
            groups: annex-*
        annex/wildcard-trees:
            trees: annex/*
    "#
    );

    garden_context_from_string(&string)
}

/// Execute the "garden" command with the specified arguments.
pub fn exec_garden(args: &[&str]) -> Result<()> {
    let mut argv: Vec<&str> = vec!["garden"];
    argv.extend(args);
    display::print_command_vec(&argv);

    let mut exec = cargo_bin_cmd("garden").expect("garden command");
    exec.args(args);

    assert!(exec.status().expect("garden returned an error").success());
    Ok(())
}

/// Execute a command and ensure that exit status 0 is returned.
/// Return the captured stdout value as a string.
pub fn garden_capture(args: &[&str]) -> String {
    let mut argv: Vec<&str> = vec!["garden"];
    argv.extend(args);
    display::print_command_vec(&argv);

    let mut exec = cargo_bin_cmd("garden").expect("garden command");
    exec.args(args);
    let capture = exec.output();
    assert!(capture.is_ok());

    let utf8_result = String::from_utf8(capture.unwrap().stdout);
    assert!(utf8_result.is_ok());

    utf8_result.unwrap().trim_end().into()
}

/// Execute a command and ensure that exit status 0 is returned.
/// Return the captured stdout value as a string.
pub fn garden_exec(args: &[&str]) -> (i32, String, String) {
    let mut argv: Vec<&str> = vec!["garden"];
    argv.extend(args);
    display::print_command_vec(&argv);

    let mut exec = cargo_bin_cmd("garden").expect("garden command");
    exec.args(args);
    let output_result = exec.output();
    assert!(output_result.is_ok());
    let status = exec.status().unwrap().code().unwrap_or(0);
    let output = output_result.unwrap();
    let utf8_stdout = String::from_utf8(output.stdout);
    let utf8_stderr = String::from_utf8(output.stderr);
    assert!(utf8_stdout.is_ok());
    assert!(utf8_stderr.is_ok());

    (
        status,
        utf8_stdout.unwrap().trim_end().into(),
        utf8_stderr.unwrap().trim_end().into(),
    )
}

/// Execute a command and ensure that the exit status is returned.
pub fn assert_cmd_status(cmd: &[&str], directory: &str, status: i32) {
    display::print_command_vec(cmd);
    let exec = cmd::exec_in_dir(cmd, directory);
    let cmd_status = cmd::status(exec);
    assert_eq!(cmd_status, status);
}

/// Execute a command and ensure that exit status 0 is returned.
pub fn assert_cmd(cmd: &[&str], directory: &str) {
    assert_cmd_status(cmd, directory, errors::EX_OK);
}

/// Execute a command and ensure that exit status 0 is returned. Return the Exec object.
pub fn assert_cmd_capture(cmd: &[&str], directory: &str) -> String {
    display::print_command_vec(cmd);
    let exec = cmd::exec_in_dir(cmd, directory);
    let capture = cmd::stdout_to_string(exec);
    assert!(capture.is_ok());

    capture.unwrap()
}

/// Assert that the specified path exists.
pub fn assert_path(path: &str) {
    let pathbuf = std::path::PathBuf::from(path);
    assert!(pathbuf.exists());
}

/// Assert that the specified path is a Git worktree.
pub fn assert_git_worktree(path: &str) {
    assert_path(&format!("{path}/.git"));
}

/// Assert that the Git ref exists in the specified repository.
pub fn assert_ref(repository: &str, refname: &str) {
    let cmd = ["git", "rev-parse", "--quiet", "--verify", refname];
    assert_cmd(&cmd, repository);
}

/// Assert that the Git ref does not exist in the specified repository.
pub fn assert_ref_missing(repository: &str, refname: &str) {
    let cmd = ["git", "rev-parse", "--quiet", "--verify", refname];
    assert_cmd_status(&cmd, repository, 1);
}

/// Cleanup and create a bare repository for cloning
fn setup_tmp_bare_repo(name: &str, path: &str) {
    let cmd = ["../integration/setup.sh", name];
    assert_cmd(&cmd, path);
}

fn teardown_tmp_test_data(path: &str) {
    if let Err(err) = std::fs::remove_dir_all(path) {
        panic!("unable to remove '{path}': {err}");
    }
}

/// Provide a bare repository fixture for the current test.
pub struct BareRepoFixture<'a> {
    name: &'a str,
}

impl<'a> BareRepoFixture<'a> {
    /// Create the test bare repository.
    pub fn new(name: &'a str) -> Self {
        setup_tmp_bare_repo(name, "tests/tmp");

        Self { name }
    }

    /// Return the temporary directory for the current test.
    pub fn root(&self) -> String {
        format!("tests/tmp/{}", self.name)
    }

    /// Return a pathbuf
    pub fn root_pathbuf(&self) -> std::path::PathBuf {
        std::path::PathBuf::from(self.root())
    }

    /// Return the root as a worktree and verify that it is a worktree.
    pub fn root_worktree_pathbuf(&self) -> std::path::PathBuf {
        let worktree = self.root();
        self.assert_worktree(&worktree);
        worktree.into()
    }

    /// Verify that the path is a valid Git worktree.
    pub fn assert_worktree(&self, path: &str) {
        assert_git_worktree(path);
    }

    /// Return a path relative to the temporary directory for the current test.
    pub fn path(&self, path: &str) -> String {
        let fixture_path = format!("{}/{}", self.root(), path);
        assert_path(&fixture_path);

        fixture_path
    }

    /// Return a PathBuf relative to the temporary directory for the current test.
    pub fn pathbuf(&self, path: &str) -> std::path::PathBuf {
        std::path::PathBuf::from(self.path(path))
    }

    /// Asserts that the path is a Git worktree.
    /// Returns the path to the specified worktree.
    pub fn worktree(&self, path: &str) -> String {
        let worktree = self.path(path);
        assert_git_worktree(&worktree);

        worktree
    }

    /// Asserts that the path is a Git worktree.
    /// Returns a PathBuf for the specified worktree.
    pub fn worktree_pathbuf(&self, path: &str) -> std::path::PathBuf {
        std::path::PathBuf::from(self.worktree(path))
    }
}

impl Drop for BareRepoFixture<'_> {
    /// Teardown the test repository filesystem data.
    fn drop(&mut self) {
        teardown_tmp_test_data(&self.root());
    }
}

// assert_cmd v2.1.0 deprecated cargo_bin!() and the provided replacement
// does not work when running under "cross".
//
// cargo_bin_cmd() and related functions are duplicated here to avoid depending on deprecated code.
//
fn cargo_bin_cmd<S: AsRef<str>>(name: S) -> Result<std::process::Command, String> {
    let path = cargo_bin(name);
    if path.is_file() {
        if let Some(runner) = cargo_runner() {
            let mut cmd = std::process::Command::new(&runner[0]);
            cmd.args(&runner[1..]).arg(path);
            Ok(cmd)
        } else {
            Ok(std::process::Command::new(path))
        }
    } else {
        Err(format!("error: file not found: {path:?}"))
    }
}

fn cargo_runner() -> Option<Vec<String>> {
    let runner_env = format!(
        "CARGO_TARGET_{}_RUNNER",
        CURRENT_TARGET.replace('-', "_").to_uppercase()
    );
    let runner = std::env::var(runner_env).ok()?;
    Some(runner.split(' ').map(str::to_string).collect())
}

/// Look up the path to a cargo-built binary within an integration test.
/// Copied from the deprecated assert_cmd v2.1.0 src/cargo.rs.
fn cargo_bin<S: AsRef<str>>(name: S) -> std::path::PathBuf {
    cargo_bin_str(name.as_ref())
}

fn cargo_bin_str(name: &str) -> std::path::PathBuf {
    let env_var = format!("CARGO_BIN_EXE_{name}");
    std::env::var_os(env_var)
        .map(|p| p.into())
        .unwrap_or_else(|| target_dir().join(format!("{}{}", name, std::env::consts::EXE_SUFFIX)))
}

//
// Adapted from
// https://github.com/rust-lang/cargo/blob/485670b3983b52289a2f353d589c57fae2f60f82/tests/testsuite/support/mod.rs#L507
fn target_dir() -> std::path::PathBuf {
    std::env::current_exe()
        .ok()
        .map(|mut path| {
            path.pop();
            if path.ends_with("deps") {
                path.pop();
            }
            path
        })
        .expect("this should only be used where a `current_exe` can be set")
}