expri 0.1.4

Repo-local remote experiment workflow tools
use std::collections::BTreeSet;
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::process::Command;

use tempfile::TempDir;

use crate::archive::sha256_file;
use crate::error::{ExpriError, Result};
use crate::filter::SyncRules;

#[derive(Debug)]
pub struct SourceBundle {
  pub _temp_dir: TempDir,
  pub path: PathBuf,
  pub digest: String,
  pub size: u64,
}

#[derive(Debug)]
pub struct DirtyPaths {
  pub files: Vec<PathBuf>,
  pub deleted: Vec<PathBuf>,
}

#[derive(Clone, Debug)]
pub struct RemoteCandidate {
  pub name: String,
  pub url: String,
  pub base_commit: Option<String>,
  pub distance: Option<u64>,
}

pub fn head(repo_root: &Path) -> Result<String> {
  git_capture(repo_root, ["rev-parse", "HEAD"])
}

pub fn nearest_remote_url(repo_root: &Path, head: &str) -> Result<Option<RemoteCandidate>> {
  let remotes = git_capture(repo_root, ["remote"])?;
  let mut candidates = Vec::new();
  for remote in remotes.lines().filter(|remote| !remote.is_empty()) {
    let urls = git_capture_vec(repo_root, ["remote", "get-url", "--all", remote])?;
    let Some(url) = urls.first() else {
      continue;
    };
    let refs = remote_tracking_refs(repo_root, remote)?;
    let mut best_base = None;
    let mut best_distance = None;
    for ref_name in refs {
      let Ok(base) = git_capture(repo_root, ["merge-base", head, &ref_name]) else {
        continue;
      };
      if base.is_empty() {
        continue;
      }
      let distance = git_capture(
        repo_root,
        ["rev-list", "--count", &format!("{base}..{head}")],
      )?
      .parse::<u64>()
      .unwrap_or(u64::MAX);
      if best_distance.is_none_or(|current| distance < current) {
        best_base = Some(base);
        best_distance = Some(distance);
      }
    }
    candidates.push(RemoteCandidate {
      name: remote.to_string(),
      url: url.clone(),
      base_commit: best_base,
      distance: best_distance,
    });
  }
  candidates.sort_by(|left, right| {
    left
      .distance
      .unwrap_or(u64::MAX)
      .cmp(&right.distance.unwrap_or(u64::MAX))
      .then_with(|| remote_rank(&left.name).cmp(&remote_rank(&right.name)))
      .then_with(|| left.name.cmp(&right.name))
  });
  Ok(candidates.into_iter().next())
}

pub fn build_source_bundle(repo_root: &Path, base_commit: Option<&str>) -> Result<SourceBundle> {
  let temp_dir = tempfile::Builder::new().prefix("expri-source-").tempdir()?;
  let path = temp_dir.path().join("source.bundle");
  let refspec = match base_commit {
    Some(base_commit) => format!("{base_commit}..HEAD"),
    None => "HEAD".to_string(),
  };
  git_run(
    repo_root,
    [
      OsStr::new("bundle"),
      OsStr::new("create"),
      path.as_os_str(),
      OsStr::new(&refspec),
    ],
  )?;
  let (digest, size) = sha256_file(&path)?;
  Ok(SourceBundle {
    _temp_dir: temp_dir,
    path,
    digest,
    size,
  })
}

pub fn fetch_bundle_to_ref(repo_root: &Path, bundle_path: &Path, ref_name: &str) -> Result<()> {
  git_run(
    repo_root,
    [
      OsStr::new("fetch"),
      bundle_path.as_os_str(),
      OsStr::new(&format!("+HEAD:{ref_name}")),
    ],
  )
}

