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_output, 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_output(repo_root, &["ls-files", "--error-unmatch", "--", rel_path])
169        .with_context(|| {
170            format!(
171                "run git ls-files --error-unmatch for {} in {}",
172                rel_path,
173                repo_root.display()
174            )
175        })?;
176
177    if output.status.success() {
178        return Ok(true);
179    }
180
181    let stderr = String::from_utf8_lossy(&output.stderr).to_lowercase();
182    if stderr.contains("pathspec") || stderr.contains("did not match any file") {
183        return Ok(false);
184    }
185
186    Err(GitError::CommandFailed {
187        args: format!("ls-files --error-unmatch -- {}", rel_path),
188        code: output.status.code(),
189        stderr: stderr.trim().to_string(),
190    })
191}
192
193/// Get the configured upstream for the current branch.
194///
195/// Returns the upstream reference (e.g. "origin/main") or an error if not configured.
196pub fn upstream_ref(repo_root: &Path) -> Result<String, GitError> {
197    let output = git_output(
198        repo_root,
199        &["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
200    )
201    .with_context(|| {
202        format!(
203            "run git rev-parse --abbrev-ref --symbolic-full-name @{{u}} in {}",
204            repo_root.display()
205        )
206    })?;
207
208    if !output.status.success() {
209        let stderr = String::from_utf8_lossy(&output.stderr);
210        return Err(classify_push_error(&stderr));
211    }
212
213    let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
214    if value.is_empty() {
215        return Err(GitError::NoUpstreamConfigured);
216    }
217    Ok(value)
218}
219
220/// Check if HEAD is ahead of the configured upstream.
221///
222/// Returns true if there are local commits that haven't been pushed.
223pub fn is_ahead_of_upstream(repo_root: &Path) -> Result<bool, GitError> {
224    let upstream = upstream_ref(repo_root)?;
225    let range = format!("{upstream}...HEAD");
226    let output = git_output(repo_root, &["rev-list", "--left-right", "--count", &range])
227        .with_context(|| {
228            format!(
229                "run git rev-list --left-right --count in {}",
230                repo_root.display()
231            )
232        })?;
233
234    if !output.status.success() {
235        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
236        return Err(GitError::CommandFailed {
237            args: "rev-list --left-right --count".to_string(),
238            code: output.status.code(),
239            stderr: stderr.trim().to_string(),
240        });
241    }
242
243    let counts = String::from_utf8_lossy(&output.stdout);
244    let parts: Vec<&str> = counts.split_whitespace().collect();
245    if parts.len() != 2 {
246        return Err(GitError::UnexpectedRevListOutput(counts.trim().to_string()));
247    }
248
249    let ahead: u32 = parts[1].parse().context("parse ahead count")?;
250    Ok(ahead > 0)
251}
252
253/// Push HEAD to the configured upstream.
254///
255/// Returns an error if push fails due to authentication, missing upstream,
256/// or other git errors.
257pub fn push_upstream(repo_root: &Path) -> Result<(), GitError> {
258    let output = git_output(repo_root, &["push"])
259        .with_context(|| format!("run git push in {}", repo_root.display()))?;
260
261    if output.status.success() {
262        return Ok(());
263    }
264
265    let stderr = String::from_utf8_lossy(&output.stderr);
266    Err(classify_push_error(&stderr))
267}
268
269/// Push HEAD to origin and create upstream tracking.
270///
271/// Intended for new branches that do not have an upstream configured yet.
272pub fn push_upstream_allow_create(repo_root: &Path) -> Result<(), GitError> {
273    let output = git_output(repo_root, &["push", "-u", "origin", "HEAD"])
274        .with_context(|| format!("run git push -u origin HEAD in {}", repo_root.display()))?;
275
276    if output.status.success() {
277        return Ok(());
278    }
279
280    let stderr = String::from_utf8_lossy(&output.stderr);
281    Err(classify_push_error(&stderr))
282}
283
284fn is_non_fast_forward_error(err: &GitError) -> bool {
285    let GitError::PushFailed(detail) = err else {
286        return false;
287    };
288    let lower = detail.to_lowercase();
289    lower.contains("non-fast-forward")
290        || lower.contains("non fast-forward")
291        || lower.contains("fetch first")
292        || lower.contains("rejected")
293        || lower.contains("updates were rejected")
294}
295
296fn reference_exists(repo_root: &Path, reference: &str) -> Result<bool, GitError> {
297    let output = git_output(repo_root, &["rev-parse", "--verify", "--quiet", reference])
298        .with_context(|| {
299            format!(
300                "run git rev-parse --verify --quiet {} in {}",
301                reference,
302                repo_root.display()
303            )
304        })?;
305    if output.status.success() {
306        return Ok(true);
307    }
308    if output.status.code() == Some(1) {
309        return Ok(false);
310    }
311    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
312    Err(GitError::CommandFailed {
313        args: format!("rev-parse --verify --quiet {}", reference),
314        code: output.status.code(),
315        stderr: stderr.trim().to_string(),
316    })
317}
318
319fn is_ahead_of_ref(repo_root: &Path, reference: &str) -> Result<bool, GitError> {
320    let range = format!("{reference}...HEAD");
321    let output = git_output(repo_root, &["rev-list", "--left-right", "--count", &range])
322        .with_context(|| {
323            format!(
324                "run git rev-list --left-right --count in {}",
325                repo_root.display()
326            )
327        })?;
328
329    if !output.status.success() {
330        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
331        return Err(GitError::CommandFailed {
332            args: "rev-list --left-right --count".to_string(),
333            code: output.status.code(),
334            stderr: stderr.trim().to_string(),
335        });
336    }
337
338    let counts = String::from_utf8_lossy(&output.stdout);
339    let parts: Vec<&str> = counts.split_whitespace().collect();
340    if parts.len() != 2 {
341        return Err(GitError::UnexpectedRevListOutput(counts.trim().to_string()));
342    }
343
344    let ahead: u32 = parts[1].parse().context("parse ahead count")?;
345    Ok(ahead > 0)
346}
347
348fn set_upstream_to(repo_root: &Path, upstream: &str) -> Result<(), GitError> {
349    git_run(repo_root, &["branch", "--set-upstream-to", upstream])
350        .with_context(|| format!("set upstream to {} in {}", upstream, repo_root.display()))?;
351    Ok(())
352}
353
354/// Fetch a specific branch from origin.
355pub fn fetch_branch(repo_root: &Path, remote: &str, branch: &str) -> Result<(), GitError> {
356    git_run(repo_root, &["fetch", remote, branch])
357        .with_context(|| format!("fetch {} {} in {}", remote, branch, repo_root.display()))?;
358    Ok(())
359}
360
361/// Check if the current branch is behind its upstream.
362///
363/// Returns true if the upstream has commits that are not in the current branch.
364pub fn is_behind_upstream(repo_root: &Path, branch: &str) -> Result<bool, GitError> {
365    // First fetch to ensure we have latest
366    fetch_branch(repo_root, "origin", branch)?;
367
368    let upstream = format!("origin/{}", branch);
369    let range = format!("HEAD...{}", upstream);
370
371    let output = git_output(repo_root, &["rev-list", "--left-right", "--count", &range])
372        .with_context(|| {
373            format!(
374                "run git rev-list --left-right --count {} in {}",
375                range,
376                repo_root.display()
377            )
378        })?;
379
380    if !output.status.success() {
381        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
382        return Err(GitError::CommandFailed {
383            args: format!("rev-list --left-right --count {}", range),
384            code: output.status.code(),
385            stderr: stderr.trim().to_string(),
386        });
387    }
388
389    let counts = String::from_utf8_lossy(&output.stdout);
390    let parts: Vec<&str> = counts.split_whitespace().collect();
391    if parts.len() != 2 {
392        return Err(GitError::UnexpectedRevListOutput(counts.trim().to_string()));
393    }
394
395    // Format is "<ahead>\t<behind>"
396    let behind: u32 = parts[0].parse().context("parse behind count")?;
397    Ok(behind > 0)
398}
399
400/// Rebase current branch onto a target reference.
401pub fn rebase_onto(repo_root: &Path, target: &str) -> Result<(), GitError> {
402    // Fetch first to ensure we have the latest
403    git_run(repo_root, &["fetch", "origin", "--prune"])
404        .with_context(|| format!("fetch before rebase in {}", repo_root.display()))?;
405    git_run(repo_root, &["rebase", target])
406        .with_context(|| format!("rebase onto {} in {}", target, repo_root.display()))?;
407    Ok(())
408}
409
410/// Abort an in-progress rebase.
411pub fn abort_rebase(repo_root: &Path) -> Result<(), GitError> {
412    git_run(repo_root, &["rebase", "--abort"])
413        .with_context(|| format!("abort rebase in {}", repo_root.display()))?;
414    Ok(())
415}
416
417/// List files with merge conflicts.
418///
419/// Returns a list of file paths that have unresolved merge conflicts.
420pub fn list_conflict_files(repo_root: &Path) -> Result<Vec<String>, GitError> {
421    let output =
422        git_output(repo_root, &["diff", "--name-only", "--diff-filter=U"]).with_context(|| {
423            format!(
424                "run git diff --name-only --diff-filter=U in {}",
425                repo_root.display()
426            )
427        })?;
428
429    if !output.status.success() {
430        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
431        return Err(GitError::CommandFailed {
432            args: "diff --name-only --diff-filter=U".to_string(),
433            code: output.status.code(),
434            stderr: stderr.trim().to_string(),
435        });
436    }
437
438    let stdout = String::from_utf8_lossy(&output.stdout);
439    let files: Vec<String> = stdout
440        .lines()
441        .map(|s| s.trim().to_string())
442        .filter(|s| !s.is_empty())
443        .collect();
444
445    Ok(files)
446}
447
448/// Push the current branch to a remote.
449///
450/// This pushes HEAD to the current branch on the specified remote.
451pub fn push_current_branch(repo_root: &Path, remote: &str) -> Result<(), GitError> {
452    let output = git_output(repo_root, &["push", remote, "HEAD"])
453        .with_context(|| format!("run git push {} HEAD in {}", remote, repo_root.display()))?;
454
455    if output.status.success() {
456        return Ok(());
457    }
458
459    let stderr = String::from_utf8_lossy(&output.stderr);
460    Err(classify_push_error(&stderr))
461}
462
463/// Push HEAD to a specific branch on a remote.
464///
465/// This pushes HEAD to the specified branch on the remote, creating the branch if needed.
466/// Used in direct-push parallel mode to push directly to the base branch.
467pub fn push_head_to_branch(repo_root: &Path, remote: &str, branch: &str) -> Result<(), GitError> {
468    let refspec = format!("HEAD:{}", branch);
469    let output = git_output(repo_root, &["push", remote, &refspec]).with_context(|| {
470        format!(
471            "run git push {} HEAD:{} in {}",
472            remote,
473            branch,
474            repo_root.display()
475        )
476    })?;
477
478    if output.status.success() {
479        return Ok(());
480    }
481
482    let stderr = String::from_utf8_lossy(&output.stderr);
483    Err(classify_push_error(&stderr))
484}
485
486/// Push HEAD to upstream, rebasing on non-fast-forward rejections.
487///
488/// If the branch has no upstream yet, this will create one via `git push -u origin HEAD`.
489/// When the push is rejected because the remote has new commits, this will:
490/// - `git fetch origin --prune`
491/// - `git rebase <upstream>`
492/// - retry push with a bounded number of attempts
493pub fn push_upstream_with_rebase(repo_root: &Path) -> Result<(), GitError> {
494    const MAX_PUSH_ATTEMPTS: usize = 4;
495    let branch = current_branch(repo_root).map_err(GitError::Other)?;
496    let fallback_upstream = format!("origin/{}", branch);
497    let ahead = match is_ahead_of_upstream(repo_root) {
498        Ok(ahead) => ahead,
499        Err(GitError::NoUpstream) | Err(GitError::NoUpstreamConfigured) => {
500            if reference_exists(repo_root, &fallback_upstream)? {
501                is_ahead_of_ref(repo_root, &fallback_upstream)?
502            } else {
503                true
504            }
505        }
506        Err(err) => return Err(err),
507    };
508
509    if !ahead {
510        if upstream_ref(repo_root).is_err() && reference_exists(repo_root, &fallback_upstream)? {
511            set_upstream_to(repo_root, &fallback_upstream)?;
512        }
513        return Ok(());
514    }
515
516    let mut last_non_fast_forward: Option<GitError> = None;
517    for _attempt in 0..MAX_PUSH_ATTEMPTS {
518        let push_result = match push_upstream(repo_root) {
519            Ok(()) => return Ok(()),
520            Err(GitError::NoUpstream) | Err(GitError::NoUpstreamConfigured) => {
521                push_upstream_allow_create(repo_root)
522            }
523            Err(err) => Err(err),
524        };
525
526        match push_result {
527            Ok(()) => return Ok(()),
528            Err(err) if is_non_fast_forward_error(&err) => {
529                let upstream = match upstream_ref(repo_root) {
530                    Ok(upstream) => upstream,
531                    Err(_) => fallback_upstream.clone(),
532                };
533                rebase_onto(repo_root, &upstream)?;
534                if !is_ahead_of_ref(repo_root, &upstream)? {
535                    if upstream_ref(repo_root).is_err() {
536                        set_upstream_to(repo_root, &upstream)?;
537                    }
538                    return Ok(());
539                }
540                last_non_fast_forward = Some(err);
541                continue;
542            }
543            Err(err) => return Err(err),
544        }
545    }
546
547    Err(last_non_fast_forward
548        .unwrap_or_else(|| GitError::PushFailed("rebase-aware push exhausted retries".to_string())))
549}
550
551#[cfg(test)]
552mod tests {
553    use super::*;
554    use crate::testsupport::git as git_test;
555    use tempfile::TempDir;
556
557    #[test]
558    fn push_upstream_with_rebase_recovers_from_non_fast_forward() -> anyhow::Result<()> {
559        let remote = TempDir::new()?;
560        git_test::init_bare_repo(remote.path())?;
561
562        let repo_a = TempDir::new()?;
563        git_test::init_repo(repo_a.path())?;
564        git_test::add_remote(repo_a.path(), "origin", remote.path())?;
565
566        std::fs::write(repo_a.path().join("base.txt"), "init\n")?;
567        git_test::commit_all(repo_a.path(), "init")?;
568        git_test::git_run(repo_a.path(), &["push", "-u", "origin", "HEAD"])?;
569
570        let repo_b = TempDir::new()?;
571        git_test::clone_repo(remote.path(), repo_b.path())?;
572        git_test::configure_user(repo_b.path())?;
573        std::fs::write(repo_b.path().join("remote.txt"), "remote\n")?;
574        git_test::commit_all(repo_b.path(), "remote update")?;
575        git_test::git_run(repo_b.path(), &["push"])?;
576
577        std::fs::write(repo_a.path().join("local.txt"), "local\n")?;
578        git_test::commit_all(repo_a.path(), "local update")?;
579
580        push_upstream_with_rebase(repo_a.path())?;
581
582        let counts = git_test::git_output(
583            repo_a.path(),
584            &["rev-list", "--left-right", "--count", "@{u}...HEAD"],
585        )?;
586        let parts: Vec<&str> = counts.split_whitespace().collect();
587        assert_eq!(parts, vec!["0", "0"]);
588
589        Ok(())
590    }
591
592    #[test]
593    fn push_upstream_with_rebase_sets_upstream_when_remote_branch_exists_and_local_is_behind()
594    -> anyhow::Result<()> {
595        let remote = TempDir::new()?;
596        git_test::init_bare_repo(remote.path())?;
597
598        let seed = TempDir::new()?;
599        git_test::init_repo(seed.path())?;
600        git_test::add_remote(seed.path(), "origin", remote.path())?;
601        std::fs::write(seed.path().join("base.txt"), "base\n")?;
602        git_test::commit_all(seed.path(), "init")?;
603        git_test::git_run(seed.path(), &["push", "-u", "origin", "HEAD"])?;
604        git_test::git_run(seed.path(), &["checkout", "-b", "ralph/RQ-0940"])?;
605        std::fs::write(seed.path().join("task.txt"), "remote-only\n")?;
606        git_test::commit_all(seed.path(), "remote task")?;
607        git_test::git_run(seed.path(), &["push", "-u", "origin", "ralph/RQ-0940"])?;
608
609        let local = TempDir::new()?;
610        git_test::clone_repo(remote.path(), local.path())?;
611        git_test::configure_user(local.path())?;
612        git_test::git_run(
613            local.path(),
614            &[
615                "checkout",
616                "--no-track",
617                "-b",
618                "ralph/RQ-0940",
619                "origin/main",
620            ],
621        )?;
622
623        // This used to fail with non-fast-forward when no upstream was set locally.
624        push_upstream_with_rebase(local.path())?;
625
626        let upstream = git_test::git_output(
627            local.path(),
628            &["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
629        )?;
630        assert_eq!(upstream, "origin/ralph/RQ-0940");
631
632        Ok(())
633    }
634}