1use crate::git::current_branch;
19use crate::git::error::{GitError, classify_push_error, git_output, 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_output(repo_root, &["ls-files", "--error-unmatch", "--", rel_path])
169 .with_context(|| {
170 format!(
171 "run git ls-files --error-unmatch for {} in {}",
172 rel_path,
173 repo_root.display()
174 )
175 })?;
176
177 if output.status.success() {
178 return Ok(true);
179 }
180
181 let stderr = String::from_utf8_lossy(&output.stderr).to_lowercase();
182 if stderr.contains("pathspec") || stderr.contains("did not match any file") {
183 return Ok(false);
184 }
185
186 Err(GitError::CommandFailed {
187 args: format!("ls-files --error-unmatch -- {}", rel_path),
188 code: output.status.code(),
189 stderr: stderr.trim().to_string(),
190 })
191}
192
193pub fn upstream_ref(repo_root: &Path) -> Result<String, GitError> {
197 let output = git_output(
198 repo_root,
199 &["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
200 )
201 .with_context(|| {
202 format!(
203 "run git rev-parse --abbrev-ref --symbolic-full-name @{{u}} in {}",
204 repo_root.display()
205 )
206 })?;
207
208 if !output.status.success() {
209 let stderr = String::from_utf8_lossy(&output.stderr);
210 return Err(classify_push_error(&stderr));
211 }
212
213 let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
214 if value.is_empty() {
215 return Err(GitError::NoUpstreamConfigured);
216 }
217 Ok(value)
218}
219
220pub fn is_ahead_of_upstream(repo_root: &Path) -> Result<bool, GitError> {
224 let upstream = upstream_ref(repo_root)?;
225 let range = format!("{upstream}...HEAD");
226 let output = git_output(repo_root, &["rev-list", "--left-right", "--count", &range])
227 .with_context(|| {
228 format!(
229 "run git rev-list --left-right --count in {}",
230 repo_root.display()
231 )
232 })?;
233
234 if !output.status.success() {
235 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
236 return Err(GitError::CommandFailed {
237 args: "rev-list --left-right --count".to_string(),
238 code: output.status.code(),
239 stderr: stderr.trim().to_string(),
240 });
241 }
242
243 let counts = String::from_utf8_lossy(&output.stdout);
244 let parts: Vec<&str> = counts.split_whitespace().collect();
245 if parts.len() != 2 {
246 return Err(GitError::UnexpectedRevListOutput(counts.trim().to_string()));
247 }
248
249 let ahead: u32 = parts[1].parse().context("parse ahead count")?;
250 Ok(ahead > 0)
251}
252
253pub fn push_upstream(repo_root: &Path) -> Result<(), GitError> {
258 let output = git_output(repo_root, &["push"])
259 .with_context(|| format!("run git push in {}", repo_root.display()))?;
260
261 if output.status.success() {
262 return Ok(());
263 }
264
265 let stderr = String::from_utf8_lossy(&output.stderr);
266 Err(classify_push_error(&stderr))
267}
268
269pub fn push_upstream_allow_create(repo_root: &Path) -> Result<(), GitError> {
273 let output = git_output(repo_root, &["push", "-u", "origin", "HEAD"])
274 .with_context(|| format!("run git push -u origin HEAD in {}", repo_root.display()))?;
275
276 if output.status.success() {
277 return Ok(());
278 }
279
280 let stderr = String::from_utf8_lossy(&output.stderr);
281 Err(classify_push_error(&stderr))
282}
283
284fn is_non_fast_forward_error(err: &GitError) -> bool {
285 let GitError::PushFailed(detail) = err else {
286 return false;
287 };
288 let lower = detail.to_lowercase();
289 lower.contains("non-fast-forward")
290 || lower.contains("non fast-forward")
291 || lower.contains("fetch first")
292 || lower.contains("rejected")
293 || lower.contains("updates were rejected")
294}
295
296fn reference_exists(repo_root: &Path, reference: &str) -> Result<bool, GitError> {
297 let output = git_output(repo_root, &["rev-parse", "--verify", "--quiet", reference])
298 .with_context(|| {
299 format!(
300 "run git rev-parse --verify --quiet {} in {}",
301 reference,
302 repo_root.display()
303 )
304 })?;
305 if output.status.success() {
306 return Ok(true);
307 }
308 if output.status.code() == Some(1) {
309 return Ok(false);
310 }
311 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
312 Err(GitError::CommandFailed {
313 args: format!("rev-parse --verify --quiet {}", reference),
314 code: output.status.code(),
315 stderr: stderr.trim().to_string(),
316 })
317}
318
319fn is_ahead_of_ref(repo_root: &Path, reference: &str) -> Result<bool, GitError> {
320 let range = format!("{reference}...HEAD");
321 let output = git_output(repo_root, &["rev-list", "--left-right", "--count", &range])
322 .with_context(|| {
323 format!(
324 "run git rev-list --left-right --count in {}",
325 repo_root.display()
326 )
327 })?;
328
329 if !output.status.success() {
330 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
331 return Err(GitError::CommandFailed {
332 args: "rev-list --left-right --count".to_string(),
333 code: output.status.code(),
334 stderr: stderr.trim().to_string(),
335 });
336 }
337
338 let counts = String::from_utf8_lossy(&output.stdout);
339 let parts: Vec<&str> = counts.split_whitespace().collect();
340 if parts.len() != 2 {
341 return Err(GitError::UnexpectedRevListOutput(counts.trim().to_string()));
342 }
343
344 let ahead: u32 = parts[1].parse().context("parse ahead count")?;
345 Ok(ahead > 0)
346}
347
348fn set_upstream_to(repo_root: &Path, upstream: &str) -> Result<(), GitError> {
349 git_run(repo_root, &["branch", "--set-upstream-to", upstream])
350 .with_context(|| format!("set upstream to {} in {}", upstream, repo_root.display()))?;
351 Ok(())
352}
353
354pub fn fetch_branch(repo_root: &Path, remote: &str, branch: &str) -> Result<(), GitError> {
356 git_run(repo_root, &["fetch", remote, branch])
357 .with_context(|| format!("fetch {} {} in {}", remote, branch, repo_root.display()))?;
358 Ok(())
359}
360
361pub fn is_behind_upstream(repo_root: &Path, branch: &str) -> Result<bool, GitError> {
365 fetch_branch(repo_root, "origin", branch)?;
367
368 let upstream = format!("origin/{}", branch);
369 let range = format!("HEAD...{}", upstream);
370
371 let output = git_output(repo_root, &["rev-list", "--left-right", "--count", &range])
372 .with_context(|| {
373 format!(
374 "run git rev-list --left-right --count {} in {}",
375 range,
376 repo_root.display()
377 )
378 })?;
379
380 if !output.status.success() {
381 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
382 return Err(GitError::CommandFailed {
383 args: format!("rev-list --left-right --count {}", range),
384 code: output.status.code(),
385 stderr: stderr.trim().to_string(),
386 });
387 }
388
389 let counts = String::from_utf8_lossy(&output.stdout);
390 let parts: Vec<&str> = counts.split_whitespace().collect();
391 if parts.len() != 2 {
392 return Err(GitError::UnexpectedRevListOutput(counts.trim().to_string()));
393 }
394
395 let behind: u32 = parts[0].parse().context("parse behind count")?;
397 Ok(behind > 0)
398}
399
400pub fn rebase_onto(repo_root: &Path, target: &str) -> Result<(), GitError> {
402 git_run(repo_root, &["fetch", "origin", "--prune"])
404 .with_context(|| format!("fetch before rebase in {}", repo_root.display()))?;
405 git_run(repo_root, &["rebase", target])
406 .with_context(|| format!("rebase onto {} in {}", target, repo_root.display()))?;
407 Ok(())
408}
409
410pub fn abort_rebase(repo_root: &Path) -> Result<(), GitError> {
412 git_run(repo_root, &["rebase", "--abort"])
413 .with_context(|| format!("abort rebase in {}", repo_root.display()))?;
414 Ok(())
415}
416
417pub fn list_conflict_files(repo_root: &Path) -> Result<Vec<String>, GitError> {
421 let output =
422 git_output(repo_root, &["diff", "--name-only", "--diff-filter=U"]).with_context(|| {
423 format!(
424 "run git diff --name-only --diff-filter=U in {}",
425 repo_root.display()
426 )
427 })?;
428
429 if !output.status.success() {
430 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
431 return Err(GitError::CommandFailed {
432 args: "diff --name-only --diff-filter=U".to_string(),
433 code: output.status.code(),
434 stderr: stderr.trim().to_string(),
435 });
436 }
437
438 let stdout = String::from_utf8_lossy(&output.stdout);
439 let files: Vec<String> = stdout
440 .lines()
441 .map(|s| s.trim().to_string())
442 .filter(|s| !s.is_empty())
443 .collect();
444
445 Ok(files)
446}
447
448pub fn push_current_branch(repo_root: &Path, remote: &str) -> Result<(), GitError> {
452 let output = git_output(repo_root, &["push", remote, "HEAD"])
453 .with_context(|| format!("run git push {} HEAD in {}", remote, repo_root.display()))?;
454
455 if output.status.success() {
456 return Ok(());
457 }
458
459 let stderr = String::from_utf8_lossy(&output.stderr);
460 Err(classify_push_error(&stderr))
461}
462
463pub fn push_head_to_branch(repo_root: &Path, remote: &str, branch: &str) -> Result<(), GitError> {
468 let refspec = format!("HEAD:{}", branch);
469 let output = git_output(repo_root, &["push", remote, &refspec]).with_context(|| {
470 format!(
471 "run git push {} HEAD:{} in {}",
472 remote,
473 branch,
474 repo_root.display()
475 )
476 })?;
477
478 if output.status.success() {
479 return Ok(());
480 }
481
482 let stderr = String::from_utf8_lossy(&output.stderr);
483 Err(classify_push_error(&stderr))
484}
485
486pub fn push_upstream_with_rebase(repo_root: &Path) -> Result<(), GitError> {
494 const MAX_PUSH_ATTEMPTS: usize = 4;
495 let branch = current_branch(repo_root).map_err(GitError::Other)?;
496 let fallback_upstream = format!("origin/{}", branch);
497 let ahead = match is_ahead_of_upstream(repo_root) {
498 Ok(ahead) => ahead,
499 Err(GitError::NoUpstream) | Err(GitError::NoUpstreamConfigured) => {
500 if reference_exists(repo_root, &fallback_upstream)? {
501 is_ahead_of_ref(repo_root, &fallback_upstream)?
502 } else {
503 true
504 }
505 }
506 Err(err) => return Err(err),
507 };
508
509 if !ahead {
510 if upstream_ref(repo_root).is_err() && reference_exists(repo_root, &fallback_upstream)? {
511 set_upstream_to(repo_root, &fallback_upstream)?;
512 }
513 return Ok(());
514 }
515
516 let mut last_non_fast_forward: Option<GitError> = None;
517 for _attempt in 0..MAX_PUSH_ATTEMPTS {
518 let push_result = match push_upstream(repo_root) {
519 Ok(()) => return Ok(()),
520 Err(GitError::NoUpstream) | Err(GitError::NoUpstreamConfigured) => {
521 push_upstream_allow_create(repo_root)
522 }
523 Err(err) => Err(err),
524 };
525
526 match push_result {
527 Ok(()) => return Ok(()),
528 Err(err) if is_non_fast_forward_error(&err) => {
529 let upstream = match upstream_ref(repo_root) {
530 Ok(upstream) => upstream,
531 Err(_) => fallback_upstream.clone(),
532 };
533 rebase_onto(repo_root, &upstream)?;
534 if !is_ahead_of_ref(repo_root, &upstream)? {
535 if upstream_ref(repo_root).is_err() {
536 set_upstream_to(repo_root, &upstream)?;
537 }
538 return Ok(());
539 }
540 last_non_fast_forward = Some(err);
541 continue;
542 }
543 Err(err) => return Err(err),
544 }
545 }
546
547 Err(last_non_fast_forward
548 .unwrap_or_else(|| GitError::PushFailed("rebase-aware push exhausted retries".to_string())))
549}
550
551#[cfg(test)]
552mod tests {
553 use super::*;
554 use crate::testsupport::git as git_test;
555 use tempfile::TempDir;
556
557 #[test]
558 fn push_upstream_with_rebase_recovers_from_non_fast_forward() -> anyhow::Result<()> {
559 let remote = TempDir::new()?;
560 git_test::init_bare_repo(remote.path())?;
561
562 let repo_a = TempDir::new()?;
563 git_test::init_repo(repo_a.path())?;
564 git_test::add_remote(repo_a.path(), "origin", remote.path())?;
565
566 std::fs::write(repo_a.path().join("base.txt"), "init\n")?;
567 git_test::commit_all(repo_a.path(), "init")?;
568 git_test::git_run(repo_a.path(), &["push", "-u", "origin", "HEAD"])?;
569
570 let repo_b = TempDir::new()?;
571 git_test::clone_repo(remote.path(), repo_b.path())?;
572 git_test::configure_user(repo_b.path())?;
573 std::fs::write(repo_b.path().join("remote.txt"), "remote\n")?;
574 git_test::commit_all(repo_b.path(), "remote update")?;
575 git_test::git_run(repo_b.path(), &["push"])?;
576
577 std::fs::write(repo_a.path().join("local.txt"), "local\n")?;
578 git_test::commit_all(repo_a.path(), "local update")?;
579
580 push_upstream_with_rebase(repo_a.path())?;
581
582 let counts = git_test::git_output(
583 repo_a.path(),
584 &["rev-list", "--left-right", "--count", "@{u}...HEAD"],
585 )?;
586 let parts: Vec<&str> = counts.split_whitespace().collect();
587 assert_eq!(parts, vec!["0", "0"]);
588
589 Ok(())
590 }
591
592 #[test]
593 fn push_upstream_with_rebase_sets_upstream_when_remote_branch_exists_and_local_is_behind()
594 -> anyhow::Result<()> {
595 let remote = TempDir::new()?;
596 git_test::init_bare_repo(remote.path())?;
597
598 let seed = TempDir::new()?;
599 git_test::init_repo(seed.path())?;
600 git_test::add_remote(seed.path(), "origin", remote.path())?;
601 std::fs::write(seed.path().join("base.txt"), "base\n")?;
602 git_test::commit_all(seed.path(), "init")?;
603 git_test::git_run(seed.path(), &["push", "-u", "origin", "HEAD"])?;
604 git_test::git_run(seed.path(), &["checkout", "-b", "ralph/RQ-0940"])?;
605 std::fs::write(seed.path().join("task.txt"), "remote-only\n")?;
606 git_test::commit_all(seed.path(), "remote task")?;
607 git_test::git_run(seed.path(), &["push", "-u", "origin", "ralph/RQ-0940"])?;
608
609 let local = TempDir::new()?;
610 git_test::clone_repo(remote.path(), local.path())?;
611 git_test::configure_user(local.path())?;
612 git_test::git_run(
613 local.path(),
614 &[
615 "checkout",
616 "--no-track",
617 "-b",
618 "ralph/RQ-0940",
619 "origin/main",
620 ],
621 )?;
622
623 push_upstream_with_rebase(local.path())?;
625
626 let upstream = git_test::git_output(
627 local.path(),
628 &["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
629 )?;
630 assert_eq!(upstream, "origin/ralph/RQ-0940");
631
632 Ok(())
633 }
634}