git-subcopy 0.1.0

Link files across git repositories
Documentation
use std::{collections::HashMap, fs, path::{PathBuf, Path}};

use anyhow::{anyhow, Context, Result};
use git2::{
    build::RepoBuilder,
    Config,
    Oid,
    Repository,
    ResetType,
    TreeWalkMode,
    TreeWalkResult,
};
use log::{debug, info};
use tempfile::Builder;
use walkdir::WalkDir;

fn path_to_string(path: &Path) -> Result<&str> {
    path.to_str().ok_or_else(|| anyhow!("path must be valid utf-8"))
}

#[derive(Debug, Default)]
pub struct SubcopyConfigOption {
    pub url: Option<String>,
    pub rev: Option<String>,
    pub upstream_path: Option<PathBuf>,
    pub local_path: PathBuf,
}
#[derive(Debug, Default)]
pub struct SubcopyConfig {
    pub url: String,
    pub rev: String,
    pub upstream_path: PathBuf,
}

pub struct App {
    cache_dir: PathBuf,
}
impl App {
    pub fn new() -> Result<Self> {
        Ok(Self {
            cache_dir: dirs::cache_dir().map(|mut path| {
                path.push(env!("CARGO_PKG_NAME"));
                path
            }).ok_or_else(|| anyhow!("can't choose a cache directory"))?,
        })
    }

    pub fn fetch(&self, url: &str, update_existing: bool) -> Result<Repository> {
        let path = self.cache_dir.join(base64::encode_config(url, base64::URL_SAFE_NO_PAD));

        if path.exists() {
            let repo = Repository::open_bare(&path).context("failed to open cached bare repository")?;

            if update_existing {
                info!("Fetching upstream in existing repository...");
                repo.remote_anonymous(url).context("failed to create anonymous remote")?
                    .fetch(&[], None, None).context("failed to fetch from anonymous remote")?;
            }
            Ok(repo)
        } else {
            info!("Cloning new repository...");
            Ok(RepoBuilder::new()
               .bare(true)
               .clone(url, &path)
               .context("failed to clone repository")?)
        }
    }

    pub fn extract(&self, repo: &'_ Repository, rev: Oid, upstream_path: &Path, local_path: &Path) -> Result<()> {
        info!("Extracting files...");

        let tree = repo.find_object(rev, None).context("failed to find object at revision")?
            .peel_to_tree().context("failed to turn object into a tree")?;
        let entry = tree.get_path(upstream_path).context("failed to get path")?;
        let object = entry.to_object(&repo).context("failed to get path's object")?;

        if let Ok(blob) = object.peel_to_blob() {
            fs::write(local_path, blob.content()).context("failed to write file")?;
        } else {
            let tree = object.peel_to_tree()?;

            fs::create_dir_all(local_path)?;
            let mut error = None;
            tree.walk(TreeWalkMode::PreOrder, |dir, entry| {
                let inner = || -> Result<()> {
                    let object = entry.to_object(&repo)?;
                    let mut path = local_path.join(dir);
                    path.push(entry.name().ok_or_else(|| anyhow!("name is not utf-8 encoded"))?);

                    if let Ok(blob) = object.peel_to_blob() {
                        fs::write(path, blob.content()).context("failed to write file")?;
                    } else if object.peel_to_tree().is_ok() {
                        fs::create_dir_all(path)?;
                    }
                    Ok(())
                };
                match inner() {
                    Ok(()) => TreeWalkResult::Ok,
                    Err(err) => {
                        error = Some(err);
                        TreeWalkResult::Abort
                    }
                }
            })?;
            if let Some(err) = error {
                return Err(err);
            }
        }
        Ok(())
    }

    pub fn canonicalize(&self, repo: &Repository, local_path: &Path) -> Result<PathBuf> {
        let workdir = repo.workdir().ok_or_else(|| anyhow!("repository is bare and has no workdir"))?
            .canonicalize().context("failed to find full path to repository workdir")?;
        let local_path = local_path.canonicalize().context("failed to find full path to destination directory")?;
        let relative = local_path.strip_prefix(&workdir).context("destination directory not in a repository")?;

        Ok(relative.to_path_buf())
    }

    pub fn register(&self, url: &str, rev: Oid, upstream_path: &Path, local_path: &Path) -> Result<()> {
        let repo = Repository::open_from_env()?;
        let relative = self.canonicalize(&repo, local_path)?;
        let workdir = repo.workdir().expect("canonicalize has already checked this");

        let relative_str = path_to_string(&relative)?;

        let mut config = Config::open(&workdir.join(".gitcopies")).context("failed to open .gitcopies")?;
        config.set_str(&format!("subcopy.{}.url", relative_str), url)?;
        config.set_str(&format!("subcopy.{}.rev", relative_str), &rev.to_string())?;
        config.set_str(&format!("subcopy.{}.upstreamPath", relative_str), path_to_string(upstream_path)?)?;
        Ok(())
    }

