1use crate::git::current_branch;
19use crate::git::error::{GitError, classify_push_error, git_base_command, git_run};
20use anyhow::Context;
21use std::path::{Path, PathBuf};
22
23use crate::git::status::status_porcelain;
24
25pub fn revert_uncommitted(repo_root: &Path) -> Result<(), GitError> {
29 if git_run(repo_root, &["restore", "--staged", "--worktree", "."]).is_err() {
32 git_run(repo_root, &["checkout", "--", "."]).context("fallback git checkout -- .")?;
34 git_run(repo_root, &["reset", "--quiet", "HEAD"]).context("git reset --quiet HEAD")?;
36 }
37
38 git_run(repo_root, &["clean", "-fd", "-e", ".env", "-e", ".env.*"])
40 .context("git clean -fd -e .env*")?;
41 Ok(())
42}
43
44pub fn commit_all(repo_root: &Path, message: &str) -> Result<(), GitError> {
49 let message = message.trim();
50 if message.is_empty() {
51 return Err(GitError::EmptyCommitMessage);
52 }
53
54 git_run(repo_root, &["add", "-A"]).context("git add -A")?;
55 let status = status_porcelain(repo_root)?;
56 if status.trim().is_empty() {
57 return Err(GitError::NoChangesToCommit);
58 }
59
60 git_run(repo_root, &["commit", "-m", message]).context("git commit")?;
61 Ok(())
62}
63
64pub fn add_paths_force(repo_root: &Path, paths: &[PathBuf]) -> Result<(), GitError> {
68 if paths.is_empty() {
69 return Ok(());
70 }
71
72 let mut rel_paths: Vec<String> = Vec::new();
73 for path in paths {
74 if !path.exists() {
75 continue;
76 }
77 let rel = match path.strip_prefix(repo_root) {
78 Ok(rel) => rel,
79 Err(_) => {
80 log::debug!(
81 "Skipping force-add for path outside repo root: {}",
82 path.display()
83 );
84 continue;
85 }
86 };
87 if rel.as_os_str().is_empty() {
88 continue;
89 }
90 rel_paths.push(rel.to_string_lossy().to_string());
91 }
92
93 if rel_paths.is_empty() {
94 return Ok(());
95 }
96
97 let mut add_args: Vec<String> = vec!["add".to_string(), "-f".to_string(), "--".to_string()];
98 add_args.extend(rel_paths.iter().cloned());
99 let add_refs: Vec<&str> = add_args.iter().map(|s| s.as_str()).collect();
100 git_run(repo_root, &add_refs).context("git add -f -- <paths>")?;
101 Ok(())
102}
103
104pub fn restore_tracked_paths_to_head(repo_root: &Path, paths: &[PathBuf]) -> Result<(), GitError> {
108 if paths.is_empty() {
109 return Ok(());
110 }
111
112 let mut rel_paths: Vec<String> = Vec::new();
113 for path in paths {
114 let rel = match path.strip_prefix(repo_root) {
115 Ok(rel) => rel,
116 Err(_) => {
117 log::debug!(
118 "Skipping restore for path outside repo root: {}",
119 path.display()
120 );
121 continue;
122 }
123 };
124 if rel.as_os_str().is_empty() {
125 continue;
126 }
127 let rel_str = rel.to_string_lossy().to_string();
128 if is_tracked_path(repo_root, &rel_str)? {
129 rel_paths.push(rel_str);
130 } else {
131 log::debug!("Skipping restore for untracked path: {}", rel.display());
132 }
133 }
134
135 if rel_paths.is_empty() {
136 return Ok(());
137 }
138
139 let mut restore_args: Vec<String> = vec![
140 "restore".to_string(),
141 "--staged".to_string(),
142 "--worktree".to_string(),
143 "--".to_string(),
144 ];
145 restore_args.extend(rel_paths.iter().cloned());
146 let restore_refs: Vec<&str> = restore_args.iter().map(|s| s.as_str()).collect();
147 if git_run(repo_root, &restore_refs).is_err() {
148 let mut checkout_args: Vec<String> = vec!["checkout".to_string(), "--".to_string()];
149 checkout_args.extend(rel_paths.iter().cloned());
150 let checkout_refs: Vec<&str> = checkout_args.iter().map(|s| s.as_str()).collect();
151 git_run(repo_root, &checkout_refs).context("fallback git checkout -- <paths>")?;
152
153 let mut reset_args: Vec<String> = vec![
154 "reset".to_string(),
155 "--quiet".to_string(),
156 "HEAD".to_string(),
157 "--".to_string(),
158 ];
159 reset_args.extend(rel_paths.iter().cloned());
160 let reset_refs: Vec<&str> = reset_args.iter().map(|s| s.as_str()).collect();
161 git_run(repo_root, &reset_refs).context("git reset --quiet HEAD -- <paths>")?;
162 }
163
164 Ok(())
165}
166
167fn is_tracked_path(repo_root: &Path, rel_path: &str) -> Result<bool, GitError> {
168 let output = git_base_command(repo_root)
169 .args(["ls-files", "--error-unmatch", "--", rel_path])
170 .output()
171 .with_context(|| {
172 format!(
173 "run git ls-files --error-unmatch for {} in {}",
174 rel_path,
175 repo_root.display()
176 )
177 })?;
178
179 if output.status.success() {
180 return Ok(true);
181 }
182
183 let stderr = String::from_utf8_lossy(&output.stderr).to_lowercase();
184 if stderr.contains("pathspec") || stderr.contains("did not match any file") {
185 return Ok(false);
186 }
187
188 Err(GitError::CommandFailed {
189 args: format!("ls-files --error-unmatch -- {}", rel_path),
190 code: output.status.code(),
191 stderr: stderr.trim().to_string(),
192 })
193}
194
195pub fn upstream_ref(repo_root: &Path) -> Result<String, GitError> {
199 let output = git_base_command(repo_root)
200 .arg("rev-parse")
201 .arg("--abbrev-ref")
202 .arg("--symbolic-full-name")
203 .arg("@{u}")
204 .output()
205 .with_context(|| {
206 format!(
207 "run git rev-parse --abbrev-ref --symbolic-full-name @{{u}} in {}",
208 repo_root.display()
209 )
210 })?;
211
212 if !output.status.success() {
213 let stderr = String::from_utf8_lossy(&output.stderr);
214 return Err(classify_push_error(&stderr));
215 }
216
217 let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
218 if value.is_empty() {
219 return Err(GitError::NoUpstreamConfigured);
220 }
221 Ok(value)
222}
223
224pub fn is_ahead_of_upstream(repo_root: &Path) -> Result<bool, GitError> {
228 let upstream = upstream_ref(repo_root)?;
229 let range = format!("{upstream}...HEAD");
230 let output = git_base_command(repo_root)
231 .arg("rev-list")
232 .arg("--left-right")
233 .arg("--count")
234 .arg(range)
235 .output()
236 .with_context(|| {
237 format!(
238 "run git rev-list --left-right --count in {}",
239 repo_root.display()
240 )
241 })?;
242
243 if !output.status.success() {
244 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
245 return Err(GitError::CommandFailed {
246 args: "rev-list --left-right --count".to_string(),
247 code: output.status.code(),
248 stderr: stderr.trim().to_string(),
249 });
250 }
251
252 let counts = String::from_utf8_lossy(&output.stdout);
253 let parts: Vec<&str> = counts.split_whitespace().collect();
254 if parts.len() != 2 {
255 return Err(GitError::UnexpectedRevListOutput(counts.trim().to_string()));
256 }
257
258 let ahead: u32 = parts[1].parse().context("parse ahead count")?;
259 Ok(ahead > 0)
260}
261
262pub fn push_upstream(repo_root: &Path) -> Result<(), GitError> {
267 let output = git_base_command(repo_root)
268 .arg("push")
269 .output()
270 .with_context(|| format!("run git push in {}", repo_root.display()))?;
271
272 if output.status.success() {
273 return Ok(());
274 }
275
276 let stderr = String::from_utf8_lossy(&output.stderr);
277 Err(classify_push_error(&stderr))
278}
279
280pub fn push_upstream_allow_create(repo_root: &Path) -> Result<(), GitError> {
284 let output = git_base_command(repo_root)
285 .arg("push")
286 .arg("-u")
287 .arg("origin")
288 .arg("HEAD")
289 .output()
290 .with_context(|| format!("run git push -u origin HEAD in {}", repo_root.display()))?;
291
292 if output.status.success() {
293 return Ok(());
294 }
295
296 let stderr = String::from_utf8_lossy(&output.stderr);
297 Err(classify_push_error(&stderr))
298}
299
300fn is_non_fast_forward_error(err: &GitError) -> bool {
301 let GitError::PushFailed(detail) = err else {
302 return false;
303 };
304 let lower = detail.to_lowercase();
305 lower.contains("non-fast-forward")
306 || lower.contains("non fast-forward")
307 || lower.contains("fetch first")
308 || lower.contains("rejected")
309 || lower.contains("updates were rejected")
310}
311
312fn reference_exists(repo_root: &Path, reference: &str) -> Result<bool, GitError> {
313 let output = git_base_command(repo_root)
314 .args(["rev-parse", "--verify", "--quiet", reference])
315 .output()
316 .with_context(|| {
317 format!(
318 "run git rev-parse --verify --quiet {} in {}",
319 reference,
320 repo_root.display()
321 )
322 })?;
323 if output.status.success() {
324 return Ok(true);
325 }
326 if output.status.code() == Some(1) {
327 return Ok(false);
328 }
329 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
330 Err(GitError::CommandFailed {
331 args: format!("rev-parse --verify --quiet {}", reference),
332 code: output.status.code(),
333 stderr: stderr.trim().to_string(),
334 })
335}
336
337fn is_ahead_of_ref(repo_root: &Path, reference: &str) -> Result<bool, GitError> {
338 let range = format!("{reference}...HEAD");
339 let output = git_base_command(repo_root)
340 .arg("rev-list")
341 .arg("--left-right")
342 .arg("--count")
343 .arg(range)
344 .output()
345 .with_context(|| {
346 format!(
347 "run git rev-list --left-right --count in {}",
348 repo_root.display()
349 )
350 })?;
351
352 if !output.status.success() {
353 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
354 return Err(GitError::CommandFailed {
355 args: "rev-list --left-right --count".to_string(),
356 code: output.status.code(),
357 stderr: stderr.trim().to_string(),
358 });
359 }
360
361 let counts = String::from_utf8_lossy(&output.stdout);
362 let parts: Vec<&str> = counts.split_whitespace().collect();
363 if parts.len() != 2 {
364 return Err(GitError::UnexpectedRevListOutput(counts.trim().to_string()));
365 }
366
367 let ahead: u32 = parts[1].parse().context("parse ahead count")?;
368 Ok(ahead > 0)
369}
370
371fn set_upstream_to(repo_root: &Path, upstream: &str) -> Result<(), GitError> {
372 git_run(repo_root, &["branch", "--set-upstream-to", upstream])
373 .with_context(|| format!("set upstream to {} in {}", upstream, repo_root.display()))?;
374 Ok(())
375}
376
377pub fn fetch_branch(repo_root: &Path, remote: &str, branch: &str) -> Result<(), GitError> {
379 git_run(repo_root, &["fetch", remote, branch])
380 .with_context(|| format!("fetch {} {} in {}", remote, branch, repo_root.display()))?;
381 Ok(())
382}
383
384pub fn is_behind_upstream(repo_root: &Path, branch: &str) -> Result<bool, GitError> {
388 fetch_branch(repo_root, "origin", branch)?;
390
391 let upstream = format!("origin/{}", branch);
392 let range = format!("HEAD...{}", upstream);
393
394 let output = git_base_command(repo_root)
395 .arg("rev-list")
396 .arg("--left-right")
397 .arg("--count")
398 .arg(&range)
399 .output()
400 .with_context(|| {
401 format!(
402 "run git rev-list --left-right --count {} in {}",
403 range,
404 repo_root.display()
405 )
406 })?;
407
408 if !output.status.success() {
409 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
410 return Err(GitError::CommandFailed {
411 args: format!("rev-list --left-right --count {}", range),
412 code: output.status.code(),
413 stderr: stderr.trim().to_string(),
414 });
415 }
416
417 let counts = String::from_utf8_lossy(&output.stdout);
418 let parts: Vec<&str> = counts.split_whitespace().collect();
419 if parts.len() != 2 {
420 return Err(GitError::UnexpectedRevListOutput(counts.trim().to_string()));
421 }
422
423 let behind: u32 = parts[0].parse().context("parse behind count")?;
425 Ok(behind > 0)
426}
427
428pub fn rebase_onto(repo_root: &Path, target: &str) -> Result<(), GitError> {
430 git_run(repo_root, &["fetch", "origin", "--prune"])
432 .with_context(|| format!("fetch before rebase in {}", repo_root.display()))?;
433 git_run(repo_root, &["rebase", target])
434 .with_context(|| format!("rebase onto {} in {}", target, repo_root.display()))?;
435 Ok(())
436}
437
438pub fn abort_rebase(repo_root: &Path) -> Result<(), GitError> {
440 git_run(repo_root, &["rebase", "--abort"])
441 .with_context(|| format!("abort rebase in {}", repo_root.display()))?;
442 Ok(())
443}
444
445pub fn list_conflict_files(repo_root: &Path) -> Result<Vec<String>, GitError> {
449 let output = git_base_command(repo_root)
450 .args(["diff", "--name-only", "--diff-filter=U"])
451 .output()
452 .with_context(|| {
453 format!(
454 "run git diff --name-only --diff-filter=U in {}",
455 repo_root.display()
456 )
457 })?;
458
459 if !output.status.success() {
460 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
461 return Err(GitError::CommandFailed {
462 args: "diff --name-only --diff-filter=U".to_string(),
463 code: output.status.code(),
464 stderr: stderr.trim().to_string(),
465 });
466 }
467
468 let stdout = String::from_utf8_lossy(&output.stdout);
469 let files: Vec<String> = stdout
470 .lines()
471 .map(|s| s.trim().to_string())
472 .filter(|s| !s.is_empty())
473 .collect();
474
475 Ok(files)
476}
477
478pub fn push_current_branch(repo_root: &Path, remote: &str) -> Result<(), GitError> {
482 let output = git_base_command(repo_root)
483 .arg("push")
484 .arg(remote)
485 .arg("HEAD")
486 .output()
487 .with_context(|| format!("run git push {} HEAD in {}", remote, repo_root.display()))?;
488
489 if output.status.success() {
490 return Ok(());
491 }
492
493 let stderr = String::from_utf8_lossy(&output.stderr);
494 Err(classify_push_error(&stderr))
495}
496
497pub fn push_head_to_branch(repo_root: &Path, remote: &str, branch: &str) -> Result<(), GitError> {
502 let output = git_base_command(repo_root)
503 .arg("push")
504 .arg(remote)
505 .arg(format!("HEAD:{}", branch))
506 .output()
507 .with_context(|| {
508 format!(
509 "run git push {} HEAD:{} in {}",
510 remote,
511 branch,
512 repo_root.display()
513 )
514 })?;
515
516 if output.status.success() {
517 return Ok(());
518 }
519
520 let stderr = String::from_utf8_lossy(&output.stderr);
521 Err(classify_push_error(&stderr))
522}
523
524pub fn push_upstream_with_rebase(repo_root: &Path) -> Result<(), GitError> {
532 const MAX_PUSH_ATTEMPTS: usize = 4;
533 let branch = current_branch(repo_root).map_err(GitError::Other)?;
534 let fallback_upstream = format!("origin/{}", branch);
535 let ahead = match is_ahead_of_upstream(repo_root) {
536 Ok(ahead) => ahead,
537 Err(GitError::NoUpstream) | Err(GitError::NoUpstreamConfigured) => {
538 if reference_exists(repo_root, &fallback_upstream)? {
539 is_ahead_of_ref(repo_root, &fallback_upstream)?
540 } else {
541 true
542 }
543 }
544 Err(err) => return Err(err),
545 };
546
547 if !ahead {
548 if upstream_ref(repo_root).is_err() && reference_exists(repo_root, &fallback_upstream)? {
549 set_upstream_to(repo_root, &fallback_upstream)?;
550 }
551 return Ok(());
552 }
553
554 let mut last_non_fast_forward: Option<GitError> = None;
555 for _attempt in 0..MAX_PUSH_ATTEMPTS {
556 let push_result = match push_upstream(repo_root) {
557 Ok(()) => return Ok(()),
558 Err(GitError::NoUpstream) | Err(GitError::NoUpstreamConfigured) => {
559 push_upstream_allow_create(repo_root)
560 }
561 Err(err) => Err(err),
562 };
563
564 match push_result {
565 Ok(()) => return Ok(()),
566 Err(err) if is_non_fast_forward_error(&err) => {
567 let upstream = match upstream_ref(repo_root) {
568 Ok(upstream) => upstream,
569 Err(_) => fallback_upstream.clone(),
570 };
571 rebase_onto(repo_root, &upstream)?;
572 if !is_ahead_of_ref(repo_root, &upstream)? {
573 if upstream_ref(repo_root).is_err() {
574 set_upstream_to(repo_root, &upstream)?;
575 }
576 return Ok(());
577 }
578 last_non_fast_forward = Some(err);
579 continue;
580 }
581 Err(err) => return Err(err),
582 }
583 }
584
585 Err(last_non_fast_forward
586 .unwrap_or_else(|| GitError::PushFailed("rebase-aware push exhausted retries".to_string())))
587}
588
589#[cfg(test)]
590mod tests {
591 use super::*;
592 use crate::testsupport::git as git_test;
593 use tempfile::TempDir;
594
595 #[test]
596 fn push_upstream_with_rebase_recovers_from_non_fast_forward() -> anyhow::Result<()> {
597 let remote = TempDir::new()?;
598 git_test::init_bare_repo(remote.path())?;
599
600 let repo_a = TempDir::new()?;
601 git_test::init_repo(repo_a.path())?;
602 git_test::add_remote(repo_a.path(), "origin", remote.path())?;
603
604 std::fs::write(repo_a.path().join("base.txt"), "init\n")?;
605 git_test::commit_all(repo_a.path(), "init")?;
606 git_test::git_run(repo_a.path(), &["push", "-u", "origin", "HEAD"])?;
607
608 let repo_b = TempDir::new()?;
609 git_test::clone_repo(remote.path(), repo_b.path())?;
610 git_test::configure_user(repo_b.path())?;
611 std::fs::write(repo_b.path().join("remote.txt"), "remote\n")?;
612 git_test::commit_all(repo_b.path(), "remote update")?;
613 git_test::git_run(repo_b.path(), &["push"])?;
614
615 std::fs::write(repo_a.path().join("local.txt"), "local\n")?;
616 git_test::commit_all(repo_a.path(), "local update")?;
617
618 push_upstream_with_rebase(repo_a.path())?;
619
620 let counts = git_test::git_output(
621 repo_a.path(),
622 &["rev-list", "--left-right", "--count", "@{u}...HEAD"],
623 )?;
624 let parts: Vec<&str> = counts.split_whitespace().collect();
625 assert_eq!(parts, vec!["0", "0"]);
626
627 Ok(())
628 }
629
630 #[test]
631 fn push_upstream_with_rebase_sets_upstream_when_remote_branch_exists_and_local_is_behind()
632 -> anyhow::Result<()> {
633 let remote = TempDir::new()?;
634 git_test::init_bare_repo(remote.path())?;
635
636 let seed = TempDir::new()?;
637 git_test::init_repo(seed.path())?;
638 git_test::add_remote(seed.path(), "origin", remote.path())?;
639 std::fs::write(seed.path().join("base.txt"), "base\n")?;
640 git_test::commit_all(seed.path(), "init")?;
641 git_test::git_run(seed.path(), &["push", "-u", "origin", "HEAD"])?;
642 git_test::git_run(seed.path(), &["checkout", "-b", "ralph/RQ-0940"])?;
643 std::fs::write(seed.path().join("task.txt"), "remote-only\n")?;
644 git_test::commit_all(seed.path(), "remote task")?;
645 git_test::git_run(seed.path(), &["push", "-u", "origin", "ralph/RQ-0940"])?;
646
647 let local = TempDir::new()?;
648 git_test::clone_repo(remote.path(), local.path())?;
649 git_test::configure_user(local.path())?;
650 git_test::git_run(
651 local.path(),
652 &[
653 "checkout",
654 "--no-track",
655 "-b",
656 "ralph/RQ-0940",
657 "origin/main",
658 ],
659 )?;
660
661 push_upstream_with_rebase(local.path())?;
663
664 let upstream = git_test::git_output(
665 local.path(),
666 &["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
667 )?;
668 assert_eq!(upstream, "origin/ralph/RQ-0940");
669
670 Ok(())
671 }
672}