Skip to content

Commit 489fd0f

Browse files
max-sixtyclaude
andauthored
refactor(errors): route git-plumbing failure bails through CommandError (#3474)
Follow-up to #3459. Eight git-plumbing sites turned a captured non-zero exit into `bail!("…{stderr}")`, bypassing `CommandError::from_failed_output` — the one constructor that normalizes `\r` and gives renderers the typed command/output split. This converts them (`resolve_git_common_dir`, the bulk `config --list` read, `config --unset`, `config --get-regexp`, `merge-tree`, `merge-base`, the untracked-diff numstat, and `llm.rs::run_git_capture`), so failed-command display routes through one constructor and the `main.rs` multiline-error tripwire has nothing left to catch on these paths. `from_failed_output` now takes `&[impl AsRef<str>]` so `Vec<String>` arg lists work. One structural fix rode along: `generate_commit_message` now wraps only the `execute_llm_command` failure in `GitError::LlmCommandFailed`, mirroring `generate_squash_message` — a git failure while building the prompt is not an LLM-command failure, and flattening it into `LlmCommandFailed.error` dropped the typed stderr. Rendering: context-wrapped failures render byte-identically (the outside-repo snapshots pass untouched); bare ones gain the standard `git … failed (exit N)` header + gutter. The only snapshot changes are two `wt list` task-failure lines where merge-base errors drop the hand-rolled `git merge-base failed for <sha> <sha>:` prefix and now match their sibling lines. Every converted error path has a direct test — five new ones using empirically-probed failure modes (invalid regexp pattern → exit 6, invalid key → exit 1, corrupt config → 128 for the lazily-populated bulk read and merge-tree, unborn HEAD for the numstat), plus the updated `run_git_capture` unit test; `resolve_git_common_dir` and `merge_base` were already covered by existing snapshots. Two same-pattern sites remain in the commands layer (`step/shared.rs` ls-files, `step/commit.rs` stage-to-temp-index) — deliberately left for a follow-up along with the non-git CLIs (gh/glab, claude/codex). > _This was written by Claude Code on behalf of max_ --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent cd1cbe1 commit 489fd0f

9 files changed

Lines changed: 165 additions & 83 deletions

src/git/error.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,14 +219,14 @@ impl CommandError {
219219
/// Build from the captured `Output` of a non-zero exit.
220220
pub fn from_failed_output(
221221
program: impl Into<String>,
222-
args: &[&str],
222+
args: &[impl AsRef<str>],
223223
output: &std::process::Output,
224224
) -> Self {
225225
let stderr = String::from_utf8_lossy(&output.stderr).replace('\r', "\n");
226226
let stdout = String::from_utf8_lossy(&output.stdout).replace('\r', "\n");
227227
Self {
228228
program: program.into(),
229-
args: args.iter().map(|&s| s.to_string()).collect(),
229+
args: args.iter().map(|s| s.as_ref().to_string()).collect(),
230230
stderr,
231231
stdout,
232232
exit_code: output.status.code(),

src/git/repository/config.rs

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ use color_print::cformat;
77

88
use crate::config::ProjectConfig;
99

10+
use crate::git::CommandError;
11+
1012
use super::{DefaultBranchName, GitError, Repository};
1113

1214
impl Repository {
@@ -67,15 +69,15 @@ impl Repository {
6769
/// Removes the canonical form (matching what git emits) to stay in
6870
/// sync with `set_config_value` and `config_last`.
6971
pub(super) fn unset_config_value(&self, key: &str) -> anyhow::Result<bool> {
70-
let output = self.run_command_output(&["config", "--unset", key])?;
72+
let args = ["config", "--unset", key];
73+
let output = self.run_command_output(&args)?;
7174
let existed = if output.status.success() {
7275
true
7376
} else if output.status.code() == Some(5) {
7477
// --unset exit code 5 = key didn't exist
7578
false
7679
} else {
77-
let stderr = String::from_utf8_lossy(&output.stderr);
78-
anyhow::bail!("git config --unset {}: {}", key, stderr.trim());
80+
return Err(CommandError::from_failed_output("git", &args, &output).into());
7981
};
8082
if let Some(lock) = self.cache.all_config.get() {
8183
// `shift_remove` preserves remaining order (swap_remove would
@@ -94,15 +96,15 @@ impl Repository {
9496
/// surfaced as `Err`). Use this instead of `run_command` + `.unwrap_or_default()`,
9597
/// which conflates the two.
9698
pub fn get_config_regexp(&self, pattern: &str) -> anyhow::Result<String> {
97-
let output = self.run_command_output(&["config", "--get-regexp", pattern])?;
99+
let args = ["config", "--get-regexp", pattern];
100+
let output = self.run_command_output(&args)?;
98101
if output.status.success() {
99102
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
100103
} else if output.status.code() == Some(1) {
101104
// Exit 1 = no keys matched the pattern
102105
Ok(String::new())
103106
} else {
104-
let stderr = String::from_utf8_lossy(&output.stderr);
105-
anyhow::bail!("git config --get-regexp {}: {}", pattern, stderr.trim());
107+
Err(CommandError::from_failed_output("git", &args, &output).into())
106108
}
107109
}
108110

@@ -871,6 +873,44 @@ mod tests {
871873
assert_eq!(output, "");
872874
}
873875

876+
#[test]
877+
fn test_get_config_regexp_failure_is_command_error() {
878+
// A real failure (invalid pattern, exit 6) must surface as a typed
879+
// `CommandError` — unlike exit 1, which means "no keys matched".
880+
let test = TestRepo::with_initial_commit();
881+
let repo = Repository::at(test.root_path()).unwrap();
882+
883+
let err = repo.get_config_regexp("(").unwrap_err();
884+
let cmd_err = CommandError::find_in(&err).expect("error should carry a CommandError");
885+
assert_eq!(cmd_err.command_string(), "git config --get-regexp (");
886+
}
887+
888+
#[test]
889+
fn test_unset_config_failure_is_command_error() {
890+
// A real failure (invalid key, exit 1) must surface as a typed
891+
// `CommandError` — unlike exit 5, which means "key didn't exist".
892+
let test = TestRepo::with_initial_commit();
893+
let repo = Repository::at(test.root_path()).unwrap();
894+
895+
let err = repo.unset_config("inva lid.key").unwrap_err();
896+
let cmd_err = CommandError::find_in(&err).expect("error should carry a CommandError");
897+
assert_eq!(cmd_err.command_string(), "git config --unset inva lid.key");
898+
}
899+
900+
#[test]
901+
fn test_config_read_failure_is_command_error() {
902+
// Corrupting the config after the repository is open (the bulk map
903+
// populates lazily) makes `git config --list -z` fail — the failure
904+
// must surface as a typed `CommandError`.
905+
let test = TestRepo::with_initial_commit();
906+
let repo = Repository::at(test.root_path()).unwrap();
907+
std::fs::write(test.root_path().join(".git/config"), "[bad\n").unwrap();
908+
909+
let err = repo.config_value("user.name").unwrap_err();
910+
let cmd_err = CommandError::find_in(&err).expect("error should carry a CommandError");
911+
assert_eq!(cmd_err.command_string(), "git config --list -z");
912+
}
913+
874914
#[test]
875915
fn test_get_config_regexp_returns_matches() {
876916
let test = TestRepo::with_initial_commit();

src/git/repository/diff.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -270,15 +270,18 @@ impl Repository {
270270
}
271271

272272
// Exit codes: 0 = found, 1 = no common ancestor, 128+ = invalid ref
273-
let output = self.run_command_output(&["merge-base", sha1, sha2])?;
273+
let args = ["merge-base", sha1, sha2];
274+
let output = self.run_command_output(&args)?;
274275

275276
let result = if output.status.success() {
276277
Some(String::from_utf8_lossy(&output.stdout).trim().to_owned())
277278
} else if output.status.code() == Some(1) {
278279
None
279280
} else {
280-
let stderr = String::from_utf8_lossy(&output.stderr);
281-
bail!("git merge-base failed for {sha1} {sha2}: {}", stderr.trim());
281+
return Err(crate::git::CommandError::from_failed_output(
282+
"git", &args, &output,
283+
)
284+
.into());
282285
};
283286

284287
super::sha_cache::put_merge_base(self, sha1, sha2, &result);

src/git/repository/integration.rs

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -291,15 +291,17 @@ impl Repository {
291291
/// than success is an error; exit 0 → [`MergeTreeOutcome::Clean`] with the
292292
/// resulting tree SHA (first line of stdout).
293293
fn compute_merge_tree_outcome(&self, a: &str, b: &str) -> anyhow::Result<MergeTreeOutcome> {
294-
// Exit codes: 0 = clean merge, 1 = conflicts, 128+ = error (invalid ref, corrupt repo)
295-
let output = self.run_command_output(&["merge-tree", "--write-tree", a, b])?;
294+
// Exit codes: 0 = clean merge, 1 = conflicts (git also uses 1 for
295+
// unresolvable args — callers pass pre-resolved commit SHAs, see
296+
// `run_merge_tree`), anything else = error (corrupt repo, bad usage)
297+
let args = ["merge-tree", "--write-tree", a, b];
298+
let output = self.run_command_output(&args)?;
296299

297300
if output.status.code() == Some(1) {
298301
return Ok(MergeTreeOutcome::Conflict);
299302
}
300303
if !output.status.success() {
301-
let stderr = String::from_utf8_lossy(&output.stderr);
302-
anyhow::bail!("git merge-tree failed for {a} {b}: {}", stderr.trim());
304+
return Err(crate::git::CommandError::from_failed_output("git", &args, &output).into());
303305
}
304306

305307
// Clean merge — first line of stdout is the resulting tree SHA.
@@ -1129,6 +1131,30 @@ mod patch_id_tests {
11291131
}
11301132
}
11311133

1134+
#[cfg(test)]
1135+
mod merge_tree_error_tests {
1136+
use super::*;
1137+
use crate::testing::TestRepo;
1138+
1139+
/// A hard `git merge-tree` failure (here: a fatal config error, exit
1140+
/// 128) must surface as a typed `CommandError`, distinct from exit 1,
1141+
/// which means "merged with conflicts".
1142+
#[test]
1143+
fn hard_failure_is_command_error() {
1144+
let test = TestRepo::with_initial_commit();
1145+
let repo = Repository::at(test.root_path()).unwrap();
1146+
std::fs::write(test.root_path().join(".git/config"), "[bad\n").unwrap();
1147+
1148+
let err = repo.compute_merge_tree_outcome("HEAD", "HEAD").unwrap_err();
1149+
let cmd_err =
1150+
crate::git::CommandError::find_in(&err).expect("error should carry a CommandError");
1151+
assert_eq!(
1152+
cmd_err.command_string(),
1153+
"git merge-tree --write-tree HEAD HEAD"
1154+
);
1155+
}
1156+
}
1157+
11321158
#[cfg(test)]
11331159
mod merge_tree_cache_tests {
11341160
use super::*;

src/git/repository/mod.rs

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ use color_print::cformat;
118118
use dashmap::DashMap;
119119
use once_cell::sync::OnceCell;
120120

121-
use anyhow::{Context, bail};
121+
use anyhow::Context;
122122
use dunce::canonicalize;
123123

124124
use crate::config::{LoadError, ProjectConfig, ResolvedConfig, UserConfig};
@@ -891,16 +891,18 @@ impl Repository {
891891
// captures the surrounding canonicalize + cache-insert work too.
892892
let _span = crate::trace::Span::new("resolve_git_common_dir");
893893

894+
let args = ["rev-parse", "--git-common-dir"];
894895
let output = Cmd::new("git")
895-
.args(["rev-parse", "--git-common-dir"])
896+
.args(args)
896897
.current_dir(discovery_path)
897898
.context(path_to_logging_context(discovery_path))
898899
.run()
899900
.context("Failed to execute: git rev-parse --git-common-dir")?;
900901

901902
if !output.status.success() {
902-
let stderr = String::from_utf8_lossy(&output.stderr);
903-
bail!("{}", stderr.trim());
903+
return Err(
904+
super::error::CommandError::from_failed_output("git", &args, &output).into(),
905+
);
904906
}
905907

906908
let stdout = String::from_utf8_lossy(&output.stdout);
@@ -1189,15 +1191,17 @@ impl Repository {
11891191
&self,
11901192
) -> anyhow::Result<&std::sync::RwLock<indexmap::IndexMap<String, Vec<String>>>> {
11911193
self.cache.all_config.get_or_try_init(|| {
1194+
let args = ["config", "--list", "-z"];
11921195
let output = Cmd::new("git")
1193-
.args(["config", "--list", "-z"])
1196+
.args(args)
11941197
.current_dir(&self.git_common_dir)
11951198
.context(path_to_logging_context(&self.git_common_dir))
11961199
.run()
11971200
.context("failed to read git config")?;
11981201
if !output.status.success() {
1199-
let stderr = String::from_utf8_lossy(&output.stderr);
1200-
bail!("git config --list failed: {}", stderr.trim());
1202+
return Err(
1203+
super::error::CommandError::from_failed_output("git", &args, &output).into(),
1204+
);
12011205
}
12021206
Ok(std::sync::RwLock::new(parse_config_list_z(&output.stdout)))
12031207
})

src/git/repository/working_tree.rs

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -518,14 +518,12 @@ impl<'a> WorkingTree<'a> {
518518
"--".to_string(),
519519
];
520520
args.extend(paths);
521-
let command = args.join(" ");
522521
let output = idx
523-
.git(args)
522+
.git(&args)
524523
.run()
525524
.context("Failed to compute untracked diff stats")?;
526525
if !output.status.success() {
527-
let stderr = String::from_utf8_lossy(&output.stderr);
528-
anyhow::bail!("git {} failed: {}", command, stderr.trim());
526+
return Err(CommandError::from_failed_output("git", &args, &output).into());
529527
}
530528

531529
let mut stats = LineDiff::default();
@@ -932,6 +930,25 @@ mod tests {
932930
);
933931
}
934932

933+
#[test]
934+
fn untracked_diff_stats_unborn_head_is_command_error() {
935+
// With an unborn HEAD the untracked files stage fine into the temp
936+
// index, but `git diff --cached --numstat HEAD` cannot resolve HEAD —
937+
// the failure must surface as a typed `CommandError`.
938+
let test = TestRepo::new();
939+
std::fs::write(test.root_path().join("new.txt"), "hello\n").unwrap();
940+
let repo = Repository::at(test.root_path()).unwrap();
941+
942+
let err = repo.current_worktree().untracked_diff_stats().unwrap_err();
943+
let cmd_err =
944+
crate::git::CommandError::find_in(&err).expect("error should carry a CommandError");
945+
assert!(
946+
cmd_err
947+
.command_string()
948+
.starts_with("git diff --cached --numstat HEAD")
949+
);
950+
}
951+
935952
#[test]
936953
fn temp_index_tolerates_missing_real_index() {
937954
// A worktree whose `<gitdir>/index` file is absent must not error

0 commit comments

Comments
 (0)