outpost-core 0.1.3

Core library for Git Outpost, a clone-backed alternative to git worktree workflows.
Documentation
use std::ffi::OsString;
use std::io;
use std::path::{Path, PathBuf};

use crate::metadata::Metadata;
use crate::path::git_path_arg;
use crate::registry::RegistryEntry;
use crate::safety;
use crate::{
    BranchName, Outpost, OutpostError, OutpostResult, RemoteName, Reporter, SourceRepo, StepKind,
};

pub enum AddCheckout {
    CheckoutExisting {
        target_branch: Option<BranchName>,
    },
    NewBranch {
        name: BranchName,
        target_branch: Option<BranchName>,
    },
}

pub struct AddOptions {
    pub destination: PathBuf,
    pub checkout: AddCheckout,
    pub remote_name: RemoteName,
}

pub fn run(
    source: &SourceRepo,
    opts: AddOptions,
    reporter: &mut dyn Reporter,
) -> OutpostResult<Outpost> {
    let AddOptions {
        destination,
        checkout,
        remote_name,
    } = opts;
    let destination = resolve_destination(source, &destination)?;
    check_destination_clean(&destination)?;

    let branch = resolve_existing_branch(source, &checkout)?;

    source.git().run_check([
        OsString::from("-c"),
        OsString::from("protocol.file.allow=user"),
        OsString::from("clone"),
        OsString::from("--no-shared"),
        OsString::from("--"),
        git_path_arg(source.work_tree()),
        git_path_arg(&destination),
    ])?;

    let outpost_git = crate::source_repo::invoker_at(&destination, source.env());
    if remote_name.as_str() != "origin" {
        outpost_git.run_check(["remote", "rename", "origin", remote_name.as_str()])?;
    }
    apply_checkout(source, &outpost_git, &checkout, &branch, &remote_name)?;
    let entry = RegistryEntry::new(destination.clone(), remote_name.clone())?;
    Metadata {
        source_repo: source.work_tree().to_path_buf(),
        remote_name: remote_name.clone(),
    }
    .write(&outpost_git)?;

    reporter.step(
        StepKind::ConfigChange,
        &format!(
            "configuring source {}: receive.denyCurrentBranch=updateInstead",
            source.work_tree().display()
        ),
    );
    source.git().run_check([
        "config",
        "--local",
        "receive.denyCurrentBranch",
        "updateInstead",
    ])?;

    let mut registry = source.registry_mut()?;
    registry.add(entry)?;
    registry.save()?;

    source.outpost_at(&destination)
}

fn resolve_destination(source: &SourceRepo, destination: &Path) -> OutpostResult<PathBuf> {
    let anchored = if destination.is_absolute() {
        destination.to_path_buf()
    } else {
        source.work_tree().join(destination)
    };
    let (parent, name) = destination_parent_and_name(&anchored)?;
    let parent = std::fs::canonicalize(&parent).map_err(|source| OutpostError::IoAt {
        path: parent.clone(),
        source,
    })?;

    Ok(parent.join(name))
}

fn resolve_existing_branch(
    source: &SourceRepo,
    checkout: &AddCheckout,
) -> OutpostResult<BranchName> {
    match checkout {
        AddCheckout::CheckoutExisting { target_branch } => {
            resolve_target_branch(source, target_branch)
        }
        AddCheckout::NewBranch { target_branch, .. } => {
            resolve_target_branch(source, target_branch)
        }
    }
}

fn resolve_target_branch(
    source: &SourceRepo,
    target_branch: &Option<BranchName>,
) -> OutpostResult<BranchName> {
    match target_branch {
        Some(branch) => {
            require_branch_exists(source, branch)?;
            Ok(branch.clone())
        }
        None => {
            let branch = source.current_branch()?;
            if source.branch_exists(&branch)? {
                Ok(branch)
            } else {
                Err(OutpostError::BranchNotFound {
                    branch: "HEAD".to_owned(),
                    repo: source.work_tree().to_path_buf(),
                })
            }
        }
    }
}

fn require_branch_exists(source: &SourceRepo, branch: &BranchName) -> OutpostResult<()> {
    if source.branch_exists(branch)? {
        Ok(())
    } else {
        Err(OutpostError::BranchNotFound {
            branch: branch.as_str().to_owned(),
            repo: source.work_tree().to_path_buf(),
        })
    }
}

fn check_destination_clean(destination: &Path) -> OutpostResult<()> {
    let (parent, name) = destination_parent_and_name(destination)?;
    safety::check_destination_clean(&parent, &name).map_err(|err| match err {
        OutpostError::DestinationExists(_) => {
            OutpostError::DestinationExists(destination.to_path_buf())
        }
        OutpostError::DestinationInsideRepo(_) => {
            OutpostError::DestinationInsideRepo(destination.to_path_buf())
        }
        other => other,
    })
}

fn destination_parent_and_name(destination: &Path) -> OutpostResult<(PathBuf, PathBuf)> {
    let parent = destination
        .parent()
        .filter(|path| !path.as_os_str().is_empty())
        .map(PathBuf::from)
        .unwrap_or_else(|| PathBuf::from("."));
    let name = destination.file_name().ok_or_else(|| OutpostError::IoAt {
        path: destination.to_path_buf(),
        source: io::Error::new(
            io::ErrorKind::InvalidInput,
            "destination path has no file name",
        ),
    })?;

    Ok((parent, PathBuf::from(name)))
}

fn apply_checkout(
    source: &SourceRepo,
    git: &crate::GitInvoker,
    checkout: &AddCheckout,
    target_branch: &BranchName,
    remote_name: &RemoteName,
) -> OutpostResult<()> {
    match checkout {
        AddCheckout::CheckoutExisting { .. } => git.run_check(["switch", target_branch.as_str()]),
        AddCheckout::NewBranch { name, .. } => {
            source
                .git()
                .run_check(["branch", name.as_str(), target_branch.as_str()])?;
            let remote_tracking_ref =
                format!("refs/remotes/{}/{}", remote_name.as_str(), name.as_str());
            let fetch_refspec = format!("{}:{remote_tracking_ref}", name.as_str());
            let remote_branch = format!("{}/{}", remote_name.as_str(), name.as_str());
            git.run_check(["fetch", remote_name.as_str(), &fetch_refspec])?;
            git.run_check(["switch", "--track", &remote_branch])
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn destination_parent_and_name_splits_bare_relative_path() {
        let (parent, name) =
            destination_parent_and_name(Path::new("outpost")).expect("split destination");

        assert_eq!(parent, PathBuf::from("."));
        assert_eq!(name, PathBuf::from("outpost"));
    }

    #[test]
    fn destination_parent_and_name_splits_nested_relative_path() {
        let (parent, name) =
            destination_parent_and_name(Path::new("nested/outpost")).expect("split destination");

        assert_eq!(parent, PathBuf::from("nested"));
        assert_eq!(name, PathBuf::from("outpost"));
    }
}