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::{
21    ManagedCommand, TimeoutClass, execute_checked_command, execute_managed_command,
22};
23
24/// Errors that can occur during git operations.
25#[derive(Error, Debug)]
26pub enum GitError {
27    #[error("repo is dirty; commit/stash your changes before running Ralph.{details}")]
28    DirtyRepo { details: String },
29
30    #[error("git {args} failed (code={code:?}): {stderr}")]
31    CommandFailed {
32        args: String,
33        code: Option<i32>,
34        stderr: String,
35    },
36
37    #[error(
38        "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>."
39    )]
40    NoUpstream,
41
42    #[error(
43        "git push failed: authentication/permission denied. Verify the remote URL, credentials, and that you have push access."
44    )]
45    AuthFailed,
46
47    #[error("git push failed: {0}")]
48    PushFailed(String),
49
50    #[error("commit message is empty")]
51    EmptyCommitMessage,
52
53    #[error("no changes to commit")]
54    NoChangesToCommit,
55
56    #[error("no upstream configured for current branch")]
57    NoUpstreamConfigured,
58
59    #[error("unexpected rev-list output: {0}")]
60    UnexpectedRevListOutput(String),
61
62    #[error("Git LFS filter misconfigured: {details}")]
63    LfsFilterMisconfigured { details: String },
64
65    #[error(transparent)]
66    Other(#[from] anyhow::Error),
67}
68
69/// Classify a push error from stderr into a specific GitError variant.
70pub fn classify_push_error(stderr: &str) -> GitError {
71    let raw = stderr.trim();
72    let lower = raw.to_lowercase();
73
74    if lower.contains("no upstream")
75        || lower.contains("set-upstream")
76        || lower.contains("set the remote as upstream")
77        || (lower.contains("@{u}")
78            && (lower.contains("ambiguous argument")
79                || lower.contains("unknown revision")
80                || lower.contains("unknown revision or path")))
81    {
82        return GitError::NoUpstream;
83    }
84
85    if lower.contains("permission denied")
86        || lower.contains("authentication failed")
87        || lower.contains("access denied")
88        || lower.contains("could not read from remote repository")
89        || lower.contains("repository not found")
90    {
91        return GitError::AuthFailed;
92    }
93
94    let detail = if raw.is_empty() {
95        "unknown git error".to_string()
96    } else {
97        raw.to_string()
98    };
99    GitError::PushFailed(detail)
100}
101
102/// Build a base git command with fsmonitor disabled.
103///
104/// Some environments (notably when fsmonitor is enabled but unhealthy) emit:
105///   error: fsmonitor_ipc__send_query: ... '.git/fsmonitor--daemon.ipc'
106/// This is noisy and can confuse agents/automation. Disabling fsmonitor for
107/// Ralph's git invocations avoids that class of failures.
108pub fn git_base_command(repo_root: &Path) -> Command {
109    let mut cmd = Command::new("git");
110    cmd.arg("-c").arg("core.fsmonitor=false");
111    cmd.arg("-C").arg(repo_root);
112    cmd
113}
114
115/// Run a git command and return an error on failure.
116pub fn git_run(repo_root: &Path, args: &[&str]) -> Result<(), GitError> {
117    let output = git_output(repo_root, args)
118        .with_context(|| format!("run git {} in {}", args.join(" "), repo_root.display()))?;
119
120    if output.status.success() {
121        return Ok(());
122    }
123
124    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
125    Err(GitError::CommandFailed {
126        args: args.join(" "),
127        code: output.status.code(),
128        stderr: stderr.trim().to_string(),
129    })
130}
131
132/// Outcome of a git merge operation.
133/// Retained for merge-oriented callers in prompt/workflow helpers.
134#[allow(dead_code)]
135#[derive(Debug, Clone)]
136pub(crate) enum GitMergeOutcome {
137    /// Merge completed cleanly with no conflicts.
138    Clean,
139    /// Merge has conflicts that need resolution.
140    Conflicts { stderr: String },
141}
142
143/// Run a git merge command and allow exit code 1 (conflicts present) to proceed.
144///
145/// This is specifically for merge operations where conflicts are expected and
146/// will be handled by the caller. Other non-zero exit codes are treated as errors.
147///
148/// Retained for merge-oriented callers in prompt/workflow helpers.
149///
150/// # Returns
151/// - `Ok(GitMergeOutcome::Clean)` if merge succeeded (exit 0)
152/// - `Ok(GitMergeOutcome::Conflicts { stderr })` if merge has conflicts (exit 1)
153/// - `Err(GitError)` for any other failure
154#[allow(dead_code)]
155pub(crate) fn git_merge_allow_conflicts(
156    repo_root: &Path,
157    merge_target: &str,
158) -> Result<GitMergeOutcome, GitError> {
159    let output = git_output(repo_root, &["merge", merge_target])
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
183pub(crate) fn git_output(
184    repo_root: &Path,
185    args: &[&str],
186) -> Result<std::process::Output, GitError> {
187    #[cfg(test)]
188    let _path_guard = crate::testsupport::path::path_lock()
189        .lock()
190        .expect("path lock");
191
192    let mut command = git_base_command(repo_root);
193    command.args(args);
194    execute_managed_command(ManagedCommand::new(
195        command,
196        format!("git {}", args.join(" ")),
197        TimeoutClass::Git,
198    ))
199    .map(|output| output.into_output())
200    .map_err(anyhow::Error::from)
201    .map_err(GitError::from)
202}
203
204pub(crate) fn git_probe_stdout(repo_root: &Path, args: &[&str]) -> Result<String, GitError> {
205    #[cfg(test)]
206    let _path_guard = crate::testsupport::path::path_lock()
207        .lock()
208        .expect("path lock");
209
210    let mut command = git_base_command(repo_root);
211    command.args(args);
212    execute_checked_command(ManagedCommand::new(
213        command,
214        format!("git {} in {}", args.join(" "), repo_root.display()),
215        TimeoutClass::MetadataProbe,
216    ))
217    .map(|output| output.stdout_lossy())
218    .map_err(GitError::from)
219}
220
221pub(crate) fn git_head_commit(repo_root: &Path) -> Result<String, GitError> {
222    git_probe_stdout(repo_root, &["rev-parse", "HEAD"])
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use crate::testsupport::git as git_test;
229    use anyhow::Result;
230    use tempfile::TempDir;
231
232    #[test]
233    fn classify_push_error_maps_ambiguous_upstream_to_no_upstream() {
234        let stderr =
235            "fatal: ambiguous argument '@{u}': unknown revision or path not in the working tree.";
236        let err = classify_push_error(stderr);
237        assert!(matches!(err, GitError::NoUpstream));
238    }
239
240    #[test]
241    fn git_head_commit_returns_current_head() -> Result<()> {
242        let temp = TempDir::new()?;
243        git_test::init_repo(temp.path())?;
244        std::fs::write(temp.path().join("README.md"), "git helper")?;
245        git_test::commit_all(temp.path(), "init")?;
246
247        let expected = git_test::git_output(temp.path(), &["rev-parse", "HEAD"])?;
248        let actual = git_head_commit(temp.path())?;
249
250        assert_eq!(actual, expected);
251        Ok(())
252    }
253}