Implemented support for local synchronization to a remote Git repo

This commit is contained in:
2025-09-09 12:49:35 -06:00
parent 8ac9ca40df
commit a0b710b96b
7 changed files with 471 additions and 33 deletions
+284
View File
@@ -0,0 +1,284 @@
use anyhow::{Context, Result, anyhow};
use chrono::Utc;
use dialoguer::Confirm;
use dialoguer::theme::ColorfulTheme;
use indoc::formatdoc;
use log::debug;
use std::env;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use validator::Validate;
#[derive(Debug, Validate, Clone)]
pub struct SyncOpts<'a> {
#[validate(required)]
pub remote_url: &'a Option<String>,
#[validate(required)]
pub branch: &'a Option<String>,
pub user_name: &'a Option<String>,
pub user_email: &'a Option<String>,
pub git_executable: &'a Option<PathBuf>,
}
pub fn sync_and_push(opts: &SyncOpts<'_>) -> Result<()> {
debug!("Syncing with git: {:?}", opts);
opts.validate()
.with_context(|| "invalid git sync options")?;
let commit_message = format!("chore: sync @ {}", Utc::now().to_rfc3339());
let repo_dir = confy::get_configuration_file_path("gman", "vault")
.with_context(|| "get config dir")?
.parent()
.map(Path::to_path_buf)
.ok_or_else(|| anyhow!("Failed to determine repo dir"))?;
std::fs::create_dir_all(&repo_dir).with_context(|| format!("create {}", repo_dir.display()))?;
let git = resolve_git(opts.git_executable.as_ref())?;
ensure_git_available(&git)?;
let username = resolve_git_username(&git, opts.user_name.as_ref())?
.trim()
.to_string();
let email = resolve_git_email(&git, opts.user_email.as_ref())?
.trim()
.to_string();
let branch = opts.branch.as_ref().expect("no target branch defined");
let remote_url = opts.remote_url.as_ref().expect("no remote url defined");
debug!(
"{}",
formatdoc!(
r#"
Using repo dir: {}
git executable: {}
git user: {}
git user email: {}
git remote: {}"#,
repo_dir.display(),
git.display(),
username,
email,
remote_url
)
);
init_repo_if_needed(&git, &repo_dir, branch)?;
set_local_identity(&git, &repo_dir, username, email)?;
checkout_branch(&git, &repo_dir, branch)?;
set_origin(&git, &repo_dir, remote_url)?;
stage_all(&git, &repo_dir)?;
fetch_and_pull(&git, &repo_dir, branch)?;
commit_now(&git, &repo_dir, &commit_message)?;
run_git(
&git,
&repo_dir,
&["push", "-u", "origin", "--force", branch],
)?;
run_git(&git, &repo_dir, &["remote", "set-head", "origin", "-a"])
.with_context(|| "Failed to set remote HEAD")
}
fn resolve_git_username(git: &Path, name: Option<&String>) -> Result<String> {
debug!("Resolving git username");
if let Some(name) = name {
return Ok(name.to_string());
}
run_git_config_capture(&git, &["config", "user.name"])
.with_context(|| "unable to determine git username")
}
fn resolve_git_email(git: &Path, email: Option<&String>) -> Result<String> {
debug!("Resolving git user email");
if let Some(email) = email {
return Ok(email.to_string());
}
run_git_config_capture(&git, &["config", "user.email"])
.with_context(|| "unable to determine git user email")
}
fn resolve_git(override_path: Option<&PathBuf>) -> Result<PathBuf> {
debug!("Resolving git executable");
if let Some(p) = override_path {
return Ok(p.to_path_buf());
}
if let Ok(s) = env::var("GIT_EXECUTABLE") {
return Ok(PathBuf::from(s));
}
Ok(PathBuf::from("git"))
}
fn ensure_git_available(git: &Path) -> Result<()> {
let ok = Command::new(git)
.arg("--version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.context("run git --version")?
.success();
if !ok {
Err(anyhow!("`git` not available on PATH"))
} else {
Ok(())
}
}
fn run_git(git: &Path, repo: &Path, args: &[&str]) -> Result<()> {
let status = Command::new(git)
.arg("-C")
.arg(repo)
.args(args)
.status()
.with_context(|| format!("git {}", args.join(" ")))?;
if !status.success() {
return Err(anyhow!("git failed: {}", args.join(" ")));
}
Ok(())
}
fn run_git_config_capture(git: &Path, args: &[&str]) -> Result<String> {
let out = Command::new(git)
.args(args)
.output()
.with_context(|| format!("git {}", args.join(" ")))?;
if !out.status.success() {
return Err(anyhow!(
"git failed (exit {}): {}",
out.status.code().unwrap_or(-1),
String::from_utf8_lossy(&out.stderr)
));
}
Ok(String::from_utf8_lossy(&out.stdout).to_string())
}
fn init_repo_if_needed(git: &Path, repo: &Path, branch: &str) -> Result<()> {
let inside = Command::new(git)
.arg("-C")
.arg(repo)
.args(["rev-parse", "--git-dir"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
if !inside {
run_git(
git,
repo,
&["-c", &format!("init.defaultBranch={branch}"), "init"],
)?;
} else {
let _ = run_git(
git,
repo,
&["symbolic-ref", "HEAD", &format!("refs/heads/{branch}")],
);
}
Ok(())
}
fn set_local_identity(git: &Path, repo: &Path, username: String, email: String) -> Result<()> {
run_git(git, repo, &["config", "user.name", &username])?;
run_git(git, repo, &["config", "user.email", &email])?;
Ok(())
}
fn checkout_branch(git: &Path, repo: &Path, branch: &str) -> Result<()> {
run_git(git, repo, &["checkout", "-B", branch])?;
Ok(())
}
fn set_origin(git: &Path, repo: &Path, url: &str) -> Result<()> {
let has_origin = Command::new(git)
.arg("-C")
.arg(repo)
.args(["remote", "get-url", "origin"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
if has_origin {
run_git(git, repo, &["remote", "set-url", "origin", url])?;
} else {
if Confirm::with_theme(&ColorfulTheme::default())
.with_prompt(format!("Have you already created the remote origin '{url}' on the Git host so we can push to it?"))
.default(false)
.interact()?
{
run_git(git, repo, &["remote", "add", "origin", url])?;
} else {
return Err(anyhow!("Remote origin does not yet exist. Please create remote origin before synchronizing, then try again"));
}
}
Ok(())
}
fn stage_all(git: &Path, repo: &Path) -> Result<()> {
run_git(git, repo, &["add", "-A"])?;
Ok(())
}
fn fetch_and_pull(git: &Path, repo: &Path, branch: &str) -> Result<()> {
run_git(git, repo, &["fetch", "origin", branch])
.with_context(|| "Failed to fetch changes from remote")?;
run_git(
git,
repo,
&["merge", "--ff-only", &format!("origin/{branch}")],
)
.with_context(|| "Failed to merge remote changes")?;
Ok(())
}
fn has_head(git: &Path, repo: &Path) -> bool {
Command::new(git)
.arg("-C")
.arg(repo)
.args(["rev-parse", "--verify", "HEAD"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn commit_now(git: &Path, repo: &Path, msg: &str) -> Result<()> {
let staged_changed = Command::new(git)
.arg("-C")
.arg(repo)
.args(["diff", "--cached", "--quiet", "--exit-code"])
.status()
.context("git diff --cached")?
.code()
.map(|c| c == 1)
.unwrap_or(false);
if staged_changed {
run_git(git, repo, &["commit", "-m", msg])?;
return Ok(());
}
let unborn = !has_head(git, repo);
if unborn {
run_git(
git,
repo,
&["commit", "--allow-empty", "-m", "initial sync commit"],
)?;
return Ok(());
}
Ok(())
}