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