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