Skip to main content

ralph/git/
error.rs

1//! Git-related error types and error classification.
2//!
3//! This module defines all error types that can occur during git operations.
4//! It provides structured error variants for common failure modes like dirty
5//! repositories, authentication failures, and missing upstream configuration.
6//!
7//! # Invariants
8//! - All error types implement `Send + Sync` for anyhow compatibility
9//! - Error messages should be actionable and include context where possible
10//!
11//! # What this does NOT handle
12//! - Success cases or happy-path results
13//! - Non-git related errors (use anyhow for those)
14
15use anyhow::{Context, Result};
16use std::path::Path;
17use std::process::Command;
18use thiserror::Error;
19
20/// Errors that can occur during git operations.
21#[derive(Error, Debug)]
22pub enum GitError {
23    #[error("repo is dirty; commit/stash your changes before running Ralph.{details}")]
24    DirtyRepo { details: String },
25
26    #[error("git {args} failed (code={code:?}): {stderr}")]
27    CommandFailed {
28        args: String,
29        code: Option<i32>,
30        stderr: String,
31    },
32
33    #[error(
34        "git push failed: no upstream configured for current branch. Set it with: git push -u origin <branch> OR git branch --set-upstream-to origin/<branch>."
35    )]
36    NoUpstream,
37
38    #[error(
39        "git push failed: authentication/permission denied. Verify the remote URL, credentials, and that you have push access."
40    )]
41    AuthFailed,
42
43    #[error("git push failed: {0}")]
44    PushFailed(String),
45
46    #[error("commit message is empty")]
47    EmptyCommitMessage,
48
49    #[error("no changes to commit")]
50    NoChangesToCommit,
51
52    #[error("no upstream configured for current branch")]
53    NoUpstreamConfigured,
54
55    #[error("unexpected rev-list output: {0}")]
56    UnexpectedRevListOutput(String),
57
58    #[error("Git LFS filter misconfigured: {details}")]
59    LfsFilterMisconfigured { details: String },
60
61    #[error(transparent)]
62    Other(#[from] anyhow::Error),
63}
64
65/// Classify a push error from stderr into a specific GitError variant.
66pub fn classify_push_error(stderr: &str) -> GitError {
67    let raw = stderr.trim();
68    let lower = raw.to_lowercase();
69
70    if lower.contains("no upstream")
71        || lower.contains("set-upstream")
72        || lower.contains("set the remote as upstream")
73        || (lower.contains("@{u}")
74            && (lower.contains("ambiguous argument")
75                || lower.contains("unknown revision")
76                || lower.contains("unknown revision or path")))
77    {
78        return GitError::NoUpstream;
79    }
80
81    if lower.contains("permission denied")
82        || lower.contains("authentication failed")
83        || lower.contains("access denied")
84        || lower.contains("could not read from remote repository")
85        || lower.contains("repository not found")
86    {
87        return GitError::AuthFailed;
88    }
89
90    let detail = if raw.is_empty() {
91        "unknown git error".to_string()
92    } else {
93        raw.to_string()
94    };
95    GitError::PushFailed(detail)
96}
97
98/// Build a base git command with fsmonitor disabled.
99///
100/// Some environments (notably when fsmonitor is enabled but unhealthy) emit:
101///   error: fsmonitor_ipc__send_query: ... '.git/fsmonitor--daemon.ipc'
102/// This is noisy and can confuse agents/automation. Disabling fsmonitor for
103/// Ralph's git invocations avoids that class of failures.
104pub fn git_base_command(repo_root: &Path) -> Command {
105    let mut cmd = Command::new("git");
106    cmd.arg("-c").arg("core.fsmonitor=false");
107    cmd.arg("-C").arg(repo_root);
108    cmd
109}
110
111/// Run a git command and return an error on failure.
112pub fn git_run(repo_root: &Path, args: &[&str]) -> Result<(), GitError> {
113    let output = git_base_command(repo_root)
114        .args(args)
115        .output()
116        .with_context(|| format!("run git {} in {}", args.join(" "), repo_root.display()))?;
117
118    if output.status.success() {
119        return Ok(());
120    }
121
122    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
123    Err(GitError::CommandFailed {
124        args: args.join(" "),
125        code: output.status.code(),
126        stderr: stderr.trim().to_string(),
127    })
128}
129
130/// Outcome of a git merge operation.
131/// Retained for merge-oriented callers in prompt/workflow helpers.
132#[allow(dead_code)]
133#[derive(Debug, Clone)]
134pub(crate) enum GitMergeOutcome {
135    /// Merge completed cleanly with no conflicts.
136    Clean,
137    /// Merge has conflicts that need resolution.
138    Conflicts { stderr: String },
139}
140
141/// Run a git merge command and allow exit code 1 (conflicts present) to proceed.
142///
143/// This is specifically for merge operations where conflicts are expected and
144/// will be handled by the caller. Other non-zero exit codes are treated as errors.
145///
146/// Retained for merge-oriented callers in prompt/workflow helpers.
147///
148/// # Returns
149/// - `Ok(GitMergeOutcome::Clean)` if merge succeeded (exit 0)
150/// - `Ok(GitMergeOutcome::Conflicts { stderr })` if merge has conflicts (exit 1)
151/// - `Err(GitError)` for any other failure
152#[allow(dead_code)]
153pub(crate) fn git_merge_allow_conflicts(
154    repo_root: &Path,
155    merge_target: &str,
156) -> Result<GitMergeOutcome, GitError> {
157    let output = git_base_command(repo_root)
158        .args(["merge", merge_target])
159        .output()
160        .with_context(|| format!("run git merge {} in {}", merge_target, repo_root.display()))?;
161
162    if output.status.success() {
163        return Ok(GitMergeOutcome::Clean);
164    }
165
166    let code = output.status.code();
167    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
168
169    // Exit code 1 typically means conflicts are present
170    if code == Some(1) {
171        return Ok(GitMergeOutcome::Conflicts {
172            stderr: stderr.trim().to_string(),
173        });
174    }
175
176    Err(GitError::CommandFailed {
177        args: format!("merge {}", merge_target),
178        code,
179        stderr: stderr.trim().to_string(),
180    })
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn classify_push_error_maps_ambiguous_upstream_to_no_upstream() {
189        let stderr =
190            "fatal: ambiguous argument '@{u}': unknown revision or path not in the working tree.";
191        let err = classify_push_error(stderr);
192        assert!(matches!(err, GitError::NoUpstream));
193    }
194}