pub fn dirty_paths(repo_root: &Path, rules: &SyncRules) -> Result<DirtyPaths> {
  let mut relative_paths = BTreeSet::new();
  for value in git_capture_bytes(repo_root, ["diff", "--name-only", "-z", "HEAD", "--"])?
    .split(|byte| *byte == 0)
  {
    if !value.is_empty() {
      relative_paths.insert(PathBuf::from(String::from_utf8_lossy(value).as_ref()));
    }
  }
  for value in git_capture_bytes(
    repo_root,
    ["ls-files", "--others", "--exclude-standard", "-z"],
  )?
  .split(|byte| *byte == 0)
  {
    if !value.is_empty() {
      relative_paths.insert(PathBuf::from(String::from_utf8_lossy(value).as_ref()));
    }
  }
  for path in rules.include_ignored() {
    let relative_path = PathBuf::from(path);
    if repo_root.join(&relative_path).exists() {
      relative_paths.insert(relative_path);
    }
  }

  let mut files = Vec::new();
  let mut deleted = Vec::new();
  for relative_path in relative_paths {
    if !rules.should_include(&relative_path) {
      continue;
    }
    let absolute_path = repo_root.join(&relative_path);
    if absolute_path.is_file() {
      files.push(relative_path);
    } else {
      deleted.push(relative_path);
    }
  }
  Ok(DirtyPaths { files, deleted })
}

pub fn ls_files(repo_root: &Path, paths: &[PathBuf]) -> Result<Vec<u8>> {
  let mut command = Command::new("git");
  command.current_dir(repo_root);
  command.args(["ls-files", "-z", "--"]);
  command.args(paths);
  let output = command.output().map_err(|source| ExpriError::IoContext {
    action: "run git in",
    path: repo_root.display().to_string(),
    source,
  })?;
  if !output.status.success() {
    return Err(ExpriError::CommandFailed {
      program: "git".to_string(),
      code: output.status.code(),
    });
  }
  Ok(output.stdout)
}

fn git_capture<const N: usize>(repo_root: &Path, args: [&str; N]) -> Result<String> {
  let output = Command::new("git")
    .current_dir(repo_root)
    .args(args)
    .output()
    .map_err(|source| ExpriError::IoContext {
      action: "run git in",
      path: repo_root.display().to_string(),
      source,
    })?;
  if !output.status.success() {
    return Err(ExpriError::CommandFailed {
      program: "git".to_string(),
      code: output.status.code(),
    });
  }
  Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}

fn git_capture_vec<const N: usize>(repo_root: &Path, args: [&str; N]) -> Result<Vec<String>> {
  Ok(
    git_capture(repo_root, args)?
      .lines()
      .filter(|line| !line.is_empty())
      .map(ToString::to_string)
      .collect(),
  )
}

fn remote_tracking_refs(repo_root: &Path, remote: &str) -> Result<Vec<String>> {
  let prefix = format!("refs/remotes/{remote}");
  let output = git_capture(repo_root, ["for-each-ref", "--format=%(refname)", &prefix])?;
  Ok(
    output
      .lines()
      .filter(|ref_name| !ref_name.ends_with("/HEAD"))
      .map(ToString::to_string)
      .collect(),
  )
}

fn remote_rank(remote: &str) -> u8 {
  match remote {
    "origin" => 0,
    "upstream" => 1,
    _ => 2,
  }
}

fn git_capture_bytes<const N: usize>(repo_root: &Path, args: [&str; N]) -> Result<Vec<u8>> {
  let output = Command::new("git")
    .current_dir(repo_root)
    .args(args)
    .output()
    .map_err(|source| ExpriError::IoContext {
      action: "run git in",
      path: repo_root.display().to_string(),
      source,
    })?;
  if !output.status.success() {
    return Err(ExpriError::CommandFailed {
      program: "git".to_string(),
      code: output.status.code(),
    });
  }
  Ok(output.stdout)
}

fn git_run<const N: usize>(repo_root: &Path, args: [&OsStr; N]) -> Result<()> {
  let status = Command::new("git")
    .current_dir(repo_root)
    .args(args)
    .status()
    .map_err(|source| ExpriError::IoContext {
      action: "run git in",
      path: repo_root.display().to_string(),
      source,
    })?;
  if !status.success() {
    return Err(ExpriError::CommandFailed {
      program: "git".to_string(),
      code: status.code(),
    });
  }
  Ok(())
}