    pub fn list(&self) -> Result<HashMap<String, SubcopyConfigOption>> {
        let repo = Repository::open_from_env()?;
        let workdir = repo.workdir().ok_or_else(|| anyhow!("repository is bare and has no workdir"))?;
        let mut config = Config::open(&workdir.join(".gitcopies")).context("failed to open .gitcopies")?;
        let snapshot = config.snapshot().context("failed to take a snapshot of config")?;

        let mut map: HashMap<String, SubcopyConfigOption> = HashMap::new();

        for entry in &snapshot.entries(Some(r"^subcopy\..*\.(url|rev|upstreampath)$")).context("failed to iter config entries")? {
            let entry = entry.context("failed to read config entry")?;
            let name = entry.name().ok_or_else(|| anyhow!("entry name was not valid utf-8"))?;

            let withoutend = name.rsplitn(2, '.').nth(1).ok_or_else(|| anyhow!("incomplete subcopy property name"))?;
            let middle = withoutend.splitn(2, '.').nth(1).ok_or_else(|| anyhow!("incomplete subcopy property name"))?;
            let slot = map.entry(middle.to_owned()).or_insert_with(|| SubcopyConfigOption {
                local_path: PathBuf::from(&middle),
                ..SubcopyConfigOption::default()
            });

            if name.ends_with("url") {
                slot.url = entry.value().map(String::from);
            } else if name.ends_with("rev") {
                slot.rev = entry.value().map(String::from);
            } else if name.ends_with("upstreampath") {
                slot.upstream_path = entry.value().map(PathBuf::from);
            }
        }

        Ok(map)
    }

    pub fn get(&self, key: &Path) -> Result<SubcopyConfig> {
        let repo = Repository::open_from_env()?;
        let key = self.canonicalize(&repo, key)?;

        let workdir = repo.workdir().ok_or_else(|| anyhow!("repository is bare and has no workdir"))?;
        let mut config = Config::open(&workdir.join(".gitcopies")).context("failed to open .gitcopies")?;
        let snapshot = config.snapshot().context("failed to take a snapshot of config")?;

        let key = path_to_string(&key)?;

        Ok(SubcopyConfig {
            url: snapshot.get_string(&format!("subcopy.{}.url", key))?,
            rev: snapshot.get_string(&format!("subcopy.{}.rev", key))?,
            upstream_path: snapshot.get_path(&format!("subcopy.{}.upstreamPath", key))?,
        })
    }

    pub fn with_repo<F, T>(&self, url: &str, rev: &str, upstream_path: &Path, local_path: &Path, callback: F) -> Result<T>
    where
        F: FnOnce(&Repository) -> Result<T>,
    {
        let tmp = Builder::new().prefix("git-subcopy").tempdir().context("failed to get temporary directory")?;
        let upstream_repo = {
            let upstream_bare = self.fetch(url, false).context("failed to fetch source repository")?;
            let upstream_bare_path = upstream_bare.path().canonicalize().context("failed to get full cache path")?;
            let upstream_str = path_to_string(&upstream_bare_path)?;

            info!("Cloning cached repo...");
            Repository::clone(&upstream_str, tmp.path())
                .context("failed to clone cache of upstream repository")?
        };

        upstream_repo.remote("upstream", url).context("failed to add upstream remote")?;

        let rev = upstream_repo.revparse_single(rev).context("failed to parse revision")?;
        upstream_repo.reset(&rev, ResetType::Hard, None).context("failed to reset repository")?;

        info!("Copying changes...");
        let upstream_path = tmp.path().join(upstream_path);

        if local_path.is_file() {
            debug!("{} -> {}", local_path.display(), upstream_path.display());
            fs::copy(local_path, &upstream_path).context("failed to copy file")?;
        } else {
            for entry in WalkDir::new(local_path) {
                let entry = entry.context("failed to read directory entry")?;

                let from = entry.path();
                let to_relative = entry.path().strip_prefix(local_path).context("walkdir should always have prefix")?;
                let to = upstream_path.join(to_relative);

                debug!("{} -> {}", from.display(), to.display());
                if entry.file_type().is_dir() {
                    fs::create_dir_all(&to).context("failed to copy dir")?;
                } else {
                    fs::copy(from, &to).context("failed to copy file")?;
                }
            }
        }

        let ret = callback(&upstream_repo)?;

        if upstream_path.is_file() {
            debug!("{} -> {}", upstream_path.display(), upstream_path.display());
            fs::copy(&upstream_path, local_path).context("failed to copy file")?;
        } else {
            for entry in WalkDir::new(&upstream_path).into_iter().filter_entry(|e| e.file_name().to_str() != Some(".git")) {
                let entry = entry.context("failed to read directory entry")?;

                let from = entry.path();
                let to_relative = entry.path().strip_prefix(&upstream_path).context("walkdir should always have prefix")?;
                let to = local_path.join(to_relative);

                debug!("{} -> {}", from.display(), to.display());
                if entry.file_type().is_dir() {
                    fs::create_dir_all(&to).context("failed to copy dir")?;
                } else {
                    fs::copy(from, &to).context("failed to copy file")?;
                }
            }
        }

        Ok(ret)
    }
}