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