use anyhow::Result;
use std::path::Path;
use tokio::process::Command;
pub struct Repo<'a> {
pub url: &'a str,
pub dst: &'a Path,
pub rev: Option<&'a str>,
}
#[derive(Debug, PartialEq, Eq)]
pub enum RepoStatus {
NotInstalled,
Clean,
Modified,
Error(String),
}
impl<'a> Repo<'a> {
pub fn new(url: &'a str, dst: &'a Path, rev: Option<&'a str>) -> Self {
Self { url, dst, rev }
}
pub async fn sync(&self) -> Result<()> {
let url = if !self.url.contains("://")
&& !self.url.contains("@")
&& !self.url.contains(":\\")
&& !self.url.starts_with("/")
{
format!("https://github.com/{}", self.url)
} else {
self.url.to_string()
};
let mut is_new_clone = false;
if self.dst.exists() {
let mut args = vec!["pull"];
if let Some(rev) = self.rev {
args = vec!["fetch", "--depth", "1", "origin", rev];
}
let output = Command::new("git")
.args(&args)
.current_dir(self.dst)
.output()
.await?;
if !output.status.success() {
anyhow::bail!(
"git pull/fetch failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
} else {
if let Some(parent) = self.dst.parent() {
std::fs::create_dir_all(parent)?;
}
let mut args = vec!["clone", "--depth", "1"];
if let Some(rev) = self.rev {
args.push("--branch");
args.push(rev);
}
args.push(&url);
args.push(self.dst.to_str().unwrap());
let output = Command::new("git").args(&args).output().await?;
if !output.status.success() {
if self.rev.is_some()
&& String::from_utf8_lossy(&output.stderr).contains("not found in upstream")
{
let output = Command::new("git")
.args(["clone", &url, self.dst.to_str().unwrap()]) .output()
.await?;
if !output.status.success() {
anyhow::bail!(
"git clone fallback failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
} else {
anyhow::bail!(
"git clone failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
}
is_new_clone = true;
}
if let Some(rev) = self.rev {
let output = Command::new("git")
.args(["checkout", rev])
.current_dir(self.dst)
.output()
.await?;
if !output.status.success() {
if is_new_clone {
let _ = std::fs::remove_dir_all(self.dst);
}
anyhow::bail!(
"git checkout failed for rev '{}': {}",
rev,
String::from_utf8_lossy(&output.stderr)
);
}
}
Ok(())
}
pub async fn update(&self) -> Result<()> {
if !self.dst.exists() {
anyhow::bail!("Plugin not installed: {}", self.dst.display());
}
let args: Vec<&str> = if let Some(rev) = self.rev {
vec!["fetch", "--depth", "1", "origin", rev]
} else {
vec!["pull"]
};
let output = Command::new("git")
.args(&args)
.current_dir(self.dst)
.output()
.await?;
if !output.status.success() {
anyhow::bail!(
"git pull/fetch failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
if let Some(rev) = self.rev {
let output = Command::new("git")
.args(["checkout", rev])
.current_dir(self.dst)
.output()
.await?;
if !output.status.success() {
anyhow::bail!(
"git checkout failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
}
Ok(())
}
pub async fn get_status(&self) -> RepoStatus {
if !self.dst.exists() {
return RepoStatus::NotInstalled;
}
let status_output = Command::new("git")
.args(["status", "--porcelain"])
.current_dir(self.dst)
.output()
.await;
match status_output {
Ok(output) if output.status.success() => {
let stdout = String::from_utf8_lossy(&output.stdout);
if !stdout.trim().is_empty() {
return RepoStatus::Modified;
}
}
_ => return RepoStatus::Error("Failed to run git status".to_string()),
}
if let Some(rev) = self.rev {
let verify = Command::new("git")
.args(["rev-parse", "--verify", rev])
.current_dir(self.dst)
.output()
.await;
match verify {
Ok(output) if output.status.success() => {}
_ => return RepoStatus::Error(format!("rev '{}' not found in local repo", rev)),
}
}
RepoStatus::Clean
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[tokio::test]
async fn test_sync_cleans_up_on_invalid_rev() {
let root = tempdir().unwrap();
let src = root.path().join("src");
let dst = root.path().join("dst");
fs::create_dir_all(&src).unwrap();
Command::new("git")
.args(["init"])
.current_dir(&src)
.output()
.await
.unwrap();
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(&src)
.output()
.await
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(&src)
.output()
.await
.unwrap();
fs::write(src.join("hello.txt"), "hello").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(&src)
.output()
.await
.unwrap();
Command::new("git")
.args(["commit", "-m", "init"])
.current_dir(&src)
.output()
.await
.unwrap();
let repo = Repo::new(src.to_str().unwrap(), &dst, Some("nonexistent-rev"));
let result = repo.sync().await;
assert!(result.is_err(), "存在しない rev は sync エラーになるべき");
assert!(!dst.exists(), "失敗後にディレクトリが残ってはいけない");
}
#[tokio::test]
async fn test_get_status_errors_on_invalid_rev() {
let root = tempdir().unwrap();
let src = root.path().join("src");
fs::create_dir_all(&src).unwrap();
Command::new("git")
.args(["init"])
.current_dir(&src)
.output()
.await
.unwrap();
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(&src)
.output()
.await
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(&src)
.output()
.await
.unwrap();
fs::write(src.join("hello.txt"), "hello").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(&src)
.output()
.await
.unwrap();
Command::new("git")
.args(["commit", "-m", "init"])
.current_dir(&src)
.output()
.await
.unwrap();
let repo = Repo::new(src.to_str().unwrap(), &src, Some("nonexistent-rev"));
let status = repo.get_status().await;
assert!(
matches!(status, RepoStatus::Error(_)),
"存在しない rev は get_status が Error を返すべき、実際: {:?}",
status
);
}
#[tokio::test]
async fn test_git_update_method_pulls_latest() {
let root = tempdir().unwrap();
let src = root.path().join("src");
let dst = root.path().join("dst");
fs::create_dir_all(&src).unwrap();
Command::new("git")
.args(["init"])
.current_dir(&src)
.output()
.await
.unwrap();
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(&src)
.output()
.await
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(&src)
.output()
.await
.unwrap();
fs::write(src.join("hello.txt"), "v1").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(&src)
.output()
.await
.unwrap();
Command::new("git")
.args(["commit", "-m", "v1"])
.current_dir(&src)
.output()
.await
.unwrap();
let repo = Repo::new(src.to_str().unwrap(), &dst, None);
repo.sync().await.unwrap();
fs::write(src.join("hello.txt"), "v2").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(&src)
.output()
.await
.unwrap();
Command::new("git")
.args(["commit", "-m", "v2"])
.current_dir(&src)
.output()
.await
.unwrap();
repo.update().await.unwrap();
let content = fs::read_to_string(dst.join("hello.txt")).unwrap();
assert_eq!(content, "v2");
}
#[tokio::test]
async fn test_git_update_method_fails_when_not_installed() {
let root = tempdir().unwrap();
let dst = root.path().join("nonexistent");
let repo = Repo::new("dummy/repo", &dst, None);
let result = repo.update().await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not installed"));
}
#[tokio::test]
async fn test_git_update() {
let root = tempdir().unwrap();
let src = root.path().join("src");
let dst = root.path().join("dst");
fs::create_dir_all(&src).unwrap();
Command::new("git")
.args(["init"])
.current_dir(&src)
.output()
.await
.unwrap();
fs::write(src.join("hello.txt"), "hello").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(&src)
.output()
.await
.unwrap();
Command::new("git")
.args(["commit", "-m", "init"])
.current_dir(&src)
.output()
.await
.unwrap();
let repo = Repo::new(src.to_str().unwrap(), &dst, None);
repo.sync().await.unwrap();
fs::write(src.join("hello.txt"), "updated").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(&src)
.output()
.await
.unwrap();
Command::new("git")
.args(["commit", "-m", "update"])
.current_dir(&src)
.output()
.await
.unwrap();
repo.sync().await.unwrap();
let content = fs::read_to_string(dst.join("hello.txt")).unwrap();
assert_eq!(content, "updated");
}
#[tokio::test]
async fn test_git_status() {
let root = tempdir().unwrap();
let src = root.path().join("src");
fs::create_dir_all(&src).unwrap();
Command::new("git")
.args(["init"])
.current_dir(&src)
.output()
.await
.unwrap();
fs::write(src.join("hello.txt"), "hello").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(&src)
.output()
.await
.unwrap();
Command::new("git")
.args(["commit", "-m", "init"])
.current_dir(&src)
.output()
.await
.unwrap();
let repo = Repo::new(src.to_str().unwrap(), &src, None);
assert_eq!(repo.get_status().await, RepoStatus::Clean);
fs::write(src.join("hello.txt"), "modified").unwrap();
assert_eq!(repo.get_status().await, RepoStatus::Modified);
}
#[tokio::test]
async fn test_git_rev_checkout() {
let root = tempdir().unwrap();
let src = root.path().join("src");
let dst = root.path().join("dst");
fs::create_dir_all(&src).unwrap();
Command::new("git")
.args(["init"])
.current_dir(&src)
.output()
.await
.unwrap();
fs::write(src.join("hello.txt"), "v1").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(&src)
.output()
.await
.unwrap();
Command::new("git")
.args(["commit", "-m", "v1"])
.current_dir(&src)
.output()
.await
.unwrap();
Command::new("git")
.args(["tag", "v1.0"])
.current_dir(&src)
.output()
.await
.unwrap();
fs::write(src.join("hello.txt"), "v2").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(&src)
.output()
.await
.unwrap();
Command::new("git")
.args(["commit", "-m", "v2"])
.current_dir(&src)
.output()
.await
.unwrap();
let repo = Repo::new(src.to_str().unwrap(), &dst, Some("v1.0"));
repo.sync().await.unwrap();
let content = fs::read_to_string(dst.join("hello.txt")).unwrap();
assert_eq!(content, "v1");
}
}