Skip to main content

ralph/git/
commit.rs

1//! Git commit and push operations.
2//!
3//! This module provides functions for creating commits, restoring tracked paths,
4//! and pushing to upstream remotes. It handles error classification and provides
5//! clear feedback on failures.
6//!
7//! # Invariants
8//! - Upstream must be configured before pushing
9//! - Empty commit messages are rejected
10//! - No-changes commits are rejected
11//! - Path restores only operate on tracked files under the repo root
12//!
13//! # What this does NOT handle
14//! - Status checking (see git/status.rs)
15//! - LFS validation (see git/lfs.rs)
16//! - Repository cleanliness (see git/clean.rs)
17
18use crate::git::current_branch;
19use crate::git::error::{GitError, classify_push_error, git_base_command, git_run};
20use anyhow::Context;
21use std::path::{Path, PathBuf};
22
23use crate::git::status::status_porcelain;
24
25/// Revert uncommitted changes, restoring the working tree to current HEAD.
26///
27/// This discards ONLY uncommitted changes. It does NOT reset to a pre-run SHA.
28pub fn revert_uncommitted(repo_root: &Path) -> Result<(), GitError> {
29    // Revert tracked changes in both index and working tree.
30    // Prefer `git restore` (modern); fall back to older `git checkout` syntax.
31    if git_run(repo_root, &["restore", "--staged", "--worktree", "."]).is_err() {
32        // Older git fallback.
33        git_run(repo_root, &["checkout", "--", "."]).context("fallback git checkout -- .")?;
34        // Ensure staged changes are cleared too.
35        git_run(repo_root, &["reset", "--quiet", "HEAD"]).context("git reset --quiet HEAD")?;
36    }
37
38    // Remove untracked files/directories created during the run.
39    git_run(repo_root, &["clean", "-fd", "-e", ".env", "-e", ".env.*"])
40        .context("git clean -fd -e .env*")?;
41    Ok(())
42}
43
44/// Create a commit with all changes.
45///
46/// Stages everything and creates a single commit with the given message.
47/// Returns an error if the message is empty or there are no changes to commit.
48pub fn commit_all(repo_root: &Path, message: &str) -> Result<(), GitError> {
49    let message = message.trim();
50    if message.is_empty() {
51        return Err(GitError::EmptyCommitMessage);
52    }
53
54    git_run(repo_root, &["add", "-A"]).context("git add -A")?;
55    let status = status_porcelain(repo_root)?;
56    if status.trim().is_empty() {
57        return Err(GitError::NoChangesToCommit);
58    }
59
60    git_run(repo_root, &["commit", "-m", message]).context("git commit")?;
61    Ok(())
62}
63
64/// Force-add existing paths, even if they are ignored.
65///
66/// Paths must be under the repo root; missing or outside paths are skipped.
67pub fn add_paths_force(repo_root: &Path, paths: &[PathBuf]) -> Result<(), GitError> {
68    if paths.is_empty() {
69        return Ok(());
70    }
71
72    let mut rel_paths: Vec<String> = Vec::new();
73    for path in paths {
74        if !path.exists() {
75            continue;
76        }
77        let rel = match path.strip_prefix(repo_root) {
78            Ok(rel) => rel,
79            Err(_) => {
80                log::debug!(
81                    "Skipping force-add for path outside repo root: {}",
82                    path.display()
83                );
84                continue;
85            }
86        };
87        if rel.as_os_str().is_empty() {
88            continue;
89        }
90        rel_paths.push(rel.to_string_lossy().to_string());
91    }
92
93    if rel_paths.is_empty() {
94        return Ok(());
95    }
96
97    let mut add_args: Vec<String> = vec!["add".to_string(), "-f".to_string(), "--".to_string()];
98    add_args.extend(rel_paths.iter().cloned());
99    let add_refs: Vec<&str> = add_args.iter().map(|s| s.as_str()).collect();
100    git_run(repo_root, &add_refs).context("git add -f -- <paths>")?;
101    Ok(())
102}
103
104/// Restore tracked paths to the current HEAD (index + working tree).
105///
106/// Paths must be under the repo root; untracked paths are skipped.
107pub fn restore_tracked_paths_to_head(repo_root: &Path, paths: &[PathBuf]) -> Result<(), GitError> {
108    if paths.is_empty() {
109        return Ok(());
110    }
111
112    let mut rel_paths: Vec<String> = Vec::new();
113    for path in paths {
114        let rel = match path.strip_prefix(repo_root) {
115            Ok(rel) => rel,
116            Err(_) => {
117                log::debug!(
118                    "Skipping restore for path outside repo root: {}",
119                    path.display()
120                );
121                continue;
122            }
123        };
124        if rel.as_os_str().is_empty() {
125            continue;
126        }
127        let rel_str = rel.to_string_lossy().to_string();
128        if is_tracked_path(repo_root, &rel_str)? {
129            rel_paths.push(rel_str);
130        } else {
131            log::debug!("Skipping restore for untracked path: {}", rel.display());
132        }
133    }
134
135    if rel_paths.is_empty() {
136        return Ok(());
137    }
138
139    let mut restore_args: Vec<String> = vec![
140        "restore".to_string(),
141        "--staged".to_string(),
142        "--worktree".to_string(),
143        "--".to_string(),
144    ];
145    restore_args.extend(rel_paths.iter().cloned());
146    let restore_refs: Vec<&str> = restore_args.iter().map(|s| s.as_str()).collect();
147    if git_run(repo_root, &restore_refs).is_err() {
148        let mut checkout_args: Vec<String> = vec!["checkout".to_string(), "--".to_string()];
149        checkout_args.extend(rel_paths.iter().cloned());
150        let checkout_refs: Vec<&str> = checkout_args.iter().map(|s| s.as_str()).collect();
151        git_run(repo_root, &checkout_refs).context("fallback git checkout -- <paths>")?;
152
153        let mut reset_args: Vec<String> = vec![
154            "reset".to_string(),
155            "--quiet".to_string(),
156            "HEAD".to_string(),
157            "--".to_string(),
158        ];
159        reset_args.extend(rel_paths.iter().cloned());
160        let reset_refs: Vec<&str> = reset_args.iter().map(|s| s.as_str()).collect();
161        git_run(repo_root, &reset_refs).context("git reset --quiet HEAD -- <paths>")?;
162    }
163
164    Ok(())
165}
166
167fn is_tracked_path(repo_root: &Path, rel_path: &str) -> Result<bool, GitError> {
168    let output = git_base_command(repo_root)
169        .args(["ls-files", "--error-unmatch", "--", rel_path])
170        .output()
171        .with_context(|| {
172            format!(
173                "run git ls-files --error-unmatch for {} in {}",
174                rel_path,
175                repo_root.display()
176            )
177        })?;
178
179    if output.status.success() {
180        return Ok(true);
181    }
182
183    let stderr = String::from_utf8_lossy(&output.stderr).to_lowercase();
184    if stderr.contains("pathspec") || stderr.contains("did not match any file") {
185        return Ok(false);
186    }
187
188    Err(GitError::CommandFailed {
189        args: format!("ls-files --error-unmatch -- {}", rel_path),
190        code: output.status.code(),
191        stderr: stderr.trim().to_string(),
192    })
193}
194
195/// Get the configured upstream for the current branch.
196///
197/// Returns the upstream reference (e.g. "origin/main") or an error if not configured.
198pub fn upstream_ref(repo_root: &Path) -> Result<String, GitError> {
199    let output = git_base_command(repo_root)
200        .arg("rev-parse")
201        .arg("--abbrev-ref")
202        .arg("--symbolic-full-name")
203        .arg("@{u}")
204        .output()
205        .with_context(|| {
206            format!(
207                "run git rev-parse --abbrev-ref --symbolic-full-name @{{u}} in {}",
208                repo_root.display()
209            )
210        })?;
211
212    if !output.status.success() {
213        let stderr = String::from_utf8_lossy(&output.stderr);
214        return Err(classify_push_error(&stderr));
215    }
216
217    let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
218    if value.is_empty() {
219        return Err(GitError::NoUpstreamConfigured);
220    }
221    Ok(value)
222}
223
224/// Check if HEAD is ahead of the configured upstream.
225///
226/// Returns true if there are local commits that haven't been pushed.
227pub fn is_ahead_of_upstream(repo_root: &Path) -> Result<bool, GitError> {
228    let upstream = upstream_ref(repo_root)?;
229    let range = format!("{upstream}...HEAD");
230    let output = git_base_command(repo_root)
231        .arg("rev-list")
232        .arg("--left-right")
233        .arg("--count")
234        .arg(range)
235        .output()
236        .with_context(|| {
237            format!(
238                "run git rev-list --left-right --count in {}",
239                repo_root.display()
240            )
241        })?;
242
243    if !output.status.success() {
244        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
245        return Err(GitError::CommandFailed {
246            args: "rev-list --left-right --count".to_string(),
247            code: output.status.code(),
248            stderr: stderr.trim().to_string(),
249        });
250    }
251
252    let counts = String::from_utf8_lossy(&output.stdout);
253    let parts: Vec<&str> = counts.split_whitespace().collect();
254    if parts.len() != 2 {
255        return Err(GitError::UnexpectedRevListOutput(counts.trim().to_string()));
256    }
257
258    let ahead: u32 = parts[1].parse().context("parse ahead count")?;
259    Ok(ahead > 0)
260}
261
262/// Push HEAD to the configured upstream.
263///
264/// Returns an error if push fails due to authentication, missing upstream,
265/// or other git errors.
266pub fn push_upstream(repo_root: &Path) -> Result<(), GitError> {
267    let output = git_base_command(repo_root)
268        .arg("push")
269        .output()
270        .with_context(|| format!("run git push in {}", repo_root.display()))?;
271
272    if output.status.success() {
273        return Ok(());
274    }
275
276    let stderr = String::from_utf8_lossy(&output.stderr);
277    Err(classify_push_error(&stderr))
278}
279
280/// Push HEAD to origin and create upstream tracking.
281///
282/// Intended for new branches that do not have an upstream configured yet.
283pub fn push_upstream_allow_create(repo_root: &Path) -> Result<(), GitError> {
284    let output = git_base_command(repo_root)
285        .arg("push")
286        .arg("-u")
287        .arg("origin")
288        .arg("HEAD")
289        .output()
290        .with_context(|| format!("run git push -u origin HEAD in {}", repo_root.display()))?;
291
292    if output.status.success() {
293        return Ok(());
294    }
295
296    let stderr = String::from_utf8_lossy(&output.stderr);
297    Err(classify_push_error(&stderr))
298}
299
300fn is_non_fast_forward_error(err: &GitError) -> bool {
301    let GitError::PushFailed(detail) = err else {
302        return false;
303    };
304    let lower = detail.to_lowercase();
305    lower.contains("non-fast-forward")
306        || lower.contains("non fast-forward")
307        || lower.contains("fetch first")
308        || lower.contains("rejected")
309        || lower.contains("updates were rejected")
310}
311
312fn reference_exists(repo_root: &Path, reference: &str) -> Result<bool, GitError> {
313    let output = git_base_command(repo_root)
314        .args(["rev-parse", "--verify", "--quiet", reference])
315        .output()
316        .with_context(|| {
317            format!(
318                "run git rev-parse --verify --quiet {} in {}",
319                reference,
320                repo_root.display()
321            )
322        })?;
323    if output.status.success() {
324        return Ok(true);
325    }
326    if output.status.code() == Some(1) {
327        return Ok(false);
328    }
329    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
330    Err(GitError::CommandFailed {
331        args: format!("rev-parse --verify --quiet {}", reference),
332        code: output.status.code(),
333        stderr: stderr.trim().to_string(),
334    })
335}
336
337fn is_ahead_of_ref(repo_root: &Path, reference: &str) -> Result<bool, GitError> {
338    let range = format!("{reference}...HEAD");
339    let output = git_base_command(repo_root)
340        .arg("rev-list")
341        .arg("--left-right")
342        .arg("--count")
343        .arg(range)
344        .output()
345        .with_context(|| {
346            format!(
347                "run git rev-list --left-right --count in {}",
348                repo_root.display()
349            )
350        })?;
351
352    if !output.status.success() {
353        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
354        return Err(GitError::CommandFailed {
355            args: "rev-list --left-right --count".to_string(),
356            code: output.status.code(),
357            stderr: stderr.trim().to_string(),
358        });
359    }
360
361    let counts = String::from_utf8_lossy(&output.stdout);
362    let parts: Vec<&str> = counts.split_whitespace().collect();
363    if parts.len() != 2 {
364        return Err(GitError::UnexpectedRevListOutput(counts.trim().to_string()));
365    }
366
367    let ahead: u32 = parts[1].parse().context("parse ahead count")?;
368    Ok(ahead > 0)
369}
370
371fn set_upstream_to(repo_root: &Path, upstream: &str) -> Result<(), GitError> {
372    git_run(repo_root, &["branch", "--set-upstream-to", upstream])
373        .with_context(|| format!("set upstream to {} in {}", upstream, repo_root.display()))?;
374    Ok(())
375}
376
377/// Fetch a specific branch from origin.
378pub fn fetch_branch(repo_root: &Path, remote: &str, branch: &str) -> Result<(), GitError> {
379    git_run(repo_root, &["fetch", remote, branch])
380        .with_context(|| format!("fetch {} {} in {}", remote, branch, repo_root.display()))?;
381    Ok(())
382}
383
384/// Check if the current branch is behind its upstream.
385///
386/// Returns true if the upstream has commits that are not in the current branch.
387pub fn is_behind_upstream(repo_root: &Path, branch: &str) -> Result<bool, GitError> {
388    // First fetch to ensure we have latest
389    fetch_branch(repo_root, "origin", branch)?;
390
391    let upstream = format!("origin/{}", branch);
392    let range = format!("HEAD...{}", upstream);
393
394    let output = git_base_command(repo_root)
395        .arg("rev-list")
396        .arg("--left-right")
397        .arg("--count")
398        .arg(&range)
399        .output()
400        .with_context(|| {
401            format!(
402                "run git rev-list --left-right --count {} in {}",
403                range,
404                repo_root.display()
405            )
406        })?;
407
408    if !output.status.success() {
409        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
410        return Err(GitError::CommandFailed {
411            args: format!("rev-list --left-right --count {}", range),
412            code: output.status.code(),
413            stderr: stderr.trim().to_string(),
414        });
415    }
416
417    let counts = String::from_utf8_lossy(&output.stdout);
418    let parts: Vec<&str> = counts.split_whitespace().collect();
419    if parts.len() != 2 {
420        return Err(GitError::UnexpectedRevListOutput(counts.trim().to_string()));
421    }
422
423    // Format is "<ahead>\t<behind>"
424    let behind: u32 = parts[0].parse().context("parse behind count")?;
425    Ok(behind > 0)
426}
427
428/// Rebase current branch onto a target reference.
429pub fn rebase_onto(repo_root: &Path, target: &str) -> Result<(), GitError> {
430    // Fetch first to ensure we have the latest
431    git_run(repo_root, &["fetch", "origin", "--prune"])
432        .with_context(|| format!("fetch before rebase in {}", repo_root.display()))?;
433    git_run(repo_root, &["rebase", target])
434        .with_context(|| format!("rebase onto {} in {}", target, repo_root.display()))?;
435    Ok(())
436}
437
438/// Abort an in-progress rebase.
439pub fn abort_rebase(repo_root: &Path) -> Result<(), GitError> {
440    git_run(repo_root, &["rebase", "--abort"])
441        .with_context(|| format!("abort rebase in {}", repo_root.display()))?;
442    Ok(())
443}
444
445/// List files with merge conflicts.
446///
447/// Returns a list of file paths that have unresolved merge conflicts.
448pub fn list_conflict_files(repo_root: &Path) -> Result<Vec<String>, GitError> {
449    let output = git_base_command(repo_root)
450        .args(["diff", "--name-only", "--diff-filter=U"])
451        .output()
452        .with_context(|| {
453            format!(
454                "run git diff --name-only --diff-filter=U in {}",
455                repo_root.display()
456            )
457        })?;
458
459    if !output.status.success() {
460        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
461        return Err(GitError::CommandFailed {
462            args: "diff --name-only --diff-filter=U".to_string(),
463            code: output.status.code(),
464            stderr: stderr.trim().to_string(),
465        });
466    }
467
468    let stdout = String::from_utf8_lossy(&output.stdout);
469    let files: Vec<String> = stdout
470        .lines()
471        .map(|s| s.trim().to_string())
472        .filter(|s| !s.is_empty())
473        .collect();
474
475    Ok(files)
476}
477
478/// Push the current branch to a remote.
479///
480/// This pushes HEAD to the current branch on the specified remote.
481pub fn push_current_branch(repo_root: &Path, remote: &str) -> Result<(), GitError> {
482    let output = git_base_command(repo_root)
483        .arg("push")
484        .arg(remote)
485        .arg("HEAD")
486        .output()
487        .with_context(|| format!("run git push {} HEAD in {}", remote, repo_root.display()))?;
488
489    if output.status.success() {
490        return Ok(());
491    }
492
493    let stderr = String::from_utf8_lossy(&output.stderr);
494    Err(classify_push_error(&stderr))
495}
496
497/// Push HEAD to a specific branch on a remote.
498///
499/// This pushes HEAD to the specified branch on the remote, creating the branch if needed.
500/// Used in direct-push parallel mode to push directly to the base branch.
501pub fn push_head_to_branch(repo_root: &Path, remote: &str, branch: &str) -> Result<(), GitError> {
502    let output = git_base_command(repo_root)
503        .arg("push")
504        .arg(remote)
505        .arg(format!("HEAD:{}", branch))
506        .output()
507        .with_context(|| {
508            format!(
509                "run git push {} HEAD:{} in {}",
510                remote,
511                branch,
512                repo_root.display()
513            )
514        })?;
515
516    if output.status.success() {
517        return Ok(());
518    }
519
520    let stderr = String::from_utf8_lossy(&output.stderr);
521    Err(classify_push_error(&stderr))
522}
523
524/// Push HEAD to upstream, rebasing on non-fast-forward rejections.
525///
526/// If the branch has no upstream yet, this will create one via `git push -u origin HEAD`.
527/// When the push is rejected because the remote has new commits, this will:
528/// - `git fetch origin --prune`
529/// - `git rebase <upstream>`
530/// - retry push with a bounded number of attempts
531pub fn push_upstream_with_rebase(repo_root: &Path) -> Result<(), GitError> {
532    const MAX_PUSH_ATTEMPTS: usize = 4;
533    let branch = current_branch(repo_root).map_err(GitError::Other)?;
534    let fallback_upstream = format!("origin/{}", branch);
535    let ahead = match is_ahead_of_upstream(repo_root) {
536        Ok(ahead) => ahead,
537        Err(GitError::NoUpstream) | Err(GitError::NoUpstreamConfigured) => {
538            if reference_exists(repo_root, &fallback_upstream)? {
539                is_ahead_of_ref(repo_root, &fallback_upstream)?
540            } else {
541                true
542            }
543        }
544        Err(err) => return Err(err),
545    };
546
547    if !ahead {
548        if upstream_ref(repo_root).is_err() && reference_exists(repo_root, &fallback_upstream)? {
549            set_upstream_to(repo_root, &fallback_upstream)?;
550        }
551        return Ok(());
552    }
553
554    let mut last_non_fast_forward: Option<GitError> = None;
555    for _attempt in 0..MAX_PUSH_ATTEMPTS {
556        let push_result = match push_upstream(repo_root) {
557            Ok(()) => return Ok(()),
558            Err(GitError::NoUpstream) | Err(GitError::NoUpstreamConfigured) => {
559                push_upstream_allow_create(repo_root)
560            }
561            Err(err) => Err(err),
562        };
563
564        match push_result {
565            Ok(()) => return Ok(()),
566            Err(err) if is_non_fast_forward_error(&err) => {
567                let upstream = match upstream_ref(repo_root) {
568                    Ok(upstream) => upstream,
569                    Err(_) => fallback_upstream.clone(),
570                };
571                rebase_onto(repo_root, &upstream)?;
572                if !is_ahead_of_ref(repo_root, &upstream)? {
573                    if upstream_ref(repo_root).is_err() {
574                        set_upstream_to(repo_root, &upstream)?;
575                    }
576                    return Ok(());
577                }
578                last_non_fast_forward = Some(err);
579                continue;
580            }
581            Err(err) => return Err(err),
582        }
583    }
584
585    Err(last_non_fast_forward
586        .unwrap_or_else(|| GitError::PushFailed("rebase-aware push exhausted retries".to_string())))
587}
588
589#[cfg(test)]
590mod tests {
591    use super::*;
592    use crate::testsupport::git as git_test;
593    use tempfile::TempDir;
594
595    #[test]
596    fn push_upstream_with_rebase_recovers_from_non_fast_forward() -> anyhow::Result<()> {
597        let remote = TempDir::new()?;
598        git_test::init_bare_repo(remote.path())?;
599
600        let repo_a = TempDir::new()?;
601        git_test::init_repo(repo_a.path())?;
602        git_test::add_remote(repo_a.path(), "origin", remote.path())?;
603
604        std::fs::write(repo_a.path().join("base.txt"), "init\n")?;
605        git_test::commit_all(repo_a.path(), "init")?;
606        git_test::git_run(repo_a.path(), &["push", "-u", "origin", "HEAD"])?;
607
608        let repo_b = TempDir::new()?;
609        git_test::clone_repo(remote.path(), repo_b.path())?;
610        git_test::configure_user(repo_b.path())?;
611        std::fs::write(repo_b.path().join("remote.txt"), "remote\n")?;
612        git_test::commit_all(repo_b.path(), "remote update")?;
613        git_test::git_run(repo_b.path(), &["push"])?;
614
615        std::fs::write(repo_a.path().join("local.txt"), "local\n")?;
616        git_test::commit_all(repo_a.path(), "local update")?;
617
618        push_upstream_with_rebase(repo_a.path())?;
619
620        let counts = git_test::git_output(
621            repo_a.path(),
622            &["rev-list", "--left-right", "--count", "@{u}...HEAD"],
623        )?;
624        let parts: Vec<&str> = counts.split_whitespace().collect();
625        assert_eq!(parts, vec!["0", "0"]);
626
627        Ok(())
628    }
629
630    #[test]
631    fn push_upstream_with_rebase_sets_upstream_when_remote_branch_exists_and_local_is_behind()
632    -> anyhow::Result<()> {
633        let remote = TempDir::new()?;
634        git_test::init_bare_repo(remote.path())?;
635
636        let seed = TempDir::new()?;
637        git_test::init_repo(seed.path())?;
638        git_test::add_remote(seed.path(), "origin", remote.path())?;
639        std::fs::write(seed.path().join("base.txt"), "base\n")?;
640        git_test::commit_all(seed.path(), "init")?;
641        git_test::git_run(seed.path(), &["push", "-u", "origin", "HEAD"])?;
642        git_test::git_run(seed.path(), &["checkout", "-b", "ralph/RQ-0940"])?;
643        std::fs::write(seed.path().join("task.txt"), "remote-only\n")?;
644        git_test::commit_all(seed.path(), "remote task")?;
645        git_test::git_run(seed.path(), &["push", "-u", "origin", "ralph/RQ-0940"])?;
646
647        let local = TempDir::new()?;
648        git_test::clone_repo(remote.path(), local.path())?;
649        git_test::configure_user(local.path())?;
650        git_test::git_run(
651            local.path(),
652            &[
653                "checkout",
654                "--no-track",
655                "-b",
656                "ralph/RQ-0940",
657                "origin/main",
658            ],
659        )?;
660
661        // This used to fail with non-fast-forward when no upstream was set locally.
662        push_upstream_with_rebase(local.path())?;
663
664        let upstream = git_test::git_output(
665            local.path(),
666            &["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
667        )?;
668        assert_eq!(upstream, "origin/ralph/RQ-0940");
669
670        Ok(())
671    }
672}