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
20use crate::runutil::{ManagedCommand, TimeoutClass, execute_managed_command};
21
22/// Errors that can occur during git operations.
23#[derive(Error, Debug)]
24pub enum GitError {
25    #[error("repo is dirty; commit/stash your changes before running Ralph.{details}")]
26    DirtyRepo { details: String },
27
28    #[error("git {args} failed (code={code:?}): {stderr}")]
29    CommandFailed {
30        args: String,
31        code: Option<i32>,
32        stderr: String,
33    },
34
35    #[error(
36        "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>."
37    )]
38    NoUpstream,
39
40    #[error(
41        "git push failed: authentication/permission denied. Verify the remote URL, credentials, and that you have push access."
42    )]
43    AuthFailed,
44
45    #[error("git push failed: {0}")]
46    PushFailed(String),
47
48    #[error("commit message is empty")]
49    EmptyCommitMessage,
50
51    #[error("no changes to commit")]
52    NoChangesToCommit,
53
54    #[error("no upstream configured for current branch")]
55    NoUpstreamConfigured,
56
57    #[error("unexpected rev-list output: {0}")]
58    UnexpectedRevListOutput(String),
59
60    #[error("Git LFS filter misconfigured: {details}")]
61    LfsFilterMisconfigured { details: String },
62
63    #[error(transparent)]
64    Other(#[from] anyhow::Error),
65}
66
67/// Classify a push error from stderr into a specific GitError variant.
68pub fn classify_push_error(stderr: &str) -> GitError {
69    let raw = stderr.trim();
70    let lower = raw.to_lowercase();
71
72    if lower.contains("no upstream")
73        || lower.contains("set-upstream")
74        || lower.contains("set the remote as upstream")
75        || (lower.contains("@{u}")
76            && (lower.contains("ambiguous argument")
77                || lower.contains("unknown revision")
78                || lower.contains("unknown revision or path")))
79    {
80        return GitError::NoUpstream;
81    }
82
83    if lower.contains("permission denied")
84        || lower.contains("authentication failed")
85        || lower.contains("access denied")
86        || lower.contains("could not read from remote repository")
87        || lower.contains("repository not found")
88    {
89        return GitError::AuthFailed;
90    }
91
92    let detail = if raw.is_empty() {
93        "unknown git error".to_string()
94    } else {
95        raw.to_string()
96    };
97    GitError::PushFailed(detail)
98}
99
100/// Build a base git command with fsmonitor disabled.
101///
102/// Some environments (notably when fsmonitor is enabled but unhealthy) emit:
103///   error: fsmonitor_ipc__send_query: ... '.git/fsmonitor--daemon.ipc'
104/// This is noisy and can confuse agents/automation. Disabling fsmonitor for
105/// Ralph's git invocations avoids that class of failures.
106pub fn git_base_command(repo_root: &Path) -> Command {
107    let mut cmd = Command::new("git");
108    cmd.arg("-c").arg("core.fsmonitor=false");
109    cmd.arg("-C").arg(repo_root);
110    cmd
111}
112
113/// Run a git command and return an error on failure.
114pub fn git_run(repo_root: &Path, args: &[&str]) -> Result<(), GitError> {
115    let output = git_output(repo_root, args)
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_output(repo_root, &["merge", merge_target])
158        .with_context(|| format!("run git merge {} in {}", merge_target, repo_root.display()))?;
159
160    if output.status.success() {
161        return Ok(GitMergeOutcome::Clean);
162    }
163
164    let code = output.status.code();
165    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
166
167    // Exit code 1 typically means conflicts are present
168    if code == Some(1) {
169        return Ok(GitMergeOutcome::Conflicts {
170            stderr: stderr.trim().to_string(),
171        });
172    }
173
174    Err(GitError::CommandFailed {
175        args: format!("merge {}", merge_target),
176        code,
177        stderr: stderr.trim().to_string(),
178    })
179}
180
181pub(crate) fn git_output(
182    repo_root: &Path,
183    args: &[&str],
184) -> Result<std::process::Output, GitError> {
185    let mut command = git_base_command(repo_root);
186    command.args(args);
187    execute_managed_command(ManagedCommand::new(
188        command,
189        format!("git {}", args.join(" ")),
190        TimeoutClass::Git,
191    ))
192    .map(|output| output.into_output())
193    .map_err(anyhow::Error::from)
194    .map_err(GitError::from)
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn classify_push_error_maps_ambiguous_upstream_to_no_upstream() {
203        let stderr =
204            "fatal: ambiguous argument '@{u}': unknown revision or path not in the working tree.";
205        let err = classify_push_error(stderr);
206        assert!(matches!(err, GitError::NoUpstream));
207    }
208}