Added support for multiple providers and wrote additional regression tests. Also fixed a bug with local synchronization with remote Git repositories when the CLI was just installed but the remote repo already exists with stuff in it.

This commit is contained in:
2025-09-11 15:07:16 -06:00
parent 0f5c28a040
commit a8d959dac3
19 changed files with 1155 additions and 239 deletions
+100 -9
View File
@@ -66,10 +66,16 @@ pub fn sync_and_push(opts: &SyncOpts<'_>) -> Result<()> {
checkout_branch(&git, &repo_dir, branch)?;
set_origin(&git, &repo_dir, remote_url)?;
stage_all(&git, &repo_dir)?;
// Always align local with remote before staging/committing. For a fresh
// repo where the remote already has content, we intentionally discard any
// local working tree changes and take the remote state to avoid merge
// conflicts on first sync.
fetch_and_pull(&git, &repo_dir, branch)?;
// Stage and commit any subsequent local changes after aligning with remote
// so we don't merge uncommitted local state.
stage_all(&git, &repo_dir)?;
commit_now(&git, &repo_dir, &commit_message)?;
run_git(
@@ -228,17 +234,51 @@ fn stage_all(git: &Path, repo: &Path) -> Result<()> {
}
fn fetch_and_pull(git: &Path, repo: &Path, branch: &str) -> Result<()> {
run_git(git, repo, &["fetch", "origin", branch])
// Fetch all refs from origin (safe even if branch doesn't exist remotely)
run_git(git, repo, &["fetch", "origin", "--prune"])
.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")?;
let origin_ref = format!("origin/{branch}");
let remote_has_branch = has_remote_branch(git, repo, branch);
// If the repo has no commits yet, prefer remote state and discard local
// if the remote branch exists. Otherwise, keep local state and allow an
// initial commit to be created and pushed.
if !has_head(git, repo) {
if remote_has_branch {
run_git(git, repo, &["checkout", "-f", "-B", branch, &origin_ref])
.with_context(|| "Failed to checkout remote branch over local state")?;
run_git(git, repo, &["reset", "--hard", &origin_ref])
.with_context(|| "Failed to hard reset to remote branch")?;
run_git(git, repo, &["clean", "-fd"]).with_context(|| "Failed to clean untracked files")?;
}
return Ok(());
}
// If we have local history and the remote branch exists, fast-forward.
if remote_has_branch {
run_git(
git,
repo,
&["merge", "--ff-only", &origin_ref],
)
.with_context(|| "Failed to merge remote changes")?;
}
Ok(())
}
fn has_remote_branch(git: &Path, repo: &Path, branch: &str) -> bool {
Command::new(git)
.arg("-C")
.arg(repo)
.args(["show-ref", "--verify", "--quiet", &format!("refs/remotes/origin/{}", branch)])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn has_head(git: &Path, repo: &Path) -> bool {
Command::new(git)
.arg("-C")
@@ -280,3 +320,54 @@ fn commit_now(git: &Path, repo: &Path, msg: &str) -> Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sync_opts_validation_ok() {
let remote = Some("git@github.com:user/repo.git".to_string());
let branch = Some("main".to_string());
let opts = SyncOpts {
remote_url: &remote,
branch: &branch,
user_name: &None,
user_email: &None,
git_executable: &None,
};
assert!(opts.validate().is_ok());
}
#[test]
fn sync_opts_validation_missing_fields() {
let remote = None;
let branch = None;
let opts = SyncOpts {
remote_url: &remote,
branch: &branch,
user_name: &None,
user_email: &None,
git_executable: &None,
};
assert!(opts.validate().is_err());
}
#[test]
fn resolve_git_prefers_override_and_env() {
// Override path wins
let override_path = Some(PathBuf::from("/custom/git"));
let got = resolve_git(override_path.as_ref()).unwrap();
assert_eq!(got, PathBuf::from("/custom/git"));
// If no override, env var is used
unsafe {
env::set_var("GIT_EXECUTABLE", "/env/git");
}
let got_env = resolve_git(None).unwrap();
assert_eq!(got_env, PathBuf::from("/env/git"));
unsafe {
env::remove_var("GIT_EXECUTABLE");
}
}
}