1use std::{collections::HashMap, process::Command};
2
3pub use self::git_push as push;
4use crate::{
5 config::CommitConfig,
6 error::{CommitGenError, Result},
7 style,
8 types::{CommitMetadata, Mode},
9};
10
11pub fn get_git_diff(
13 mode: &Mode,
14 target: Option<&str>,
15 dir: &str,
16 config: &CommitConfig,
17) -> Result<String> {
18 let output = match mode {
19 Mode::Staged => Command::new("git")
20 .args(["diff", "--cached"])
21 .current_dir(dir)
22 .output()
23 .map_err(|e| CommitGenError::GitError(format!("Failed to run git diff --cached: {e}")))?,
24 Mode::Commit => {
25 let target = target.ok_or_else(|| {
26 CommitGenError::ValidationError("--target required for commit mode".to_string())
27 })?;
28 let mut cmd = Command::new("git");
29 cmd.arg("show");
30 if config.exclude_old_message {
31 cmd.arg("--format=");
32 }
33 cmd.arg(target)
34 .current_dir(dir)
35 .output()
36 .map_err(|e| CommitGenError::GitError(format!("Failed to run git show: {e}")))?
37 },
38 Mode::Unstaged => {
39 let tracked_output = Command::new("git")
41 .args(["diff"])
42 .current_dir(dir)
43 .output()
44 .map_err(|e| CommitGenError::GitError(format!("Failed to run git diff: {e}")))?;
45
46 if !tracked_output.status.success() {
47 let stderr = String::from_utf8_lossy(&tracked_output.stderr);
48 return Err(CommitGenError::GitError(format!("git diff failed: {stderr}")));
49 }
50
51 let tracked_diff = String::from_utf8_lossy(&tracked_output.stdout).to_string();
52
53 let untracked_output = Command::new("git")
55 .args(["ls-files", "--others", "--exclude-standard"])
56 .current_dir(dir)
57 .output()
58 .map_err(|e| {
59 CommitGenError::GitError(format!("Failed to list untracked files: {e}"))
60 })?;
61
62 if !untracked_output.status.success() {
63 let stderr = String::from_utf8_lossy(&untracked_output.stderr);
64 return Err(CommitGenError::GitError(format!("git ls-files failed: {stderr}")));
65 }
66
67 let untracked_list = String::from_utf8_lossy(&untracked_output.stdout);
68 let untracked_files: Vec<&str> =
69 untracked_list.lines().filter(|s| !s.is_empty()).collect();
70
71 if untracked_files.is_empty() {
72 return Ok(tracked_diff);
73 }
74
75 let mut combined_diff = tracked_diff;
77 for file in untracked_files {
78 let file_diff_output = Command::new("git")
79 .args(["diff", "--no-index", "/dev/null", file])
80 .current_dir(dir)
81 .output()
82 .map_err(|e| {
83 CommitGenError::GitError(format!("Failed to diff untracked file {file}: {e}"))
84 })?;
85
86 if file_diff_output.status.success() || file_diff_output.status.code() == Some(1) {
88 let file_diff = String::from_utf8_lossy(&file_diff_output.stdout);
89 let lines: Vec<&str> = file_diff.lines().collect();
91 if lines.len() >= 2 {
92 use std::fmt::Write;
93 if !combined_diff.is_empty() {
94 combined_diff.push('\n');
95 }
96 writeln!(combined_diff, "diff --git a/{file} b/{file}").unwrap();
97 combined_diff.push_str("new file mode 100644\n");
98 combined_diff.push_str("index 0000000..0000000\n");
99 combined_diff.push_str("--- /dev/null\n");
100 writeln!(combined_diff, "+++ b/{file}").unwrap();
101 for line in lines.iter().skip(2) {
103 combined_diff.push_str(line);
104 combined_diff.push('\n');
105 }
106 }
107 }
108 }
109
110 return Ok(combined_diff);
111 },
112 Mode::Compose => unreachable!("compose mode handled separately"),
113 };
114
115 if !output.status.success() {
116 let stderr = String::from_utf8_lossy(&output.stderr);
117 return Err(CommitGenError::GitError(format!("Git command failed: {stderr}")));
118 }
119
120 let diff = String::from_utf8_lossy(&output.stdout).to_string();
121
122 if diff.trim().is_empty() {
123 let mode_str = match mode {
124 Mode::Staged => "staged",
125 Mode::Commit => "commit",
126 Mode::Unstaged => "unstaged",
127 Mode::Compose => "compose",
128 };
129 return Err(CommitGenError::NoChanges { mode: mode_str.to_string() });
130 }
131
132 Ok(diff)
133}
134
135pub fn get_git_stat(
137 mode: &Mode,
138 target: Option<&str>,
139 dir: &str,
140 config: &CommitConfig,
141) -> Result<String> {
142 let output = match mode {
143 Mode::Staged => Command::new("git")
144 .args(["diff", "--cached", "--stat"])
145 .current_dir(dir)
146 .output()
147 .map_err(|e| {
148 CommitGenError::GitError(format!("Failed to run git diff --cached --stat: {e}"))
149 })?,
150 Mode::Commit => {
151 let target = target.ok_or_else(|| {
152 CommitGenError::ValidationError("--target required for commit mode".to_string())
153 })?;
154 let mut cmd = Command::new("git");
155 cmd.arg("show");
156 if config.exclude_old_message {
157 cmd.arg("--format=");
158 }
159 cmd.arg("--stat")
160 .arg(target)
161 .current_dir(dir)
162 .output()
163 .map_err(|e| CommitGenError::GitError(format!("Failed to run git show --stat: {e}")))?
164 },
165 Mode::Unstaged => {
166 let tracked_output = Command::new("git")
168 .args(["diff", "--stat"])
169 .current_dir(dir)
170 .output()
171 .map_err(|e| CommitGenError::GitError(format!("Failed to run git diff --stat: {e}")))?;
172
173 if !tracked_output.status.success() {
174 let stderr = String::from_utf8_lossy(&tracked_output.stderr);
175 return Err(CommitGenError::GitError(format!("git diff --stat failed: {stderr}")));
176 }
177
178 let mut stat = String::from_utf8_lossy(&tracked_output.stdout).to_string();
179
180 let untracked_output = Command::new("git")
182 .args(["ls-files", "--others", "--exclude-standard"])
183 .current_dir(dir)
184 .output()
185 .map_err(|e| {
186 CommitGenError::GitError(format!("Failed to list untracked files: {e}"))
187 })?;
188
189 if !untracked_output.status.success() {
190 let stderr = String::from_utf8_lossy(&untracked_output.stderr);
191 return Err(CommitGenError::GitError(format!("git ls-files failed: {stderr}")));
192 }
193
194 let untracked_list = String::from_utf8_lossy(&untracked_output.stdout);
195 let untracked_files: Vec<&str> =
196 untracked_list.lines().filter(|s| !s.is_empty()).collect();
197
198 if !untracked_files.is_empty() {
199 use std::fmt::Write;
200 for file in untracked_files {
201 use std::fs;
202 if let Ok(metadata) = fs::metadata(format!("{dir}/{file}")) {
203 let lines = if metadata.is_file() {
204 fs::read_to_string(format!("{dir}/{file}"))
205 .map(|content| content.lines().count())
206 .unwrap_or(0)
207 } else {
208 0
209 };
210 if !stat.is_empty() && !stat.ends_with('\n') {
211 stat.push('\n');
212 }
213 writeln!(stat, " {file} | {lines} {}", "+".repeat(lines.min(50))).unwrap();
214 }
215 }
216 }
217
218 return Ok(stat);
219 },
220 Mode::Compose => unreachable!("compose mode handled separately"),
221 };
222
223 if !output.status.success() {
224 let stderr = String::from_utf8_lossy(&output.stderr);
225 return Err(CommitGenError::GitError(format!("Git stat command failed: {stderr}")));
226 }
227
228 Ok(String::from_utf8_lossy(&output.stdout).to_string())
229}
230
231pub fn git_commit(
233 message: &str,
234 dry_run: bool,
235 dir: &str,
236 sign: bool,
237 skip_hooks: bool,
238) -> Result<()> {
239 if dry_run {
240 let sign_flag = if sign { " -S" } else { "" };
241 let hooks_flag = if skip_hooks { " --no-verify" } else { "" };
242 let command =
243 format!("git commit{sign_flag}{hooks_flag} -m \"{}\"", message.replace('\n', "\\n"));
244 println!("\n{}", style::boxed_message("DRY RUN", &command, 60));
245 return Ok(());
246 }
247
248 let mut args = vec!["commit"];
249 if sign {
250 args.push("-S");
251 }
252 if skip_hooks {
253 args.push("--no-verify");
254 }
255 args.push("-m");
256 args.push(message);
257
258 let output = Command::new("git")
259 .args(&args)
260 .current_dir(dir)
261 .output()
262 .map_err(|e| CommitGenError::GitError(format!("Failed to run git commit: {e}")))?;
263
264 if !output.status.success() {
265 let stderr = String::from_utf8_lossy(&output.stderr);
266 let stdout = String::from_utf8_lossy(&output.stdout);
267 return Err(CommitGenError::GitError(format!(
268 "Git commit failed:\nstderr: {stderr}\nstdout: {stdout}"
269 )));
270 }
271
272 let stdout = String::from_utf8_lossy(&output.stdout);
273 println!("\n{stdout}");
274 println!(
275 "{} {}",
276 style::success(style::icons::SUCCESS),
277 style::success("Successfully committed!")
278 );
279
280 Ok(())
281}
282
283pub fn git_push(dir: &str) -> Result<()> {
285 println!("\n{}", style::info("Pushing changes..."));
286
287 let output = Command::new("git")
288 .args(["push"])
289 .current_dir(dir)
290 .output()
291 .map_err(|e| CommitGenError::GitError(format!("Failed to run git push: {e}")))?;
292
293 if !output.status.success() {
294 let stderr = String::from_utf8_lossy(&output.stderr);
295 let stdout = String::from_utf8_lossy(&output.stdout);
296 return Err(CommitGenError::GitError(format!(
297 "Git push failed:\nstderr: {stderr}\nstdout: {stdout}"
298 )));
299 }
300
301 let stdout = String::from_utf8_lossy(&output.stdout);
302 let stderr = String::from_utf8_lossy(&output.stderr);
303 if !stdout.is_empty() {
304 println!("{stdout}");
305 }
306 if !stderr.is_empty() {
307 println!("{stderr}");
308 }
309 println!("{} {}", style::success(style::icons::SUCCESS), style::success("Successfully pushed!"));
310
311 Ok(())
312}
313
314pub fn get_head_hash(dir: &str) -> Result<String> {
316 let output = Command::new("git")
317 .args(["rev-parse", "HEAD"])
318 .current_dir(dir)
319 .output()
320 .map_err(|e| CommitGenError::GitError(format!("Failed to get HEAD hash: {e}")))?;
321
322 if !output.status.success() {
323 let stderr = String::from_utf8_lossy(&output.stderr);
324 return Err(CommitGenError::GitError(format!("git rev-parse HEAD failed: {stderr}")));
325 }
326
327 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
328}
329
330pub fn get_commit_list(start_ref: Option<&str>, dir: &str) -> Result<Vec<String>> {
334 let mut args = vec!["rev-list", "--reverse"];
335 let range;
336 if let Some(start) = start_ref {
337 range = format!("{start}..HEAD");
338 args.push(&range);
339 } else {
340 args.push("HEAD");
341 }
342
343 let output = Command::new("git")
344 .args(&args)
345 .current_dir(dir)
346 .output()
347 .map_err(|e| CommitGenError::GitError(format!("Failed to run git rev-list: {e}")))?;
348
349 if !output.status.success() {
350 let stderr = String::from_utf8_lossy(&output.stderr);
351 return Err(CommitGenError::GitError(format!("git rev-list failed: {stderr}")));
352 }
353
354 let stdout = String::from_utf8_lossy(&output.stdout);
355 Ok(stdout.lines().map(|s| s.to_string()).collect())
356}
357
358pub fn get_commit_metadata(hash: &str, dir: &str) -> Result<CommitMetadata> {
360 let format_str = "%an%x00%ae%x00%aI%x00%cn%x00%ce%x00%cI%x00%B";
363
364 let info_output = Command::new("git")
365 .args(["show", "-s", &format!("--format={format_str}"), hash])
366 .current_dir(dir)
367 .output()
368 .map_err(|e| CommitGenError::GitError(format!("Failed to run git show: {e}")))?;
369
370 if !info_output.status.success() {
371 let stderr = String::from_utf8_lossy(&info_output.stderr);
372 return Err(CommitGenError::GitError(format!("git show failed for {hash}: {stderr}")));
373 }
374
375 let info = String::from_utf8_lossy(&info_output.stdout);
376 let parts: Vec<&str> = info.splitn(7, '\0').collect();
377
378 if parts.len() < 7 {
379 return Err(CommitGenError::GitError(format!("Failed to parse commit metadata for {hash}")));
380 }
381
382 let tree_output = Command::new("git")
384 .args(["rev-parse", &format!("{hash}^{{tree}}")])
385 .current_dir(dir)
386 .output()
387 .map_err(|e| CommitGenError::GitError(format!("Failed to get tree hash: {e}")))?;
388 let tree_hash = String::from_utf8_lossy(&tree_output.stdout)
389 .trim()
390 .to_string();
391
392 let parents_output = Command::new("git")
394 .args(["rev-list", "--parents", "-n", "1", hash])
395 .current_dir(dir)
396 .output()
397 .map_err(|e| CommitGenError::GitError(format!("Failed to get parent hashes: {e}")))?;
398 let parents_line = String::from_utf8_lossy(&parents_output.stdout);
399 let parent_hashes: Vec<String> = parents_line
400 .split_whitespace()
401 .skip(1) .map(|s| s.to_string())
403 .collect();
404
405 Ok(CommitMetadata {
406 hash: hash.to_string(),
407 author_name: parts[0].to_string(),
408 author_email: parts[1].to_string(),
409 author_date: parts[2].to_string(),
410 committer_name: parts[3].to_string(),
411 committer_email: parts[4].to_string(),
412 committer_date: parts[5].to_string(),
413 message: parts[6].trim().to_string(),
414 parent_hashes,
415 tree_hash,
416 })
417}
418
419pub fn check_working_tree_clean(dir: &str) -> Result<bool> {
421 let output = Command::new("git")
422 .args(["status", "--porcelain"])
423 .current_dir(dir)
424 .output()
425 .map_err(|e| CommitGenError::GitError(format!("Failed to check working tree: {e}")))?;
426
427 Ok(output.stdout.is_empty())
428}
429
430pub fn create_backup_branch(dir: &str) -> Result<String> {
432 use chrono::Local;
433
434 let timestamp = Local::now().format("%Y%m%d-%H%M%S");
435 let backup_name = format!("backup-rewrite-{timestamp}");
436
437 let output = Command::new("git")
438 .args(["branch", &backup_name])
439 .current_dir(dir)
440 .output()
441 .map_err(|e| CommitGenError::GitError(format!("Failed to create backup branch: {e}")))?;
442
443 if !output.status.success() {
444 let stderr = String::from_utf8_lossy(&output.stderr);
445 return Err(CommitGenError::GitError(format!("git branch failed: {stderr}")));
446 }
447
448 Ok(backup_name)
449}
450
451pub fn get_recent_commits(dir: &str, count: usize) -> Result<Vec<String>> {
453 let output = Command::new("git")
454 .args(["log", &format!("-{count}"), "--pretty=format:%s"])
455 .current_dir(dir)
456 .output()
457 .map_err(|e| CommitGenError::GitError(format!("Failed to run git log: {e}")))?;
458
459 if !output.status.success() {
460 let stderr = String::from_utf8_lossy(&output.stderr);
461 return Err(CommitGenError::GitError(format!("git log failed: {stderr}")));
462 }
463
464 let stdout = String::from_utf8_lossy(&output.stdout);
465 Ok(stdout.lines().map(|s| s.to_string()).collect())
466}
467
468pub fn get_common_scopes(dir: &str, limit: usize) -> Result<Vec<(String, usize)>> {
470 let output = Command::new("git")
471 .args(["log", &format!("-{limit}"), "--pretty=format:%s"])
472 .current_dir(dir)
473 .output()
474 .map_err(|e| CommitGenError::GitError(format!("Failed to run git log: {e}")))?;
475
476 if !output.status.success() {
477 let stderr = String::from_utf8_lossy(&output.stderr);
478 return Err(CommitGenError::GitError(format!("git log failed: {stderr}")));
479 }
480
481 let stdout = String::from_utf8_lossy(&output.stdout);
482 let mut scope_counts: HashMap<String, usize> = HashMap::new();
483
484 for line in stdout.lines() {
486 if let Some(scope) = extract_scope_from_commit(line) {
487 *scope_counts.entry(scope).or_insert(0) += 1;
488 }
489 }
490
491 let mut scopes: Vec<(String, usize)> = scope_counts.into_iter().collect();
493 scopes.sort_by(|a, b| b.1.cmp(&a.1));
494
495 Ok(scopes)
496}
497
498fn extract_scope_from_commit(commit_msg: &str) -> Option<String> {
500 let parts: Vec<&str> = commit_msg.splitn(2, ':').collect();
502 if parts.len() < 2 {
503 return None;
504 }
505
506 let prefix = parts[0];
507 if let Some(scope_start) = prefix.find('(')
508 && let Some(scope_end) = prefix.find(')')
509 && scope_start < scope_end
510 {
511 return Some(prefix[scope_start + 1..scope_end].to_string());
512 }
513
514 None
515}
516
517#[derive(Debug, Clone)]
519pub struct StylePatterns {
520 pub scope_usage_pct: f32,
522 pub common_verbs: Vec<(String, usize)>,
524 pub avg_length: usize,
526 pub length_range: (usize, usize),
528 pub lowercase_pct: f32,
530 pub top_scopes: Vec<(String, usize)>,
532}
533
534impl StylePatterns {
535 pub fn format_for_prompt(&self) -> String {
537 let mut lines = Vec::new();
538
539 lines.push(format!("Scope usage: {:.0}% of commits use scopes", self.scope_usage_pct));
540
541 if !self.common_verbs.is_empty() {
542 let verbs: Vec<_> = self
543 .common_verbs
544 .iter()
545 .take(5)
546 .map(|(v, c)| format!("{v} ({c})"))
547 .collect();
548 lines.push(format!("Common verbs: {}", verbs.join(", ")));
549 }
550
551 lines.push(format!(
552 "Average length: {} chars (range: {}-{})",
553 self.avg_length, self.length_range.0, self.length_range.1
554 ));
555
556 lines.push(format!("Capitalization: {:.0}% start lowercase", self.lowercase_pct));
557
558 if !self.top_scopes.is_empty() {
559 let scopes: Vec<_> = self
560 .top_scopes
561 .iter()
562 .take(5)
563 .map(|(s, c)| format!("{s} ({c})"))
564 .collect();
565 lines.push(format!("Top scopes: {}", scopes.join(", ")));
566 }
567
568 lines.join("\n")
569 }
570}
571
572pub fn extract_style_patterns(commits: &[String]) -> Option<StylePatterns> {
574 if commits.is_empty() {
575 return None;
576 }
577
578 let mut scope_count = 0;
579 let mut lowercase_count = 0;
580 let mut verb_counts: HashMap<String, usize> = HashMap::new();
581 let mut scope_counts: HashMap<String, usize> = HashMap::new();
582 let mut lengths = Vec::new();
583
584 for commit in commits {
585 if let Some(colon_pos) = commit.find(':') {
587 let prefix = &commit[..colon_pos];
588 let summary = commit[colon_pos + 1..].trim();
589
590 if let Some(paren_start) = prefix.find('(')
592 && let Some(paren_end) = prefix.find(')')
593 {
594 scope_count += 1;
595 let scope = &prefix[paren_start + 1..paren_end];
596 *scope_counts.entry(scope.to_string()).or_insert(0) += 1;
597 }
598
599 if let Some(first_char) = summary.chars().next() {
601 if first_char.is_lowercase() {
602 lowercase_count += 1;
603 }
604
605 let first_word = summary.split_whitespace().next().unwrap_or("");
607 if !first_word.is_empty() {
608 *verb_counts.entry(first_word.to_lowercase()).or_insert(0) += 1;
609 }
610 }
611
612 lengths.push(summary.len());
613 }
614 }
615
616 let total = commits.len();
617 let scope_usage_pct = (scope_count as f32 / total as f32) * 100.0;
618 let lowercase_pct = (lowercase_count as f32 / total as f32) * 100.0;
619
620 let mut common_verbs: Vec<_> = verb_counts.into_iter().collect();
622 common_verbs.sort_by(|a, b| b.1.cmp(&a.1));
623
624 let mut top_scopes: Vec<_> = scope_counts.into_iter().collect();
626 top_scopes.sort_by(|a, b| b.1.cmp(&a.1));
627
628 let avg_length = if lengths.is_empty() {
630 0
631 } else {
632 lengths.iter().sum::<usize>() / lengths.len()
633 };
634 let length_range = if lengths.is_empty() {
635 (0, 0)
636 } else {
637 (*lengths.iter().min().unwrap_or(&0), *lengths.iter().max().unwrap_or(&0))
638 };
639
640 Some(StylePatterns {
641 scope_usage_pct,
642 common_verbs,
643 avg_length,
644 length_range,
645 lowercase_pct,
646 top_scopes,
647 })
648}
649
650pub fn rewrite_history(
652 commits: &[CommitMetadata],
653 new_messages: &[String],
654 dir: &str,
655) -> Result<()> {
656 if commits.len() != new_messages.len() {
657 return Err(CommitGenError::Other("Commit count mismatch".to_string()));
658 }
659
660 let branch_output = Command::new("git")
662 .args(["rev-parse", "--abbrev-ref", "HEAD"])
663 .current_dir(dir)
664 .output()
665 .map_err(|e| CommitGenError::GitError(format!("Failed to get current branch: {e}")))?;
666 let current_branch = String::from_utf8_lossy(&branch_output.stdout)
667 .trim()
668 .to_string();
669
670 let mut parent_map: HashMap<String, String> = HashMap::new();
672 let mut new_head: Option<String> = None;
673
674 for (idx, (commit, new_msg)) in commits.iter().zip(new_messages.iter()).enumerate() {
675 let new_parents: Vec<String> = commit
677 .parent_hashes
678 .iter()
679 .map(|old_parent| {
680 parent_map
681 .get(old_parent)
682 .cloned()
683 .unwrap_or_else(|| old_parent.clone())
684 })
685 .collect();
686
687 let mut cmd = Command::new("git");
689 cmd.arg("commit-tree")
690 .arg(&commit.tree_hash)
691 .arg("-m")
692 .arg(new_msg)
693 .current_dir(dir);
694
695 for parent in &new_parents {
696 cmd.arg("-p").arg(parent);
697 }
698
699 cmd.env("GIT_AUTHOR_NAME", &commit.author_name)
701 .env("GIT_AUTHOR_EMAIL", &commit.author_email)
702 .env("GIT_AUTHOR_DATE", &commit.author_date)
703 .env("GIT_COMMITTER_NAME", &commit.committer_name)
704 .env("GIT_COMMITTER_EMAIL", &commit.committer_email)
705 .env("GIT_COMMITTER_DATE", &commit.committer_date);
706
707 let output = cmd
708 .output()
709 .map_err(|e| CommitGenError::GitError(format!("Failed to run git commit-tree: {e}")))?;
710
711 if !output.status.success() {
712 let stderr = String::from_utf8_lossy(&output.stderr);
713 return Err(CommitGenError::GitError(format!(
714 "commit-tree failed for {}: {}",
715 commit.hash, stderr
716 )));
717 }
718
719 let new_hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
720
721 parent_map.insert(commit.hash.clone(), new_hash.clone());
722 new_head = Some(new_hash);
723
724 if (idx + 1) % 50 == 0 {
726 eprintln!(" Rewrote {}/{} commits...", idx + 1, commits.len());
727 }
728 }
729
730 if let Some(head) = new_head {
732 let update_output = Command::new("git")
733 .args(["update-ref", &format!("refs/heads/{current_branch}"), &head])
734 .current_dir(dir)
735 .output()
736 .map_err(|e| CommitGenError::GitError(format!("Failed to update ref: {e}")))?;
737
738 if !update_output.status.success() {
739 let stderr = String::from_utf8_lossy(&update_output.stderr);
740 return Err(CommitGenError::GitError(format!("git update-ref failed: {stderr}")));
741 }
742
743 let reset_output = Command::new("git")
744 .args(["reset", "--hard", &head])
745 .current_dir(dir)
746 .output()
747 .map_err(|e| CommitGenError::GitError(format!("Failed to reset: {e}")))?;
748
749 if !reset_output.status.success() {
750 let stderr = String::from_utf8_lossy(&reset_output.stderr);
751 return Err(CommitGenError::GitError(format!("git reset failed: {stderr}")));
752 }
753 }
754
755 Ok(())
756}