use std::path::{Path, PathBuf};
use std::process::Command;
use base64::Engine as _;
use serde::{Deserialize, Serialize};
use crate::platform::error::PlatformError;
use crate::platform::services::platform::PlatformService;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct SyncState {
pub branch: String,
pub remote_configured: bool,
pub remote_branch: String,
pub ahead: Option<u32>,
pub behind: Option<u32>,
pub rebase_in_progress: bool,
pub conflicts: Vec<String>,
pub dirty: u32,
}
impl SyncState {
pub fn word(&self) -> &'static str {
if !self.remote_configured {
return "no-remote";
}
if self.rebase_in_progress {
return "conflict";
}
match (self.ahead, self.behind) {
(Some(0), Some(0)) => "clean",
(Some(a), Some(0)) if a > 0 => "unpushed",
(Some(0), Some(b)) if b > 0 => "behind",
(Some(_), Some(_)) => "diverged",
_ => "unknown",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Resolution {
Mine,
Theirs,
Content,
}
pub struct GitSyncService {
platform: std::sync::Arc<PlatformService>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "outcome", rename_all = "lowercase")]
pub enum SyncOutcome {
Ok { state: SyncState },
Conflict { state: SyncState },
}
impl GitSyncService {
pub fn new(platform: std::sync::Arc<PlatformService>) -> Self {
Self { platform }
}
fn repo_dir(&self, owner: &str, project: &str) -> Result<PathBuf, PlatformError> {
Ok(self.platform.file.ensure_project_layout(owner, project)?.repo_dir)
}
fn remote(&self, owner: &str, project: &str) -> Result<(String, String, String), PlatformError> {
let cfg = self.platform.zebflow_cfg.read_or_default(owner, project)?;
let r = &cfg.configs.git.remote;
let branch = if r.branch.trim().is_empty() { "main".to_string() } else { r.branch.trim().to_string() };
Ok((r.repo_url.trim().to_string(), r.credential_id.trim().to_string(), branch))
}
fn auth_args(&self, owner: &str, project: &str, credential_id: &str, repo_url: &str) -> Vec<String> {
if credential_id.is_empty() || !repo_url.starts_with("http") {
return Vec::new();
}
let Ok(Some(cred)) = self.platform.credentials.get_project_credential(owner, project, credential_id) else {
return Vec::new();
};
let username = cred.secret["username"].as_str().unwrap_or("");
let token = cred.secret["token"].as_str().unwrap_or("");
if token.is_empty() {
return Vec::new();
}
let user = if username.is_empty() { "x-access-token" } else { username };
let basic = base64::engine::general_purpose::STANDARD.encode(format!("{user}:{token}"));
vec!["-c".to_string(), format!("http.extraheader=AUTHORIZATION: basic {basic}")]
}
fn identity_args(&self, actor: Option<&str>, project: &str) -> Vec<String> {
let id = self.platform.git_identity.resolve_for_actor(actor, project);
vec!["-c".into(), format!("user.name={}", id.name), "-c".into(), format!("user.email={}", id.email)]
}
fn ensure_origin(&self, repo: &Path, repo_url: &str) -> Result<(), PlatformError> {
if repo_url.is_empty() {
return Err(PlatformError::new("GIT_NO_REMOTE", "No remote configured. Connect a remote in the Git panel first."));
}
let current = run(repo, &[], &["remote", "get-url", "origin"]).ok();
match current {
Some(url) if url.trim() == repo_url => Ok(()),
Some(_) => run(repo, &[], &["remote", "set-url", "origin", repo_url]).map(|_| ()),
None => run(repo, &[], &["remote", "add", "origin", repo_url]).map(|_| ()),
}
}
pub fn state(&self, owner: &str, project: &str) -> Result<SyncState, PlatformError> {
let repo = self.repo_dir(owner, project)?;
let (repo_url, _, remote_branch) = self.remote(owner, project)?;
let branch = run(&repo, &[], &["rev-parse", "--abbrev-ref", "HEAD"]).map(|s| s.trim().to_string()).unwrap_or_else(|_| "HEAD".into());
let rebase_in_progress = repo.join(".git/rebase-merge").exists() || repo.join(".git/rebase-apply").exists();
let conflicts: Vec<String> = run(&repo, &[], &["diff", "--name-only", "--diff-filter=U"])
.map(|s| s.lines().filter(|l| !l.trim().is_empty()).map(|l| l.trim().to_string()).collect())
.unwrap_or_default();
let dirty = run(&repo, &[], &["status", "--porcelain=v1"]).map(|s| s.lines().filter(|l| !l.trim().is_empty()).count() as u32).unwrap_or(0);
let (ahead, behind) = if repo_url.is_empty() {
(None, None)
} else {
match run(&repo, &[], &["rev-list", "--left-right", "--count", &format!("HEAD...origin/{remote_branch}")]) {
Ok(s) => {
let mut it = s.split_whitespace();
(it.next().and_then(|a| a.parse().ok()), it.next().and_then(|b| b.parse().ok()))
}
Err(_) => (None, None),
}
};
Ok(SyncState { branch, remote_configured: !repo_url.is_empty(), remote_branch, ahead, behind, rebase_in_progress, conflicts, dirty })
}
pub fn fetch(&self, owner: &str, project: &str) -> Result<SyncState, PlatformError> {
let repo = self.repo_dir(owner, project)?;
let (repo_url, cred, remote_branch) = self.remote(owner, project)?;
self.ensure_origin(&repo, &repo_url)?;
let auth = self.auth_args(owner, project, &cred, &repo_url);
match run(&repo, &auth, &["fetch", "origin", &remote_branch]) {
Ok(_) => {}
Err(e) if e.message.contains("couldn't find remote ref") => {}
Err(e) => return Err(e.with_code("GIT_FETCH")),
}
self.state(owner, project)
}
pub fn sync(&self, owner: &str, project: &str, actor: Option<&str>) -> Result<SyncOutcome, PlatformError> {
let repo = self.repo_dir(owner, project)?;
let before = self.state(owner, project)?;
if before.rebase_in_progress {
return Ok(SyncOutcome::Conflict { state: before });
}
let fetched = self.fetch(owner, project)?;
if fetched.behind == Some(0) || fetched.behind.is_none() {
return Ok(SyncOutcome::Ok { state: fetched });
}
let identity = self.identity_args(actor, project);
let (_, _, remote_branch) = self.remote(owner, project)?;
match run_env(&repo, &identity, &["rebase", "--autostash", &format!("origin/{remote_branch}")], &[("GIT_EDITOR", "true")]) {
Ok(_) => Ok(SyncOutcome::Ok { state: self.state(owner, project)? }),
Err(err) => {
let state = self.state(owner, project)?;
if state.rebase_in_progress && !state.conflicts.is_empty() {
Ok(SyncOutcome::Conflict { state })
} else {
let _ = run(&repo, &[], &["rebase", "--abort"]);
Err(err.with_code("GIT_REBASE"))
}
}
}
}
pub fn push(&self, owner: &str, project: &str) -> Result<SyncState, PlatformError> {
let repo = self.repo_dir(owner, project)?;
let (repo_url, cred, remote_branch) = self.remote(owner, project)?;
self.ensure_origin(&repo, &repo_url)?;
let state = self.state(owner, project)?;
if state.rebase_in_progress {
return Err(PlatformError::new("GIT_CONFLICT", "a rebase is in progress — resolve the conflicts and continue, or abort"));
}
let auth = self.auth_args(owner, project, &cred, &repo_url);
match run(&repo, &auth, &["push", "--set-upstream", "origin", &format!("HEAD:{remote_branch}")]) {
Ok(_) => self.fetch(owner, project),
Err(err) => {
let msg = err.message.to_lowercase();
if msg.contains("rejected") || msg.contains("non-fast-forward") || msg.contains("fetch first") {
Err(PlatformError::new("GIT_BEHIND", "the remote has commits this project does not — sync first, then push"))
} else {
Err(err.with_code("GIT_PUSH"))
}
}
}
}
pub fn resolve(&self, owner: &str, project: &str, rel_path: &str, how: Resolution, content: Option<&str>) -> Result<SyncState, PlatformError> {
let repo = self.repo_dir(owner, project)?;
let state = self.state(owner, project)?;
let rel = rel_path.trim().trim_start_matches('/');
if !state.rebase_in_progress {
return Err(PlatformError::new("GIT_NO_CONFLICT", "no rebase in progress"));
}
if !state.conflicts.iter().any(|c| c == rel) {
return Err(PlatformError::new("GIT_NO_CONFLICT", format!("'{rel}' is not a conflicted file")));
}
match how {
Resolution::Mine => { run(&repo, &[], &["checkout", "--theirs", "--", rel])?; }
Resolution::Theirs => { run(&repo, &[], &["checkout", "--ours", "--", rel])?; }
Resolution::Content => {
let abs = repo.join(rel);
if !abs.starts_with(&repo) {
return Err(PlatformError::new("GIT_RESOLVE", "path leaves the repository"));
}
if let Some(text) = content {
std::fs::write(&abs, text)?;
}
let saved = std::fs::read_to_string(&abs).unwrap_or_default();
if saved.lines().any(|l| l.starts_with("<<<<<<< ") || l.starts_with(">>>>>>> ") || l == "=======") {
return Err(PlatformError::new("GIT_RESOLVE", format!("'{rel}' still has conflict markers — finish editing it first")));
}
}
}
run(&repo, &[], &["add", "--", rel])?;
self.state(owner, project)
}
pub fn continue_rebase(&self, owner: &str, project: &str, actor: Option<&str>) -> Result<SyncOutcome, PlatformError> {
let repo = self.repo_dir(owner, project)?;
let state = self.state(owner, project)?;
if !state.rebase_in_progress {
return Err(PlatformError::new("GIT_NO_CONFLICT", "no rebase in progress"));
}
if !state.conflicts.is_empty() {
return Err(PlatformError::new("GIT_CONFLICT", format!("{} file(s) still conflicted: {}", state.conflicts.len(), state.conflicts.join(", "))));
}
let identity = self.identity_args(actor, project);
match run_env(&repo, &identity, &["rebase", "--continue"], &[("GIT_EDITOR", "true")]) {
Ok(_) => Ok(SyncOutcome::Ok { state: self.state(owner, project)? }),
Err(err) => {
let state = self.state(owner, project)?;
if state.rebase_in_progress && !state.conflicts.is_empty() {
Ok(SyncOutcome::Conflict { state })
} else {
Err(err.with_code("GIT_REBASE"))
}
}
}
}
pub fn abort(&self, owner: &str, project: &str) -> Result<SyncState, PlatformError> {
let repo = self.repo_dir(owner, project)?;
if self.state(owner, project)?.rebase_in_progress {
run(&repo, &[], &["rebase", "--abort"])?;
}
self.state(owner, project)
}
}
fn run(repo: &Path, pre: &[String], args: &[&str]) -> Result<String, PlatformError> {
run_env(repo, pre, args, &[])
}
fn run_env(repo: &Path, pre: &[String], args: &[&str], env: &[(&str, &str)]) -> Result<String, PlatformError> {
let mut cmd = Command::new("git");
for p in pre {
cmd.arg(p);
}
cmd.arg("-C").arg(repo);
for a in args {
cmd.arg(a);
}
for (k, v) in env {
cmd.env(k, v);
}
let out = cmd.output().map_err(|e| PlatformError::new("GIT_EXEC", e.to_string()))?;
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
if out.status.success() {
Ok(stdout)
} else {
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
let msg = if stderr.is_empty() { stdout.trim().to_string() } else { stderr };
Err(PlatformError::new("GIT_FAILED", scrub_secrets(&msg)))
}
}
fn scrub_secrets(text: &str) -> String {
let re = regex::Regex::new(r"(?i)(authorization: basic )[A-Za-z0-9+/=]+|://[^/\s:]+:[^@\s]+@").unwrap();
re.replace_all(text, |c: ®ex::Captures| {
if c.get(1).is_some() { format!("{}[redacted]", &c[1]) } else { "://[redacted]@".to_string() }
})
.to_string()
}
trait WithCode {
fn with_code(self, code: &'static str) -> Self;
}
impl WithCode for PlatformError {
fn with_code(mut self, code: &'static str) -> Self {
if self.code == "GIT_FAILED" {
self.code = code;
}
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_state_word_reads_the_counts() {
let mut s = SyncState { remote_configured: true, ahead: Some(0), behind: Some(0), ..Default::default() };
assert_eq!(s.word(), "clean");
s.ahead = Some(2);
assert_eq!(s.word(), "unpushed");
s.behind = Some(1);
assert_eq!(s.word(), "diverged");
s.ahead = Some(0);
assert_eq!(s.word(), "behind");
s.rebase_in_progress = true;
assert_eq!(s.word(), "conflict");
s.remote_configured = false;
assert_eq!(s.word(), "no-remote");
let fresh = SyncState { remote_configured: true, ..Default::default() };
assert_eq!(fresh.word(), "unknown");
}
#[test]
fn secrets_never_survive_an_error_message() {
assert_eq!(scrub_secrets("fatal: https://user:ghp_abc@github.com/x/y failed"), "fatal: https://[redacted]@github.com/x/y failed");
assert_eq!(scrub_secrets("header AUTHORIZATION: basic dXNlcjp0b2tlbg== rejected"), "header AUTHORIZATION: basic [redacted] rejected");
}
}