Skip to main content

ag_git/
client.rs

1//! Git client trait boundary and production adapter implementation.
2
3use std::future::Future;
4use std::path::PathBuf;
5use std::pin::Pin;
6
7use super::error::GitError;
8use super::merge::SquashMergeOutcome;
9use super::rebase::{InProgressGitOperation, RebaseStepResult};
10#[cfg(test)]
11use super::sync;
12use super::sync::{BranchTrackingMap, PullRebaseResult, SingleCommitMessageStrategy};
13use super::{
14    abort_rebase, branch_tracking_statuses, commit_all, commit_all_preserving_single_commit,
15    create_worktree, current_upstream_reference, delete_branch, detect_git_info, diff,
16    fetch_remote, find_git_repo_root, get_ahead_behind, get_ref_ahead_behind, has_commits_since,
17    has_unmerged_paths, head_commit_message, head_hash, head_short_hash, in_progress_operation,
18    is_rebase_in_progress, is_worktree_clean, list_conflicted_files, list_local_commit_titles,
19    list_staged_conflict_marker_files, list_upstream_commit_titles, main_checkout_working_tree,
20    main_repo_root, pull_rebase, push_current_branch, push_current_branch_to_remote_branch, rebase,
21    rebase_continue, rebase_onto_start, rebase_start, ref_hash, remote_branch_exists,
22    remove_worktree, repo_url, squash_merge, squash_merge_diff, stage_all, tracked_worktree_status,
23    worktree_status,
24};
25
26/// Boxed async result used by [`GitClient`] trait methods.
27pub type GitFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
28
29/// Low-level async git boundary used by app orchestration code.
30///
31/// Production uses [`RealGitClient`], while tests can inject
32/// `MockGitClient` to avoid flaky multi-command process workflows.
33#[cfg_attr(any(test, feature = "test-utils"), mockall::automock)]
34pub trait GitClient: Send + Sync {
35    /// Detects the current branch name for the repository containing `dir`.
36    ///
37    /// Returns `None` when `dir` is not inside a git repository or no branch
38    /// can be determined.
39    fn detect_git_info(&self, dir: PathBuf) -> GitFuture<Option<String>>;
40
41    /// Resolves the repository root directory that contains `dir`.
42    ///
43    /// Returns `None` when `dir` is not in a git repository.
44    fn find_git_repo_root(&self, dir: PathBuf) -> GitFuture<Option<PathBuf>>;
45
46    /// Creates a new worktree at `worktree_path` on `branch_name` from
47    /// `start_ref` inside `repo_path`.
48    ///
49    /// # Errors
50    /// Returns an error when any underlying git command fails, when branches
51    /// cannot be resolved, or when the target worktree path cannot be created.
52    fn create_worktree(
53        &self,
54        repo_path: PathBuf,
55        worktree_path: PathBuf,
56        branch_name: String,
57        start_ref: String,
58    ) -> GitFuture<Result<(), GitError>>;
59
60    /// Removes the existing worktree at `worktree_path`.
61    ///
62    /// # Errors
63    /// Returns an error when the path is not a registered worktree or git
64    /// cannot remove it.
65    fn remove_worktree(&self, worktree_path: PathBuf) -> GitFuture<Result<(), GitError>>;
66
67    /// Returns the staged squash-merge preview diff from `source_branch` into
68    /// `target_branch` within `repo_path`.
69    ///
70    /// # Errors
71    /// Returns an error when either branch is missing or diff generation fails.
72    fn squash_merge_diff(
73        &self,
74        repo_path: PathBuf,
75        source_branch: String,
76        target_branch: String,
77    ) -> GitFuture<Result<String, GitError>>;
78
79    /// Performs a squash merge of `source_branch` into `target_branch` inside
80    /// `repo_path` using `commit_message`.
81    ///
82    /// # Errors
83    /// Returns an error when checkout, merge, or commit operations fail.
84    fn squash_merge(
85        &self,
86        repo_path: PathBuf,
87        source_branch: String,
88        target_branch: String,
89        commit_message: String,
90    ) -> GitFuture<Result<SquashMergeOutcome, GitError>>;
91
92    /// Runs `git rebase <target_branch>` in `repo_path`.
93    ///
94    /// # Errors
95    /// Returns an error when rebase setup fails or git reports a fatal error.
96    fn rebase(&self, repo_path: PathBuf, target_branch: String) -> GitFuture<Result<(), GitError>>;
97
98    /// Starts a rebase onto `target_branch` and reports whether it completed
99    /// immediately or stopped for conflicts.
100    ///
101    /// # Errors
102    /// Returns an error when rebase cannot be started.
103    fn rebase_start(
104        &self,
105        repo_path: PathBuf,
106        target_branch: String,
107    ) -> GitFuture<Result<RebaseStepResult, GitError>>;
108
109    /// Starts `git rebase --onto new_base old_base` in `repo_path`.
110    ///
111    /// # Errors
112    /// Returns an error when rebase cannot be started.
113    fn rebase_onto_start(
114        &self,
115        repo_path: PathBuf,
116        new_base: String,
117        old_base: String,
118    ) -> GitFuture<Result<RebaseStepResult, GitError>>;
119
120    /// Continues an in-progress rebase in `repo_path`.
121    ///
122    /// # Errors
123    /// Returns an error when there is no rebase to continue or git fails.
124    fn rebase_continue(&self, repo_path: PathBuf) -> GitFuture<Result<RebaseStepResult, GitError>>;
125
126    /// Aborts an in-progress rebase in `repo_path`.
127    ///
128    /// # Errors
129    /// Returns an error when abort fails or no rebase state exists.
130    fn abort_rebase(&self, repo_path: PathBuf) -> GitFuture<Result<(), GitError>>;
131
132    /// Returns whether rebase metadata exists in `repo_path`.
133    ///
134    /// # Errors
135    /// Returns an error when git state cannot be inspected.
136    fn is_rebase_in_progress(&self, repo_path: PathBuf) -> GitFuture<Result<bool, GitError>>;
137
138    /// Returns detected in-progress git operation metadata in `repo_path`.
139    ///
140    /// # Errors
141    /// Returns an error when git state cannot be inspected.
142    fn in_progress_operation(
143        &self,
144        repo_path: PathBuf,
145    ) -> GitFuture<Result<Option<InProgressGitOperation>, GitError>>;
146
147    /// Returns whether unmerged index entries remain in `repo_path`.
148    ///
149    /// # Errors
150    /// Returns an error when index status cannot be read.
151    fn has_unmerged_paths(&self, repo_path: PathBuf) -> GitFuture<Result<bool, GitError>>;
152
153    /// Filters `paths` to files that are staged and still contain conflict
154    /// markers in `repo_path`.
155    ///
156    /// # Errors
157    /// Returns an error when staged content cannot be inspected.
158    fn list_staged_conflict_marker_files(
159        &self,
160        repo_path: PathBuf,
161        paths: Vec<String>,
162    ) -> GitFuture<Result<Vec<String>, GitError>>;
163
164    /// Lists files currently marked conflicted in the index for `repo_path`.
165    ///
166    /// # Errors
167    /// Returns an error when conflict state cannot be queried.
168    fn list_conflicted_files(&self, repo_path: PathBuf)
169    -> GitFuture<Result<Vec<String>, GitError>>;
170
171    /// Stages and commits all changes in `repo_path` using `message`.
172    ///
173    /// Set `no_verify` to skip commit hooks.
174    ///
175    /// # Errors
176    /// Returns an error when staging or commit creation fails.
177    fn commit_all(
178        &self,
179        repo_path: PathBuf,
180        message: String,
181        no_verify: bool,
182    ) -> GitFuture<Result<(), GitError>>;
183
184    /// Commits all changes while preserving one evolving session commit in
185    /// `repo_path`.
186    ///
187    /// Uses `commit_message` for new or amended commit content. Set
188    /// `no_verify` to skip commit hooks.
189    ///
190    /// # Errors
191    /// Returns an error when staging, amend/create, or branch inspection fails.
192    fn commit_all_preserving_single_commit(
193        &self,
194        repo_path: PathBuf,
195        base_branch: String,
196        commit_message: String,
197        message_strategy: SingleCommitMessageStrategy,
198        no_verify: bool,
199    ) -> GitFuture<Result<(), GitError>>;
200
201    /// Stages all tracked and untracked changes in `repo_path`.
202    ///
203    /// # Errors
204    /// Returns an error when `git add` fails.
205    fn stage_all(&self, repo_path: PathBuf) -> GitFuture<Result<(), GitError>>;
206
207    /// Returns the short `HEAD` hash for `repo_path`.
208    ///
209    /// # Errors
210    /// Returns an error when `HEAD` cannot be resolved.
211    fn head_short_hash(&self, repo_path: PathBuf) -> GitFuture<Result<String, GitError>>;
212
213    /// Returns the full `HEAD` hash for `repo_path`.
214    ///
215    /// # Errors
216    /// Returns an error when `HEAD` cannot be resolved.
217    fn head_hash(&self, repo_path: PathBuf) -> GitFuture<Result<String, GitError>>;
218
219    /// Returns the full commit hash for a branch, tag, or commit-ish ref.
220    ///
221    /// # Errors
222    /// Returns an error when the reference cannot be resolved to a commit.
223    fn ref_hash(
224        &self,
225        repo_path: PathBuf,
226        reference: String,
227    ) -> GitFuture<Result<String, GitError>>;
228
229    /// Returns the full `HEAD` commit message for `repo_path`, or `None` when
230    /// no commits exist.
231    ///
232    /// # Errors
233    /// Returns an error when `HEAD` cannot be inspected.
234    fn head_commit_message(
235        &self,
236        repo_path: PathBuf,
237    ) -> GitFuture<Result<Option<String>, GitError>>;
238
239    /// Deletes `branch_name` in `repo_path`.
240    ///
241    /// # Errors
242    /// Returns an error when the branch is missing, checked out, or deletion
243    /// is rejected by git.
244    fn delete_branch(
245        &self,
246        repo_path: PathBuf,
247        branch_name: String,
248    ) -> GitFuture<Result<(), GitError>>;
249
250    /// Returns a patch diff from `base_branch` to current `HEAD` in
251    /// `repo_path`.
252    ///
253    /// # Errors
254    /// Returns an error when refs are invalid or diff generation fails.
255    fn diff(&self, repo_path: PathBuf, base_branch: String) -> GitFuture<Result<String, GitError>>;
256
257    /// Returns whether the worktree in `repo_path` has no local changes.
258    ///
259    /// # Errors
260    /// Returns an error when status inspection fails.
261    fn is_worktree_clean(&self, repo_path: PathBuf) -> GitFuture<Result<bool, GitError>>;
262
263    /// Returns raw porcelain status for the worktree in `repo_path`.
264    ///
265    /// # Errors
266    /// Returns an error when status inspection fails.
267    fn worktree_status(&self, repo_path: PathBuf) -> GitFuture<Result<String, GitError>>;
268
269    /// Returns raw porcelain status for tracked files in `repo_path`.
270    ///
271    /// # Errors
272    /// Returns an error when tracked-file status inspection fails.
273    fn tracked_worktree_status(&self, repo_path: PathBuf) -> GitFuture<Result<String, GitError>>;
274
275    /// Returns whether `HEAD` contains commits not reachable from
276    /// `base_branch`.
277    ///
278    /// # Errors
279    /// Returns an error when commit ancestry cannot be queried.
280    fn has_commits_since(
281        &self,
282        repo_path: PathBuf,
283        base_branch: String,
284    ) -> GitFuture<Result<bool, GitError>>;
285
286    /// Performs a `pull --rebase` in `repo_path`.
287    ///
288    /// # Errors
289    /// Returns an error when pull/rebase setup fails.
290    fn pull_rebase(&self, repo_path: PathBuf) -> GitFuture<Result<PullRebaseResult, GitError>>;
291
292    /// Pushes the currently checked out branch for `repo_path` with
293    /// `--force-with-lease` and returns the configured upstream reference
294    /// after the successful push.
295    ///
296    /// # Errors
297    /// Returns an error when remote push fails.
298    fn push_current_branch(&self, repo_path: PathBuf) -> GitFuture<Result<String, GitError>>;
299
300    /// Pushes the current branch for `repo_path` to one explicit remote branch
301    /// name with `--force-with-lease` and returns the configured upstream
302    /// reference after the push.
303    ///
304    /// # Errors
305    /// Returns an error when remote push fails.
306    fn push_current_branch_to_remote_branch(
307        &self,
308        repo_path: PathBuf,
309        remote_branch_name: String,
310    ) -> GitFuture<Result<String, GitError>>;
311
312    /// Checks whether `remote_branch_name` already exists on the remote for
313    /// the repository at `repo_path`.
314    ///
315    /// # Errors
316    /// Returns an error when the remote lookup command fails.
317    fn remote_branch_exists(
318        &self,
319        repo_path: PathBuf,
320        remote_branch_name: String,
321    ) -> GitFuture<Result<bool, GitError>>;
322
323    /// Resolves the current upstream reference for `repo_path`.
324    ///
325    /// # Errors
326    /// Returns an error when upstream tracking information is unavailable.
327    fn current_upstream_reference(&self, repo_path: PathBuf)
328    -> GitFuture<Result<String, GitError>>;
329
330    /// Fetches remote refs for `repo_path`.
331    ///
332    /// # Errors
333    /// Returns an error when fetch fails.
334    fn fetch_remote(&self, repo_path: PathBuf) -> GitFuture<Result<(), GitError>>;
335
336    /// Reads ahead/behind commit counts for `repo_path`.
337    ///
338    /// # Errors
339    /// Returns an error when upstream tracking information is unavailable.
340    fn get_ahead_behind(&self, repo_path: PathBuf) -> GitFuture<Result<(u32, u32), GitError>>;
341
342    /// Reads ahead/behind commit counts between two explicit refs.
343    ///
344    /// The returned tuple is `(ahead, behind)` from the perspective of
345    /// `left_ref`.
346    ///
347    /// # Errors
348    /// Returns an error when either ref cannot be resolved.
349    fn get_ref_ahead_behind(
350        &self,
351        repo_path: PathBuf,
352        left_ref: String,
353        right_ref: String,
354    ) -> GitFuture<Result<(u32, u32), GitError>>;
355
356    /// Reads ahead/behind snapshots for all local branches that track an
357    /// upstream.
358    ///
359    /// The returned map is keyed by local branch name and stores `None` when
360    /// a branch has no tracked upstream or its upstream is gone.
361    ///
362    /// # Errors
363    /// Returns an error when branch tracking information cannot be queried.
364    fn branch_tracking_statuses(
365        &self,
366        repo_path: PathBuf,
367    ) -> GitFuture<Result<BranchTrackingMap, GitError>>;
368
369    /// Returns commit subjects that exist in upstream but not in local
370    /// `HEAD`.
371    ///
372    /// # Errors
373    /// Returns an error when upstream tracking data or commit history cannot be
374    /// read.
375    fn list_upstream_commit_titles(
376        &self,
377        repo_path: PathBuf,
378    ) -> GitFuture<Result<Vec<String>, GitError>>;
379
380    /// Returns commit subjects that exist in local `HEAD` but not in upstream.
381    ///
382    /// # Errors
383    /// Returns an error when upstream tracking data or commit history cannot be
384    /// read.
385    fn list_local_commit_titles(
386        &self,
387        repo_path: PathBuf,
388    ) -> GitFuture<Result<Vec<String>, GitError>>;
389
390    /// Reads the configured origin URL for `repo_path`.
391    ///
392    /// # Errors
393    /// Returns an error when origin is missing or cannot be resolved.
394    fn repo_url(&self, repo_path: PathBuf) -> GitFuture<Result<String, GitError>>;
395
396    /// Resolves the main repository root for a repository or worktree path.
397    ///
398    /// # Errors
399    /// Returns an error when the main repository cannot be resolved.
400    fn main_repo_root(&self, repo_path: PathBuf) -> GitFuture<Result<PathBuf, GitError>>;
401
402    /// Resolves the main working checkout for a repository or worktree path.
403    ///
404    /// Returns `None` when the shared repository is bare, because a bare
405    /// repository has no main working checkout.
406    ///
407    /// # Errors
408    /// Returns an error when the shared repository cannot be resolved.
409    fn main_checkout_working_tree(
410        &self,
411        repo_path: PathBuf,
412    ) -> GitFuture<Result<Option<PathBuf>, GitError>>;
413}
414
415/// Production [`GitClient`] implementation backed by real git commands.
416pub struct RealGitClient;
417
418impl GitClient for RealGitClient {
419    fn detect_git_info(&self, dir: PathBuf) -> GitFuture<Option<String>> {
420        Box::pin(async move { detect_git_info(dir).await })
421    }
422
423    fn find_git_repo_root(&self, dir: PathBuf) -> GitFuture<Option<PathBuf>> {
424        Box::pin(async move { find_git_repo_root(dir).await })
425    }
426
427    fn create_worktree(
428        &self,
429        repo_path: PathBuf,
430        worktree_path: PathBuf,
431        branch_name: String,
432        start_ref: String,
433    ) -> GitFuture<Result<(), GitError>> {
434        Box::pin(
435            async move { create_worktree(repo_path, worktree_path, branch_name, start_ref).await },
436        )
437    }
438
439    fn remove_worktree(&self, worktree_path: PathBuf) -> GitFuture<Result<(), GitError>> {
440        Box::pin(async move { remove_worktree(worktree_path).await })
441    }
442
443    fn squash_merge_diff(
444        &self,
445        repo_path: PathBuf,
446        source_branch: String,
447        target_branch: String,
448    ) -> GitFuture<Result<String, GitError>> {
449        Box::pin(async move { squash_merge_diff(repo_path, source_branch, target_branch).await })
450    }
451
452    fn squash_merge(
453        &self,
454        repo_path: PathBuf,
455        source_branch: String,
456        target_branch: String,
457        commit_message: String,
458    ) -> GitFuture<Result<SquashMergeOutcome, GitError>> {
459        Box::pin(async move {
460            squash_merge(repo_path, source_branch, target_branch, commit_message).await
461        })
462    }
463
464    fn rebase(&self, repo_path: PathBuf, target_branch: String) -> GitFuture<Result<(), GitError>> {
465        Box::pin(async move { rebase::rebase(repo_path, target_branch).await })
466    }
467
468    fn rebase_start(
469        &self,
470        repo_path: PathBuf,
471        target_branch: String,
472    ) -> GitFuture<Result<RebaseStepResult, GitError>> {
473        Box::pin(async move { rebase_start(repo_path, target_branch).await })
474    }
475
476    fn rebase_onto_start(
477        &self,
478        repo_path: PathBuf,
479        new_base: String,
480        old_base: String,
481    ) -> GitFuture<Result<RebaseStepResult, GitError>> {
482        Box::pin(async move { rebase_onto_start(repo_path, new_base, old_base).await })
483    }
484
485    fn rebase_continue(&self, repo_path: PathBuf) -> GitFuture<Result<RebaseStepResult, GitError>> {
486        Box::pin(async move { rebase_continue(repo_path).await })
487    }
488
489    fn abort_rebase(&self, repo_path: PathBuf) -> GitFuture<Result<(), GitError>> {
490        Box::pin(async move { abort_rebase(repo_path).await })
491    }
492
493    fn is_rebase_in_progress(&self, repo_path: PathBuf) -> GitFuture<Result<bool, GitError>> {
494        Box::pin(async move { is_rebase_in_progress(repo_path).await })
495    }
496
497    fn in_progress_operation(
498        &self,
499        repo_path: PathBuf,
500    ) -> GitFuture<Result<Option<InProgressGitOperation>, GitError>> {
501        Box::pin(async move { in_progress_operation(repo_path).await })
502    }
503
504    fn has_unmerged_paths(&self, repo_path: PathBuf) -> GitFuture<Result<bool, GitError>> {
505        Box::pin(async move { has_unmerged_paths(repo_path).await })
506    }
507
508    fn list_staged_conflict_marker_files(
509        &self,
510        repo_path: PathBuf,
511        paths: Vec<String>,
512    ) -> GitFuture<Result<Vec<String>, GitError>> {
513        Box::pin(async move { list_staged_conflict_marker_files(repo_path, paths).await })
514    }
515
516    fn list_conflicted_files(
517        &self,
518        repo_path: PathBuf,
519    ) -> GitFuture<Result<Vec<String>, GitError>> {
520        Box::pin(async move { list_conflicted_files(repo_path).await })
521    }
522
523    fn commit_all(
524        &self,
525        repo_path: PathBuf,
526        message: String,
527        no_verify: bool,
528    ) -> GitFuture<Result<(), GitError>> {
529        Box::pin(async move { commit_all(repo_path, message, no_verify).await })
530    }
531
532    fn commit_all_preserving_single_commit(
533        &self,
534        repo_path: PathBuf,
535        base_branch: String,
536        commit_message: String,
537        message_strategy: SingleCommitMessageStrategy,
538        no_verify: bool,
539    ) -> GitFuture<Result<(), GitError>> {
540        Box::pin(async move {
541            commit_all_preserving_single_commit(
542                repo_path,
543                base_branch,
544                commit_message,
545                message_strategy,
546                no_verify,
547            )
548            .await
549        })
550    }
551
552    fn stage_all(&self, repo_path: PathBuf) -> GitFuture<Result<(), GitError>> {
553        Box::pin(async move { stage_all(repo_path).await })
554    }
555
556    fn head_short_hash(&self, repo_path: PathBuf) -> GitFuture<Result<String, GitError>> {
557        Box::pin(async move { head_short_hash(repo_path).await })
558    }
559
560    fn head_hash(&self, repo_path: PathBuf) -> GitFuture<Result<String, GitError>> {
561        Box::pin(async move { head_hash(repo_path).await })
562    }
563
564    fn ref_hash(
565        &self,
566        repo_path: PathBuf,
567        reference: String,
568    ) -> GitFuture<Result<String, GitError>> {
569        Box::pin(async move { ref_hash(repo_path, reference).await })
570    }
571
572    fn head_commit_message(
573        &self,
574        repo_path: PathBuf,
575    ) -> GitFuture<Result<Option<String>, GitError>> {
576        Box::pin(async move { head_commit_message(repo_path).await })
577    }
578
579    fn delete_branch(
580        &self,
581        repo_path: PathBuf,
582        branch_name: String,
583    ) -> GitFuture<Result<(), GitError>> {
584        Box::pin(async move { delete_branch(repo_path, branch_name).await })
585    }
586
587    fn diff(&self, repo_path: PathBuf, base_branch: String) -> GitFuture<Result<String, GitError>> {
588        Box::pin(async move { diff(repo_path, base_branch).await })
589    }
590
591    fn is_worktree_clean(&self, repo_path: PathBuf) -> GitFuture<Result<bool, GitError>> {
592        Box::pin(async move { is_worktree_clean(repo_path).await })
593    }
594
595    fn worktree_status(&self, repo_path: PathBuf) -> GitFuture<Result<String, GitError>> {
596        Box::pin(async move { worktree_status(repo_path).await })
597    }
598
599    fn tracked_worktree_status(&self, repo_path: PathBuf) -> GitFuture<Result<String, GitError>> {
600        Box::pin(async move { tracked_worktree_status(repo_path).await })
601    }
602
603    fn has_commits_since(
604        &self,
605        repo_path: PathBuf,
606        base_branch: String,
607    ) -> GitFuture<Result<bool, GitError>> {
608        Box::pin(async move { has_commits_since(repo_path, base_branch).await })
609    }
610
611    fn pull_rebase(&self, repo_path: PathBuf) -> GitFuture<Result<PullRebaseResult, GitError>> {
612        Box::pin(async move { pull_rebase(repo_path).await })
613    }
614
615    fn push_current_branch(&self, repo_path: PathBuf) -> GitFuture<Result<String, GitError>> {
616        Box::pin(async move { push_current_branch(repo_path).await })
617    }
618
619    fn push_current_branch_to_remote_branch(
620        &self,
621        repo_path: PathBuf,
622        remote_branch_name: String,
623    ) -> GitFuture<Result<String, GitError>> {
624        Box::pin(async move {
625            push_current_branch_to_remote_branch(repo_path, remote_branch_name).await
626        })
627    }
628
629    fn remote_branch_exists(
630        &self,
631        repo_path: PathBuf,
632        remote_branch_name: String,
633    ) -> GitFuture<Result<bool, GitError>> {
634        Box::pin(async move { remote_branch_exists(repo_path, remote_branch_name).await })
635    }
636
637    fn current_upstream_reference(
638        &self,
639        repo_path: PathBuf,
640    ) -> GitFuture<Result<String, GitError>> {
641        Box::pin(async move { current_upstream_reference(repo_path).await })
642    }
643
644    fn fetch_remote(&self, repo_path: PathBuf) -> GitFuture<Result<(), GitError>> {
645        Box::pin(async move { fetch_remote(repo_path).await })
646    }
647
648    fn get_ahead_behind(&self, repo_path: PathBuf) -> GitFuture<Result<(u32, u32), GitError>> {
649        Box::pin(async move { get_ahead_behind(repo_path).await })
650    }
651
652    fn get_ref_ahead_behind(
653        &self,
654        repo_path: PathBuf,
655        left_ref: String,
656        right_ref: String,
657    ) -> GitFuture<Result<(u32, u32), GitError>> {
658        Box::pin(async move { get_ref_ahead_behind(repo_path, left_ref, right_ref).await })
659    }
660
661    fn branch_tracking_statuses(
662        &self,
663        repo_path: PathBuf,
664    ) -> GitFuture<Result<BranchTrackingMap, GitError>> {
665        Box::pin(async move { branch_tracking_statuses(repo_path).await })
666    }
667
668    fn list_upstream_commit_titles(
669        &self,
670        repo_path: PathBuf,
671    ) -> GitFuture<Result<Vec<String>, GitError>> {
672        Box::pin(async move { list_upstream_commit_titles(repo_path).await })
673    }
674
675    fn list_local_commit_titles(
676        &self,
677        repo_path: PathBuf,
678    ) -> GitFuture<Result<Vec<String>, GitError>> {
679        Box::pin(async move { list_local_commit_titles(repo_path).await })
680    }
681
682    fn repo_url(&self, repo_path: PathBuf) -> GitFuture<Result<String, GitError>> {
683        Box::pin(async move { repo_url(repo_path).await })
684    }
685
686    fn main_repo_root(&self, repo_path: PathBuf) -> GitFuture<Result<PathBuf, GitError>> {
687        Box::pin(async move { main_repo_root(repo_path).await })
688    }
689
690    fn main_checkout_working_tree(
691        &self,
692        repo_path: PathBuf,
693    ) -> GitFuture<Result<Option<PathBuf>, GitError>> {
694        Box::pin(async move { main_checkout_working_tree(repo_path).await })
695    }
696}
697
698#[cfg(test)]
699mod tests {
700    use std::path::{Path, PathBuf};
701    use std::process::Command;
702    use std::time::Duration;
703    use std::{fs, thread};
704
705    use tempfile::tempdir;
706
707    use super::*;
708
709    /// Canonicalizes a test path for stable comparisons across symlinked
710    /// temporary directory roots (for example `/var` vs `/private/var`).
711    fn canonicalize_test_path(path: &Path) -> PathBuf {
712        fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
713    }
714
715    fn run_git_command(repo_path: &Path, args: &[&str]) {
716        let output = Command::new("git")
717            .args(args)
718            .current_dir(repo_path)
719            .output()
720            .expect("failed to run git command");
721
722        assert!(
723            output.status.success(),
724            "git command {:?} failed: {}",
725            args,
726            String::from_utf8_lossy(&output.stderr)
727        );
728    }
729
730    fn run_git_command_stdout(repo_path: &Path, args: &[&str]) -> String {
731        let output = Command::new("git")
732            .args(args)
733            .current_dir(repo_path)
734            .output()
735            .expect("failed to run git command");
736
737        assert!(
738            output.status.success(),
739            "git command {:?} failed: {}",
740            args,
741            String::from_utf8_lossy(&output.stderr)
742        );
743
744        String::from_utf8_lossy(&output.stdout).trim().to_string()
745    }
746
747    fn setup_test_git_repo(repo_path: &Path) {
748        run_git_command(repo_path, &["init", "-b", "main"]);
749        run_git_command(repo_path, &["config", "user.name", "Test User"]);
750        run_git_command(repo_path, &["config", "user.email", "test@example.com"]);
751
752        fs::write(repo_path.join("README.md"), "test repo").expect("failed to write file");
753        run_git_command(repo_path, &["add", "README.md"]);
754        run_git_command(repo_path, &["commit", "-m", "Initial commit"]);
755    }
756
757    #[tokio::test]
758    async fn test_squash_merge_returns_committed_when_changes_exist() {
759        // Arrange
760        let dir = tempdir().expect("failed to create temp dir");
761        setup_test_git_repo(dir.path());
762        run_git_command(dir.path(), &["checkout", "-b", "feature-branch"]);
763        fs::write(dir.path().join("feature.txt"), "feature content").expect("failed to write file");
764        run_git_command(dir.path(), &["add", "feature.txt"]);
765        run_git_command(dir.path(), &["commit", "-m", "Add feature"]);
766        run_git_command(dir.path(), &["checkout", "main"]);
767
768        // Act
769        let result = squash_merge(
770            dir.path().to_path_buf(),
771            "feature-branch".to_string(),
772            "main".to_string(),
773            "Squash merge feature".to_string(),
774        )
775        .await;
776
777        // Assert
778        assert_eq!(
779            result.expect("squash merge should succeed"),
780            SquashMergeOutcome::Committed,
781        );
782    }
783
784    #[tokio::test]
785    async fn test_squash_merge_returns_already_present_when_changes_exist_in_target() {
786        // Arrange
787        let dir = tempdir().expect("failed to create temp dir");
788        setup_test_git_repo(dir.path());
789        run_git_command(dir.path(), &["checkout", "-b", "session-branch"]);
790        fs::write(dir.path().join("session.txt"), "session change").expect("failed to write file");
791        run_git_command(dir.path(), &["add", "session.txt"]);
792        run_git_command(dir.path(), &["commit", "-m", "Session change"]);
793        run_git_command(dir.path(), &["checkout", "main"]);
794        fs::write(dir.path().join("session.txt"), "session change").expect("failed to write file");
795        run_git_command(dir.path(), &["add", "session.txt"]);
796        run_git_command(dir.path(), &["commit", "-m", "Apply same change on main"]);
797
798        // Act
799        let result = squash_merge(
800            dir.path().to_path_buf(),
801            "session-branch".to_string(),
802            "main".to_string(),
803            "Merge session".to_string(),
804        )
805        .await;
806
807        // Assert
808        assert_eq!(
809            result.expect("squash merge should succeed"),
810            SquashMergeOutcome::AlreadyPresentInTarget,
811        );
812    }
813
814    #[tokio::test]
815    async fn test_commit_all_preserving_single_commit_creates_first_commit() {
816        // Arrange
817        let dir = tempdir().expect("failed to create temp dir");
818        setup_test_git_repo(dir.path());
819        run_git_command(dir.path(), &["checkout", "-b", "session-branch"]);
820        let commit_message = "Session commit".to_string();
821        fs::write(dir.path().join("work.txt"), "first change").expect("failed to write file");
822
823        // Act
824        let result = commit_all_preserving_single_commit(
825            dir.path().to_path_buf(),
826            "main".to_string(),
827            commit_message.clone(),
828            SingleCommitMessageStrategy::Replace,
829            false,
830        )
831        .await;
832        let commit_count = run_git_command_stdout(dir.path(), &["rev-list", "--count", "HEAD"]);
833        let head_message = run_git_command_stdout(dir.path(), &["log", "-1", "--pretty=%B"]);
834
835        // Assert
836        assert!(
837            result.is_ok(),
838            "commit_all_preserving_single_commit should succeed: {result:?}"
839        );
840        assert_eq!(commit_count, "2");
841        assert_eq!(head_message, commit_message);
842    }
843
844    #[tokio::test]
845    async fn test_commit_all_preserving_single_commit_amends_existing_session_commit() {
846        // Arrange
847        let dir = tempdir().expect("failed to create temp dir");
848        setup_test_git_repo(dir.path());
849        run_git_command(dir.path(), &["checkout", "-b", "session-branch"]);
850        let commit_message = "Session commit".to_string();
851        fs::write(dir.path().join("work.txt"), "first change").expect("failed to write file");
852        commit_all_preserving_single_commit(
853            dir.path().to_path_buf(),
854            "main".to_string(),
855            commit_message.clone(),
856            SingleCommitMessageStrategy::Replace,
857            false,
858        )
859        .await
860        .expect("failed to create first session commit");
861        let first_hash = run_git_command_stdout(dir.path(), &["rev-parse", "HEAD"]);
862        let first_count = run_git_command_stdout(dir.path(), &["rev-list", "--count", "HEAD"]);
863
864        // Act
865        fs::write(dir.path().join("work.txt"), "second change").expect("failed to write file");
866        let result = commit_all_preserving_single_commit(
867            dir.path().to_path_buf(),
868            "main".to_string(),
869            commit_message.clone(),
870            SingleCommitMessageStrategy::Replace,
871            false,
872        )
873        .await;
874        let second_hash = run_git_command_stdout(dir.path(), &["rev-parse", "HEAD"]);
875        let second_count = run_git_command_stdout(dir.path(), &["rev-list", "--count", "HEAD"]);
876
877        // Assert
878        assert!(result.is_ok(), "amend commit should succeed: {result:?}");
879        assert_ne!(first_hash, second_hash);
880        assert_eq!(first_count, second_count);
881    }
882
883    #[tokio::test]
884    async fn test_commit_all_preserving_single_commit_replaces_amended_message() {
885        // Arrange
886        let dir = tempdir().expect("failed to create temp dir");
887        setup_test_git_repo(dir.path());
888        run_git_command(dir.path(), &["checkout", "-b", "session-branch"]);
889        fs::write(dir.path().join("work.txt"), "first change").expect("failed to write file");
890        commit_all_preserving_single_commit(
891            dir.path().to_path_buf(),
892            "main".to_string(),
893            "First session message".to_string(),
894            SingleCommitMessageStrategy::Replace,
895            false,
896        )
897        .await
898        .expect("failed to create first session commit");
899
900        // Act
901        fs::write(dir.path().join("work.txt"), "second change").expect("failed to write file");
902        let result = commit_all_preserving_single_commit(
903            dir.path().to_path_buf(),
904            "main".to_string(),
905            "Refined session message".to_string(),
906            SingleCommitMessageStrategy::Replace,
907            false,
908        )
909        .await;
910        let head_message = run_git_command_stdout(dir.path(), &["log", "-1", "--pretty=%B"]);
911
912        // Assert
913        assert!(
914            result.is_ok(),
915            "replace amended message should succeed: {result:?}"
916        );
917        assert_eq!(head_message, "Refined session message");
918    }
919
920    #[tokio::test]
921    async fn test_commit_all_preserving_single_commit_retries_index_lock_and_succeeds() {
922        // Arrange
923        let dir = tempdir().expect("failed to create temp dir");
924        setup_test_git_repo(dir.path());
925        run_git_command(dir.path(), &["checkout", "-b", "session-branch"]);
926        let commit_message = "Session commit".to_string();
927        fs::write(dir.path().join("work.txt"), "locked change").expect("failed to write file");
928        let index_lock_path = dir.path().join(".git").join("index.lock");
929        fs::write(&index_lock_path, "stale lock").expect("failed to write lock file");
930        let lock_cleanup = thread::spawn(move || {
931            thread::sleep(Duration::from_millis(250));
932            let _ = fs::remove_file(index_lock_path);
933        });
934
935        // Act
936        let result = commit_all_preserving_single_commit(
937            dir.path().to_path_buf(),
938            "main".to_string(),
939            commit_message.clone(),
940            SingleCommitMessageStrategy::Replace,
941            false,
942        )
943        .await;
944        lock_cleanup
945            .join()
946            .expect("failed to join lock cleanup thread");
947        let head_message = run_git_command_stdout(dir.path(), &["log", "-1", "--pretty=%B"]);
948
949        // Assert
950        assert!(
951            result.is_ok(),
952            "retry with index lock should succeed: {result:?}"
953        );
954        assert_eq!(head_message, commit_message);
955    }
956
957    #[tokio::test]
958    async fn test_diff_hides_leading_squash_merged_commit_for_non_rebased_session() {
959        // Arrange
960        let dir = tempdir().expect("failed to create temp dir");
961        setup_test_git_repo(dir.path());
962        run_git_command(dir.path(), &["checkout", "-b", "session-branch"]);
963        fs::write(dir.path().join("merged.txt"), "already merged change")
964            .expect("failed to write merged file");
965        run_git_command(dir.path(), &["add", "merged.txt"]);
966        run_git_command(dir.path(), &["commit", "-m", "Session change"]);
967        run_git_command(dir.path(), &["checkout", "main"]);
968        run_git_command(dir.path(), &["merge", "--squash", "session-branch"]);
969        run_git_command(dir.path(), &["commit", "-m", "Squash merge session change"]);
970        run_git_command(dir.path(), &["checkout", "session-branch"]);
971
972        // Act
973        let diff_output = diff(dir.path().to_path_buf(), "main".to_string())
974            .await
975            .expect("failed to load diff");
976
977        // Assert
978        assert!(
979            diff_output.trim().is_empty(),
980            "expected no diff, got: {diff_output}"
981        );
982    }
983
984    #[tokio::test]
985    async fn test_diff_keeps_new_commits_after_leading_squash_merged_commit() {
986        // Arrange
987        let dir = tempdir().expect("failed to create temp dir");
988        setup_test_git_repo(dir.path());
989        run_git_command(dir.path(), &["checkout", "-b", "session-branch"]);
990        fs::write(dir.path().join("merged.txt"), "already merged change")
991            .expect("failed to write merged file");
992        run_git_command(dir.path(), &["add", "merged.txt"]);
993        run_git_command(dir.path(), &["commit", "-m", "Session change"]);
994        run_git_command(dir.path(), &["checkout", "main"]);
995        run_git_command(dir.path(), &["merge", "--squash", "session-branch"]);
996        run_git_command(dir.path(), &["commit", "-m", "Squash merge session change"]);
997        run_git_command(dir.path(), &["checkout", "session-branch"]);
998        fs::write(dir.path().join("new.txt"), "new session-only change")
999            .expect("failed to write new file");
1000        run_git_command(dir.path(), &["add", "new.txt"]);
1001        run_git_command(dir.path(), &["commit", "-m", "New session change"]);
1002
1003        // Act
1004        let diff_output = diff(dir.path().to_path_buf(), "main".to_string())
1005            .await
1006            .expect("failed to load diff");
1007
1008        // Assert
1009        assert!(diff_output.contains("new.txt"));
1010        assert!(!diff_output.contains("merged.txt"));
1011    }
1012
1013    #[tokio::test]
1014    async fn test_diff_does_not_include_base_only_commits() {
1015        // Arrange
1016        let dir = tempdir().expect("failed to create temp dir");
1017        setup_test_git_repo(dir.path());
1018        run_git_command(dir.path(), &["checkout", "-b", "session-branch"]);
1019        fs::write(dir.path().join("session.txt"), "session change").expect("failed to write file");
1020        run_git_command(dir.path(), &["add", "session.txt"]);
1021        run_git_command(dir.path(), &["commit", "-m", "Session change"]);
1022        run_git_command(dir.path(), &["checkout", "main"]);
1023        fs::write(dir.path().join("main-only.txt"), "base branch only")
1024            .expect("failed to write base-only file");
1025        run_git_command(dir.path(), &["add", "main-only.txt"]);
1026        run_git_command(dir.path(), &["commit", "-m", "Main branch change"]);
1027        run_git_command(dir.path(), &["checkout", "session-branch"]);
1028
1029        // Act
1030        let diff_output = diff(dir.path().to_path_buf(), "main".to_string())
1031            .await
1032            .expect("failed to load diff");
1033
1034        // Assert
1035        assert!(diff_output.contains("session.txt"));
1036        assert!(!diff_output.contains("main-only.txt"));
1037    }
1038
1039    #[tokio::test]
1040    async fn test_is_worktree_clean_returns_true_for_clean_repo() {
1041        // Arrange
1042        let dir = tempdir().expect("failed to create temp dir");
1043        setup_test_git_repo(dir.path());
1044
1045        // Act
1046        let is_clean = is_worktree_clean(dir.path().to_path_buf())
1047            .await
1048            .expect("failed to check worktree cleanliness");
1049
1050        // Assert
1051        assert!(is_clean);
1052    }
1053
1054    #[tokio::test]
1055    async fn test_is_worktree_clean_returns_false_for_dirty_repo() {
1056        // Arrange
1057        let dir = tempdir().expect("failed to create temp dir");
1058        setup_test_git_repo(dir.path());
1059        fs::write(dir.path().join("README.md"), "dirty change").expect("failed to write change");
1060
1061        // Act
1062        let is_clean = is_worktree_clean(dir.path().to_path_buf())
1063            .await
1064            .expect("failed to check worktree cleanliness");
1065
1066        // Assert
1067        assert!(!is_clean);
1068    }
1069
1070    #[tokio::test]
1071    async fn test_worktree_status_reports_dirty_repo_paths() {
1072        // Arrange
1073        let dir = tempdir().expect("failed to create temp dir");
1074        setup_test_git_repo(dir.path());
1075        fs::write(dir.path().join("README.md"), "dirty change").expect("failed to write change");
1076        fs::write(dir.path().join("new-file.txt"), "new").expect("failed to write new file");
1077
1078        // Act
1079        let status = worktree_status(dir.path().to_path_buf())
1080            .await
1081            .expect("failed to read worktree status");
1082
1083        // Assert
1084        assert!(status.contains("README.md"));
1085        assert!(status.contains("new-file.txt"));
1086    }
1087
1088    #[tokio::test]
1089    async fn test_tracked_worktree_status_ignores_untracked_repo_paths() {
1090        // Arrange
1091        let dir = tempdir().expect("failed to create temp dir");
1092        setup_test_git_repo(dir.path());
1093        fs::write(dir.path().join("README.md"), "dirty change").expect("failed to write change");
1094        fs::write(dir.path().join("new-file.txt"), "new").expect("failed to write new file");
1095
1096        // Act
1097        let status = tracked_worktree_status(dir.path().to_path_buf())
1098            .await
1099            .expect("failed to read tracked worktree status");
1100
1101        // Assert
1102        assert!(status.contains("README.md"));
1103        assert!(!status.contains("new-file.txt"));
1104    }
1105
1106    #[tokio::test]
1107    async fn test_main_repo_root_returns_repo_root_for_main_worktree() {
1108        // Arrange
1109        let dir = tempdir().expect("failed to create temp dir");
1110        setup_test_git_repo(dir.path());
1111
1112        // Act
1113        let repo_root = main_repo_root(dir.path().to_path_buf())
1114            .await
1115            .expect("failed to resolve main repo root");
1116
1117        // Assert
1118        assert_eq!(
1119            canonicalize_test_path(&repo_root),
1120            canonicalize_test_path(dir.path())
1121        );
1122    }
1123
1124    #[tokio::test]
1125    async fn test_main_repo_root_returns_shared_repo_root_for_linked_worktree() {
1126        // Arrange
1127        let dir = tempdir().expect("failed to create temp dir");
1128        setup_test_git_repo(dir.path());
1129        let linked_worktree = dir.path().join("linked-worktree");
1130        create_worktree(
1131            dir.path().to_path_buf(),
1132            linked_worktree.clone(),
1133            "wt/main-repo-root-test".to_string(),
1134            "main".to_string(),
1135        )
1136        .await
1137        .expect("failed to create linked worktree");
1138
1139        // Act
1140        let repo_root = main_repo_root(linked_worktree)
1141            .await
1142            .expect("failed to resolve shared repo root");
1143
1144        // Assert
1145        assert_eq!(
1146            canonicalize_test_path(&repo_root),
1147            canonicalize_test_path(dir.path())
1148        );
1149    }
1150
1151    #[tokio::test]
1152    async fn test_abort_rebase_cleans_stale_rebase_merge_metadata() {
1153        // Arrange
1154        let dir = tempdir().expect("failed to create temp dir");
1155        setup_test_git_repo(dir.path());
1156        let stale_rebase_dir = dir.path().join(".git/rebase-merge");
1157        fs::create_dir_all(&stale_rebase_dir).expect("failed to create stale rebase metadata");
1158        fs::write(stale_rebase_dir.join("head-name"), "refs/heads/main")
1159            .expect("failed to write stale rebase metadata");
1160
1161        // Act
1162        let result = abort_rebase(dir.path().to_path_buf()).await;
1163
1164        // Assert
1165        assert!(result.is_ok(), "abort_rebase should succeed: {result:?}");
1166        assert!(!stale_rebase_dir.exists());
1167    }
1168
1169    #[tokio::test]
1170    async fn test_abort_rebase_returns_error_without_rebase_state_or_stale_metadata() {
1171        // Arrange
1172        let dir = tempdir().expect("failed to create temp dir");
1173        setup_test_git_repo(dir.path());
1174
1175        // Act
1176        let result = abort_rebase(dir.path().to_path_buf()).await;
1177
1178        // Assert
1179        assert!(result.is_err());
1180    }
1181
1182    #[tokio::test]
1183    async fn test_ref_hash_resolves_branch_head() {
1184        // Arrange
1185        let dir = tempdir().expect("failed to create temp dir");
1186        setup_test_git_repo(dir.path());
1187        let expected_hash = run_git_command_stdout(dir.path(), &["rev-parse", "main"]);
1188
1189        // Act
1190        let resolved_hash = ref_hash(dir.path().to_path_buf(), "main".to_string())
1191            .await
1192            .expect("failed to resolve main hash");
1193
1194        // Assert
1195        assert_eq!(resolved_hash, expected_hash);
1196    }
1197
1198    #[tokio::test]
1199    async fn test_rebase_onto_start_replays_commits_after_old_base() {
1200        // Arrange
1201        let dir = tempdir().expect("failed to create temp dir");
1202        setup_test_git_repo(dir.path());
1203        run_git_command(dir.path(), &["checkout", "-b", "parent"]);
1204        fs::write(dir.path().join("parent.txt"), "parent").expect("failed to write parent file");
1205        run_git_command(dir.path(), &["add", "parent.txt"]);
1206        run_git_command(dir.path(), &["commit", "-m", "Parent change"]);
1207        let parent_tip = run_git_command_stdout(dir.path(), &["rev-parse", "HEAD"]);
1208        run_git_command(dir.path(), &["checkout", "-b", "child"]);
1209        fs::write(dir.path().join("child.txt"), "child").expect("failed to write child file");
1210        run_git_command(dir.path(), &["add", "child.txt"]);
1211        run_git_command(dir.path(), &["commit", "-m", "Child change"]);
1212        run_git_command(dir.path(), &["checkout", "main"]);
1213        fs::write(dir.path().join("main.txt"), "main").expect("failed to write main file");
1214        run_git_command(dir.path(), &["add", "main.txt"]);
1215        run_git_command(dir.path(), &["commit", "-m", "Main change"]);
1216        run_git_command(dir.path(), &["checkout", "child"]);
1217
1218        // Act
1219        let result = rebase_onto_start(dir.path().to_path_buf(), "main".to_string(), parent_tip)
1220            .await
1221            .expect("failed to start rebase --onto");
1222        let child_only_subjects = run_git_command_stdout(
1223            dir.path(),
1224            &["log", "--format=%s", "--reverse", "main..HEAD"],
1225        );
1226
1227        // Assert
1228        assert_eq!(result, RebaseStepResult::Completed);
1229        assert_eq!(child_only_subjects, "Child change");
1230        assert!(!dir.path().join("parent.txt").exists());
1231        assert!(dir.path().join("child.txt").exists());
1232    }
1233
1234    #[tokio::test]
1235    async fn test_pull_rebase_returns_error_without_upstream() {
1236        // Arrange
1237        let dir = tempdir().expect("failed to create temp dir");
1238        setup_test_git_repo(dir.path());
1239
1240        // Act
1241        let result = pull_rebase(dir.path().to_path_buf()).await;
1242
1243        // Assert
1244        assert!(result.is_err());
1245    }
1246
1247    #[tokio::test]
1248    async fn test_pull_rebase_targets_single_upstream_when_merge_targets_are_ambiguous() {
1249        // Arrange
1250        let dir = tempdir().expect("failed to create temp dir");
1251        let remote_dir = tempdir().expect("failed to create remote temp dir");
1252        setup_test_git_repo(dir.path());
1253        run_git_command(remote_dir.path(), &["init", "--bare"]);
1254
1255        let remote_path = remote_dir.path().to_string_lossy().to_string();
1256        run_git_command(dir.path(), &["remote", "add", "origin", &remote_path]);
1257        run_git_command(dir.path(), &["push", "-u", "origin", "main"]);
1258
1259        run_git_command(dir.path(), &["checkout", "-b", "feature"]);
1260        fs::write(dir.path().join("feature.txt"), "feature change").expect("failed to write file");
1261        run_git_command(dir.path(), &["add", "feature.txt"]);
1262        run_git_command(dir.path(), &["commit", "-m", "Add feature branch"]);
1263        run_git_command(dir.path(), &["push", "-u", "origin", "feature"]);
1264        run_git_command(dir.path(), &["checkout", "main"]);
1265
1266        run_git_command(
1267            dir.path(),
1268            &["config", "--add", "branch.main.merge", "refs/heads/feature"],
1269        );
1270
1271        let pull_without_explicit_target = Command::new("git")
1272            .args(["pull", "--rebase"])
1273            .current_dir(dir.path())
1274            .output()
1275            .expect("failed to run pull --rebase");
1276
1277        assert!(
1278            !pull_without_explicit_target.status.success(),
1279            "expected plain pull --rebase to fail in ambiguous merge-target setup"
1280        );
1281        assert!(
1282            String::from_utf8_lossy(&pull_without_explicit_target.stderr)
1283                .contains("Cannot rebase onto multiple branches"),
1284            "expected ambiguous merge-target failure"
1285        );
1286
1287        // Act
1288        let result = pull_rebase(dir.path().to_path_buf()).await;
1289
1290        // Assert
1291        assert!(
1292            matches!(result, Ok(PullRebaseResult::Completed)),
1293            "pull_rebase should complete: {result:?}"
1294        );
1295    }
1296
1297    #[tokio::test]
1298    async fn test_pull_rebase_targets_local_upstream_when_upstream_name_has_no_remote_prefix() {
1299        // Arrange
1300        let dir = tempdir().expect("failed to create temp dir");
1301        setup_test_git_repo(dir.path());
1302
1303        run_git_command(dir.path(), &["checkout", "-b", "feature"]);
1304        fs::write(dir.path().join("feature.txt"), "feature change").expect("failed to write file");
1305        run_git_command(dir.path(), &["add", "feature.txt"]);
1306        run_git_command(dir.path(), &["commit", "-m", "Add feature branch"]);
1307        run_git_command(dir.path(), &["checkout", "main"]);
1308
1309        run_git_command(dir.path(), &["config", "branch.main.remote", "."]);
1310        run_git_command(
1311            dir.path(),
1312            &[
1313                "config",
1314                "--replace-all",
1315                "branch.main.merge",
1316                "refs/heads/main",
1317            ],
1318        );
1319        run_git_command(
1320            dir.path(),
1321            &["config", "--add", "branch.main.merge", "refs/heads/feature"],
1322        );
1323
1324        let pull_without_explicit_target = Command::new("git")
1325            .args(["pull", "--rebase"])
1326            .current_dir(dir.path())
1327            .output()
1328            .expect("failed to run pull --rebase");
1329
1330        assert!(
1331            !pull_without_explicit_target.status.success(),
1332            "expected plain pull --rebase to fail in ambiguous merge-target setup"
1333        );
1334        assert!(
1335            String::from_utf8_lossy(&pull_without_explicit_target.stderr)
1336                .contains("Cannot rebase onto multiple branches"),
1337            "expected ambiguous merge-target failure"
1338        );
1339
1340        // Act
1341        let result = pull_rebase(dir.path().to_path_buf()).await;
1342
1343        // Assert
1344        assert!(
1345            matches!(result, Ok(PullRebaseResult::Completed)),
1346            "pull_rebase with local upstream should complete: {result:?}"
1347        );
1348    }
1349
1350    #[tokio::test]
1351    async fn test_list_upstream_commit_titles_returns_error_without_upstream() {
1352        // Arrange
1353        let dir = tempdir().expect("failed to create temp dir");
1354        setup_test_git_repo(dir.path());
1355
1356        // Act
1357        let result = list_upstream_commit_titles(dir.path().to_path_buf()).await;
1358
1359        // Assert
1360        assert!(result.is_err());
1361    }
1362
1363    #[tokio::test]
1364    async fn test_list_upstream_commit_titles_returns_new_upstream_commit_titles() {
1365        // Arrange
1366        let dir = tempdir().expect("failed to create temp dir");
1367        let remote_dir = tempdir().expect("failed to create remote temp dir");
1368        let contributor_dir = tempdir().expect("failed to create contributor temp dir");
1369        let contributor_clone_path = contributor_dir.path().join("clone");
1370        setup_test_git_repo(dir.path());
1371        run_git_command(remote_dir.path(), &["init", "--bare"]);
1372
1373        let remote_path = remote_dir.path().to_string_lossy().to_string();
1374        let contributor_clone_path_text = contributor_clone_path.to_string_lossy().to_string();
1375        run_git_command(dir.path(), &["remote", "add", "origin", &remote_path]);
1376        run_git_command(dir.path(), &["push", "-u", "origin", "main"]);
1377
1378        run_git_command(
1379            contributor_dir.path(),
1380            &["clone", &remote_path, &contributor_clone_path_text],
1381        );
1382        run_git_command(
1383            &contributor_clone_path,
1384            &["config", "user.name", "Contributor User"],
1385        );
1386        run_git_command(
1387            &contributor_clone_path,
1388            &["config", "user.email", "contributor@example.com"],
1389        );
1390        run_git_command(
1391            &contributor_clone_path,
1392            &["checkout", "-B", "main", "origin/main"],
1393        );
1394        fs::write(contributor_clone_path.join("remote.txt"), "remote change")
1395            .expect("failed to write remote change");
1396        run_git_command(&contributor_clone_path, &["add", "remote.txt"]);
1397        run_git_command(
1398            &contributor_clone_path,
1399            &["commit", "-m", "Remote commit title"],
1400        );
1401        run_git_command(&contributor_clone_path, &["push", "origin", "main"]);
1402        run_git_command(dir.path(), &["fetch", "origin"]);
1403
1404        // Act
1405        let titles = list_upstream_commit_titles(dir.path().to_path_buf())
1406            .await
1407            .expect("failed to list upstream commit titles");
1408
1409        // Assert
1410        assert_eq!(titles, vec!["Remote commit title".to_string()]);
1411    }
1412
1413    #[tokio::test]
1414    async fn test_list_local_commit_titles_returns_error_without_upstream() {
1415        // Arrange
1416        let dir = tempdir().expect("failed to create temp dir");
1417        setup_test_git_repo(dir.path());
1418
1419        // Act
1420        let result = list_local_commit_titles(dir.path().to_path_buf()).await;
1421
1422        // Assert
1423        assert!(result.is_err());
1424    }
1425
1426    #[tokio::test]
1427    async fn test_list_local_commit_titles_returns_new_local_commit_titles() {
1428        // Arrange
1429        let dir = tempdir().expect("failed to create temp dir");
1430        let remote_dir = tempdir().expect("failed to create remote temp dir");
1431        setup_test_git_repo(dir.path());
1432        run_git_command(remote_dir.path(), &["init", "--bare"]);
1433
1434        let remote_path = remote_dir.path().to_string_lossy().to_string();
1435        run_git_command(dir.path(), &["remote", "add", "origin", &remote_path]);
1436        run_git_command(dir.path(), &["push", "-u", "origin", "main"]);
1437
1438        fs::write(dir.path().join("local_1.txt"), "local change 1")
1439            .expect("failed to write local change 1");
1440        run_git_command(dir.path(), &["add", "local_1.txt"]);
1441        run_git_command(dir.path(), &["commit", "-m", "Local commit title one"]);
1442
1443        fs::write(dir.path().join("local_2.txt"), "local change 2")
1444            .expect("failed to write local change 2");
1445        run_git_command(dir.path(), &["add", "local_2.txt"]);
1446        run_git_command(dir.path(), &["commit", "-m", "Local commit title two"]);
1447
1448        // Act
1449        let titles = list_local_commit_titles(dir.path().to_path_buf())
1450            .await
1451            .expect("failed to list local commit titles");
1452
1453        // Assert
1454        assert_eq!(
1455            titles,
1456            vec![
1457                "Local commit title one".to_string(),
1458                "Local commit title two".to_string(),
1459            ]
1460        );
1461    }
1462
1463    #[tokio::test]
1464    async fn test_push_current_branch_returns_error_without_remote() {
1465        // Arrange
1466        let dir = tempdir().expect("failed to create temp dir");
1467        setup_test_git_repo(dir.path());
1468
1469        // Act
1470        let result = push_current_branch(dir.path().to_path_buf()).await;
1471
1472        // Assert
1473        assert!(result.is_err());
1474    }
1475
1476    #[tokio::test]
1477    async fn test_push_current_branch_returns_upstream_reference() {
1478        // Arrange
1479        let dir = tempdir().expect("failed to create temp dir");
1480        let remote_dir = tempdir().expect("failed to create remote temp dir");
1481        setup_test_git_repo(dir.path());
1482        run_git_command(remote_dir.path(), &["init", "--bare"]);
1483        let remote_path = remote_dir.path().to_string_lossy().to_string();
1484        run_git_command(dir.path(), &["remote", "add", "origin", &remote_path]);
1485
1486        // Act
1487        let upstream_reference = push_current_branch(dir.path().to_path_buf())
1488            .await
1489            .expect("push should set upstream");
1490
1491        // Assert
1492        assert_eq!(upstream_reference, "origin/main");
1493    }
1494
1495    #[tokio::test]
1496    async fn test_push_current_branch_to_remote_branch_returns_upstream_reference() {
1497        // Arrange
1498        let dir = tempdir().expect("failed to create temp dir");
1499        let remote_dir = tempdir().expect("failed to create remote temp dir");
1500        setup_test_git_repo(dir.path());
1501        run_git_command(remote_dir.path(), &["init", "--bare"]);
1502        let remote_path = remote_dir.path().to_string_lossy().to_string();
1503        run_git_command(dir.path(), &["remote", "add", "origin", &remote_path]);
1504
1505        // Act
1506        let upstream_reference = push_current_branch_to_remote_branch(
1507            dir.path().to_path_buf(),
1508            "review/custom-branch".to_string(),
1509        )
1510        .await
1511        .expect("push should set a custom upstream");
1512
1513        // Assert
1514        assert_eq!(upstream_reference, "origin/review/custom-branch");
1515    }
1516
1517    #[test]
1518    fn test_is_no_upstream_error_detects_upstream_hint() {
1519        // Arrange
1520        let detail = "fatal: The current branch main has no upstream branch.";
1521
1522        // Act
1523        let is_no_upstream = sync::is_no_upstream_error(detail);
1524
1525        // Assert
1526        assert!(is_no_upstream);
1527    }
1528
1529    #[test]
1530    fn test_is_rebase_conflict_detects_conflict_keyword() {
1531        // Arrange
1532        let detail = "CONFLICT (content): Merge conflict in src/main.rs";
1533
1534        // Act / Assert
1535        assert!(rebase::is_rebase_conflict(detail));
1536    }
1537
1538    #[test]
1539    fn test_is_rebase_conflict_detects_could_not_apply() {
1540        // Arrange
1541        let detail = "error: could not apply abc1234... Update handler";
1542
1543        // Act / Assert
1544        assert!(rebase::is_rebase_conflict(detail));
1545    }
1546
1547    #[test]
1548    fn test_is_rebase_conflict_detects_mark_as_resolved() {
1549        // Arrange
1550        let detail = "hint: mark them as resolved using git add";
1551
1552        // Act / Assert
1553        assert!(rebase::is_rebase_conflict(detail));
1554    }
1555
1556    #[test]
1557    fn test_is_rebase_conflict_detects_unresolved_conflict() {
1558        // Arrange
1559        let detail = "fatal: Exiting because of an unresolved conflict.";
1560
1561        // Act / Assert
1562        assert!(rebase::is_rebase_conflict(detail));
1563    }
1564
1565    #[test]
1566    fn test_is_rebase_conflict_detects_committing_not_possible() {
1567        // Arrange
1568        let detail = "error: Committing is not possible because you have unmerged files.";
1569
1570        // Act / Assert
1571        assert!(rebase::is_rebase_conflict(detail));
1572    }
1573
1574    #[test]
1575    fn test_is_rebase_conflict_returns_false_for_unrelated_error() {
1576        // Arrange
1577        let detail = "fatal: not a git repository (or any parent up to mount point /)";
1578
1579        // Act / Assert
1580        assert!(!rebase::is_rebase_conflict(detail));
1581    }
1582
1583    #[test]
1584    fn test_is_rebase_conflict_returns_false_for_index_lock_error() {
1585        // Arrange
1586        let detail = "fatal: Unable to create '.git/index.lock': File exists.";
1587
1588        // Act / Assert
1589        assert!(!rebase::is_rebase_conflict(detail));
1590    }
1591}