✨feature: Add remote git support
This commit is contained in:
@@ -102,8 +102,14 @@ async fn run_server(port_tx: Option<std::sync::mpsc::Sender<u16>>) {
|
||||
warn!("Git init skipped: {}", e);
|
||||
}
|
||||
|
||||
// Configure git remote from env (if provided)
|
||||
if let Err(e) = services::git::configure_remote_from_env() {
|
||||
warn!("Git remote setup skipped: {}", e);
|
||||
}
|
||||
|
||||
// Start auto-commit background task (tries to commit every 60s)
|
||||
services::git::start_auto_commit();
|
||||
services::git::start_auto_sync();
|
||||
|
||||
// CORS layer (permissive for local-only app)
|
||||
let cors = CorsLayer::permissive();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::Utc;
|
||||
@@ -96,6 +97,58 @@ pub struct RemoteInfo {
|
||||
/// The background task simply tries to commit every interval;
|
||||
/// commit_all() already handles "no changes" gracefully.
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct GitAuthConfig {
|
||||
username: String,
|
||||
private_key: Option<PathBuf>,
|
||||
public_key: Option<PathBuf>,
|
||||
passphrase: Option<String>,
|
||||
}
|
||||
|
||||
fn git_auth_config() -> GitAuthConfig {
|
||||
let username = std::env::var("IRONPAD_GIT_SSH_USERNAME").unwrap_or_else(|_| "git".to_string());
|
||||
let private_key = std::env::var("IRONPAD_GIT_SSH_PRIVATE_KEY")
|
||||
.ok()
|
||||
.map(PathBuf::from)
|
||||
.filter(|p| p.exists());
|
||||
let public_key = std::env::var("IRONPAD_GIT_SSH_PUBLIC_KEY")
|
||||
.ok()
|
||||
.map(PathBuf::from)
|
||||
.filter(|p| p.exists());
|
||||
let passphrase = std::env::var("IRONPAD_GIT_SSH_PASSPHRASE").ok();
|
||||
|
||||
GitAuthConfig {
|
||||
username,
|
||||
private_key,
|
||||
public_key,
|
||||
passphrase,
|
||||
}
|
||||
}
|
||||
|
||||
fn remote_callbacks() -> git2::RemoteCallbacks<'static> {
|
||||
let auth = git_auth_config();
|
||||
let mut callbacks = git2::RemoteCallbacks::new();
|
||||
|
||||
callbacks.credentials(move |_url, username_from_url, _allowed_types| {
|
||||
let username = username_from_url.unwrap_or(auth.username.as_str());
|
||||
|
||||
if let Some(private_key) = auth.private_key.as_deref() {
|
||||
let public_key: Option<&Path> = auth.public_key.as_deref();
|
||||
let passphrase = auth.passphrase.as_deref();
|
||||
|
||||
if let Ok(cred) = git2::Cred::ssh_key(username, public_key, private_key, passphrase) {
|
||||
return Ok(cred);
|
||||
}
|
||||
|
||||
tracing::warn!("SSH key auth from file failed, falling back to SSH agent");
|
||||
}
|
||||
|
||||
git2::Cred::ssh_key_from_agent(username)
|
||||
});
|
||||
|
||||
callbacks
|
||||
}
|
||||
|
||||
/// Get repository status
|
||||
pub fn get_status() -> Result<RepoStatus, String> {
|
||||
let data_path = config::data_dir();
|
||||
@@ -317,18 +370,9 @@ pub fn push_to_remote() -> Result<(), String> {
|
||||
return Err("No remote URL configured".to_string());
|
||||
}
|
||||
|
||||
// Create callbacks for authentication
|
||||
let mut callbacks = git2::RemoteCallbacks::new();
|
||||
|
||||
// Try to use credential helper from git config
|
||||
callbacks.credentials(|_url, username_from_url, _allowed_types| {
|
||||
// Try SSH agent first
|
||||
git2::Cred::ssh_key_from_agent(username_from_url.unwrap_or("git"))
|
||||
});
|
||||
|
||||
// Set up push options
|
||||
let mut push_options = git2::PushOptions::new();
|
||||
push_options.remote_callbacks(callbacks);
|
||||
push_options.remote_callbacks(remote_callbacks());
|
||||
|
||||
// Push the current branch
|
||||
let refspec = format!("refs/heads/{}:refs/heads/{}", branch_name, branch_name);
|
||||
@@ -351,6 +395,30 @@ pub fn has_remote() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Configure `origin` from IRONPAD_GIT_REMOTE_URL.
|
||||
/// If `origin` exists, updates its URL. Otherwise creates it.
|
||||
pub fn configure_remote_from_env() -> Result<(), String> {
|
||||
let remote_url = match std::env::var("IRONPAD_GIT_REMOTE_URL") {
|
||||
Ok(url) if !url.trim().is_empty() => url,
|
||||
_ => return Ok(()),
|
||||
};
|
||||
|
||||
let data_path = config::data_dir();
|
||||
let repo = Repository::open(data_path).map_err(|e| format!("Not a git repository: {}", e))?;
|
||||
|
||||
if repo.find_remote("origin").is_ok() {
|
||||
repo.remote_set_url("origin", &remote_url)
|
||||
.map_err(|e| format!("Failed to update origin URL: {}", e))?;
|
||||
tracing::info!("Updated git remote origin from IRONPAD_GIT_REMOTE_URL");
|
||||
} else {
|
||||
repo.remote("origin", &remote_url)
|
||||
.map_err(|e| format!("Failed to create origin remote: {}", e))?;
|
||||
tracing::info!("Configured git remote origin from IRONPAD_GIT_REMOTE_URL");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Start auto-commit background task.
|
||||
/// Tries to commit every 60 seconds; commit_all() already handles "no changes" gracefully.
|
||||
pub fn start_auto_commit() {
|
||||
@@ -374,6 +442,111 @@ pub fn start_auto_commit() {
|
||||
});
|
||||
}
|
||||
|
||||
fn fast_forward_to_upstream(repo: &Repository, branch_name: &str) -> Result<(), String> {
|
||||
let local_branch = repo
|
||||
.find_branch(branch_name, git2::BranchType::Local)
|
||||
.map_err(|e| format!("Local branch not found: {}", e))?;
|
||||
let upstream_branch = match local_branch.upstream() {
|
||||
Ok(upstream) => upstream,
|
||||
Err(_) => return Ok(()),
|
||||
};
|
||||
|
||||
let local_oid = match local_branch.get().target() {
|
||||
Some(oid) => oid,
|
||||
None => return Ok(()),
|
||||
};
|
||||
let upstream_oid = match upstream_branch.get().target() {
|
||||
Some(oid) => oid,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
let (ahead, behind) = repo
|
||||
.graph_ahead_behind(local_oid, upstream_oid)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if behind == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if ahead > 0 {
|
||||
tracing::warn!(
|
||||
"Remote is ahead by {} and local is ahead by {}; skipping auto fast-forward",
|
||||
behind,
|
||||
ahead
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let refname = format!("refs/heads/{}", branch_name);
|
||||
repo.reference(&refname, upstream_oid, true, "ironpad auto fast-forward")
|
||||
.map_err(|e| format!("Failed to move local branch ref: {}", e))?;
|
||||
repo.set_head(&refname)
|
||||
.map_err(|e| format!("Failed to set HEAD: {}", e))?;
|
||||
|
||||
let mut checkout = git2::build::CheckoutBuilder::new();
|
||||
checkout.force();
|
||||
repo.checkout_head(Some(&mut checkout))
|
||||
.map_err(|e| format!("Failed to checkout fast-forwarded HEAD: {}", e))?;
|
||||
|
||||
tracing::info!("Fast-forwarded local branch {} by {} commit(s)", branch_name, behind);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sync_once() -> Result<(), String> {
|
||||
configure_remote_from_env()?;
|
||||
if !has_remote() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
fetch_from_remote()?;
|
||||
|
||||
// Try to fast-forward local branch to upstream when safe (no divergence).
|
||||
let data_path = config::data_dir();
|
||||
let repo = Repository::open(data_path).map_err(|e| format!("Not a git repository: {}", e))?;
|
||||
if let Ok(head) = repo.head() {
|
||||
if let Some(branch_name) = head.shorthand() {
|
||||
let _ = fast_forward_to_upstream(&repo, branch_name);
|
||||
}
|
||||
}
|
||||
|
||||
// Push local commits (if any) after fetching.
|
||||
if let Err(e) = push_to_remote() {
|
||||
if !(e.contains("non-fast-forward") || e.contains("rejected")) {
|
||||
return Err(e);
|
||||
}
|
||||
tracing::warn!("Auto-sync push skipped: {}", e);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Start periodic remote sync.
|
||||
/// Controlled via env vars:
|
||||
/// - IRONPAD_GIT_SYNC_INTERVAL_SECS: sync interval in seconds (default 0 = disabled)
|
||||
/// - IRONPAD_GIT_REMOTE_URL: optional remote URL used to create/update `origin`
|
||||
pub fn start_auto_sync() {
|
||||
let interval_secs = std::env::var("IRONPAD_GIT_SYNC_INTERVAL_SECS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
if interval_secs == 0 {
|
||||
tracing::info!("Git auto-sync disabled (set IRONPAD_GIT_SYNC_INTERVAL_SECS > 0 to enable)");
|
||||
return;
|
||||
}
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut ticker = interval(Duration::from_secs(interval_secs));
|
||||
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
if let Err(e) = sync_once() {
|
||||
tracing::warn!("Git auto-sync failed: {}", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Get commit history (most recent first)
|
||||
pub fn get_log(limit: Option<usize>) -> Result<Vec<CommitDetail>, String> {
|
||||
let data_path = config::data_dir();
|
||||
|
||||
Reference in New Issue
Block a user