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_merge_conflicts,
18    has_unmerged_paths, head_commit_message, head_hash, head_short_hash, in_progress_operation,
19    is_rebase_in_progress, 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    /// Returns whether merging `source_branch` into `target_branch` would
387    /// produce conflicts without changing the index or worktree.
388    ///
389    /// # Errors
390    /// Returns an error when either ref cannot be resolved or the merge
391    /// result cannot be computed.
392    fn has_merge_conflicts(
393        &self,
394        repo_path: PathBuf,
395        source_branch: String,
396        target_branch: String,
397    ) -> GitFuture<Result<bool, GitError>>;
398
399    /// Reads ahead/behind snapshots for all local branches that track an
400    /// upstream.
401    ///
402    /// The returned map is keyed by local branch name and stores `None` when
403    /// a branch has no tracked upstream or its upstream is gone.
404    ///
405    /// # Errors
406    /// Returns an error when branch tracking information cannot be queried.
407    fn branch_tracking_statuses(
408        &self,
409        repo_path: PathBuf,
410    ) -> GitFuture<Result<BranchTrackingMap, GitError>>;
411
412    /// Returns commit subjects that exist in upstream but not in local
413    /// `HEAD`.
414    ///
415    /// # Errors
416    /// Returns an error when upstream tracking data or commit history cannot be
417    /// read.
418    fn list_upstream_commit_titles(
419        &self,
420        repo_path: PathBuf,
421    ) -> GitFuture<Result<Vec<String>, GitError>>;
422
423    /// Returns commit subjects that exist in local `HEAD` but not in upstream.
424    ///
425    /// # Errors
426    /// Returns an error when upstream tracking data or commit history cannot be
427    /// read.
428    fn list_local_commit_titles(
429        &self,
430        repo_path: PathBuf,
431    ) -> GitFuture<Result<Vec<String>, GitError>>;
432
433    /// Reads the configured origin URL for `repo_path`.
434    ///
435    /// # Errors
436    /// Returns an error when origin is missing or cannot be resolved.
437    fn repo_url(&self, repo_path: PathBuf) -> GitFuture<Result<String, GitError>>;
438
439    /// Resolves the main repository root for a repository or worktree path.
440    ///
441    /// # Errors
442    /// Returns an error when the main repository cannot be resolved.
443    fn main_repo_root(&self, repo_path: PathBuf) -> GitFuture<Result<PathBuf, GitError>>;
444
445    /// Resolves the main working checkout for a repository or worktree path.
446    ///
447    /// Returns `None` when the shared repository is bare, because a bare
448    /// repository has no main working checkout.
449    ///
450    /// # Errors
451    /// Returns an error when the shared repository cannot be resolved.
452    fn main_checkout_working_tree(
453        &self,
454        repo_path: PathBuf,
455    ) -> GitFuture<Result<Option<PathBuf>, GitError>>;
456}
457
458/// Production [`GitClient`] implementation backed by real git commands.
459pub struct RealGitClient;
460
461impl GitClient for RealGitClient {
462    fn detect_git_info(&self, dir: PathBuf) -> GitFuture<Option<String>> {
463        Box::pin(async move { detect_git_info(dir).await })
464    }
465
466    fn find_git_repo_root(&self, dir: PathBuf) -> GitFuture<Option<PathBuf>> {
467        Box::pin(async move { find_git_repo_root(dir).await })
468    }
469
470    fn check_pre_commit_hook_ready(&self, repo_path: PathBuf) -> GitFuture<Result<(), GitError>> {
471        Box::pin(async move { check_pre_commit_hook_ready(repo_path).await })
472    }
473
474    fn create_worktree(
475        &self,
476        repo_path: PathBuf,
477        worktree_path: PathBuf,
478        branch_name: String,
479        start_ref: String,
480    ) -> GitFuture<Result<(), GitError>> {
481        Box::pin(
482            async move { create_worktree(repo_path, worktree_path, branch_name, start_ref).await },
483        )
484    }
485
486    fn remove_worktree(&self, worktree_path: PathBuf) -> GitFuture<Result<(), GitError>> {
487        Box::pin(async move { remove_worktree(worktree_path).await })
488    }
489
490    fn squash_merge_diff(
491        &self,
492        repo_path: PathBuf,
493        source_branch: String,
494        target_branch: String,
495    ) -> GitFuture<Result<String, GitError>> {
496        Box::pin(async move { squash_merge_diff(repo_path, source_branch, target_branch).await })
497    }
498
499    fn squash_merge(
500        &self,
501        repo_path: PathBuf,
502        source_branch: String,
503        target_branch: String,
504        commit_message: String,
505    ) -> GitFuture<Result<SquashMergeOutcome, GitError>> {
506        Box::pin(async move {
507            squash_merge(repo_path, source_branch, target_branch, commit_message).await
508        })
509    }
510
511    fn rebase(&self, repo_path: PathBuf, target_branch: String) -> GitFuture<Result<(), GitError>> {
512        Box::pin(async move { rebase::rebase(repo_path, target_branch).await })
513    }
514
515    fn rebase_start(
516        &self,
517        repo_path: PathBuf,
518        target_branch: String,
519    ) -> GitFuture<Result<RebaseStepResult, GitError>> {
520        Box::pin(async move { rebase_start(repo_path, target_branch).await })
521    }
522
523    fn rebase_onto_start(
524        &self,
525        repo_path: PathBuf,
526        new_base: String,
527        old_base: String,
528    ) -> GitFuture<Result<RebaseStepResult, GitError>> {
529        Box::pin(async move { rebase_onto_start(repo_path, new_base, old_base).await })
530    }
531
532    fn rebase_continue(&self, repo_path: PathBuf) -> GitFuture<Result<RebaseStepResult, GitError>> {
533        Box::pin(async move { rebase_continue(repo_path).await })
534    }
535
536    fn abort_rebase(&self, repo_path: PathBuf) -> GitFuture<Result<(), GitError>> {
537        Box::pin(async move { abort_rebase(repo_path).await })
538    }
539
540    fn is_rebase_in_progress(&self, repo_path: PathBuf) -> GitFuture<Result<bool, GitError>> {
541        Box::pin(async move { is_rebase_in_progress(repo_path).await })
542    }
543
544    fn in_progress_operation(
545        &self,
546        repo_path: PathBuf,
547    ) -> GitFuture<Result<Option<InProgressGitOperation>, GitError>> {
548        Box::pin(async move { in_progress_operation(repo_path).await })
549    }
550
551    fn has_unmerged_paths(&self, repo_path: PathBuf) -> GitFuture<Result<bool, GitError>> {
552        Box::pin(async move { has_unmerged_paths(repo_path).await })
553    }
554
555    fn list_staged_conflict_marker_files(
556        &self,
557        repo_path: PathBuf,
558        paths: Vec<String>,
559    ) -> GitFuture<Result<Vec<String>, GitError>> {
560        Box::pin(async move { list_staged_conflict_marker_files(repo_path, paths).await })
561    }
562
563    fn list_conflicted_files(
564        &self,
565        repo_path: PathBuf,
566    ) -> GitFuture<Result<Vec<String>, GitError>> {
567        Box::pin(async move { list_conflicted_files(repo_path).await })
568    }
569
570    fn commit_all(
571        &self,
572        repo_path: PathBuf,
573        message: String,
574        no_verify: bool,
575    ) -> GitFuture<Result<(), GitError>> {
576        Box::pin(async move { commit_all(repo_path, message, no_verify).await })
577    }
578
579    fn commit_all_preserving_single_commit(
580        &self,
581        repo_path: PathBuf,
582        base_branch: String,
583        commit_message: String,
584        message_strategy: SingleCommitMessageStrategy,
585        no_verify: bool,
586    ) -> GitFuture<Result<(), GitError>> {
587        Box::pin(async move {
588            commit_all_preserving_single_commit(
589                repo_path,
590                base_branch,
591                commit_message,
592                message_strategy,
593                no_verify,
594            )
595            .await
596        })
597    }
598
599    fn stage_all(&self, repo_path: PathBuf) -> GitFuture<Result<(), GitError>> {
600        Box::pin(async move { stage_all(repo_path).await })
601    }
602
603    fn head_short_hash(&self, repo_path: PathBuf) -> GitFuture<Result<String, GitError>> {
604        Box::pin(async move { head_short_hash(repo_path).await })
605    }
606
607    fn head_hash(&self, repo_path: PathBuf) -> GitFuture<Result<String, GitError>> {
608        Box::pin(async move { head_hash(repo_path).await })
609    }
610
611    fn ref_hash(
612        &self,
613        repo_path: PathBuf,
614        reference: String,
615    ) -> GitFuture<Result<String, GitError>> {
616        Box::pin(async move { ref_hash(repo_path, reference).await })
617    }
618
619    fn head_commit_message(
620        &self,
621        repo_path: PathBuf,
622    ) -> GitFuture<Result<Option<String>, GitError>> {
623        Box::pin(async move { head_commit_message(repo_path).await })
624    }
625
626    fn delete_branch(
627        &self,
628        repo_path: PathBuf,
629        branch_name: String,
630    ) -> GitFuture<Result<(), GitError>> {
631        Box::pin(async move { delete_branch(repo_path, branch_name).await })
632    }
633
634    fn diff(&self, repo_path: PathBuf, base_branch: String) -> GitFuture<Result<String, GitError>> {
635        Box::pin(async move { diff(repo_path, base_branch).await })
636    }
637
638    fn diff_changed_files(
639        &self,
640        repo_path: PathBuf,
641        base_branch: String,
642    ) -> GitFuture<Result<Vec<String>, GitError>> {
643        Box::pin(async move { diff_changed_files(repo_path, base_branch).await })
644    }
645
646    fn read_worktree_file(
647        &self,
648        repo_path: PathBuf,
649        relative_path: String,
650    ) -> GitFuture<Result<WorktreeFileContent, GitError>> {
651        Box::pin(async move { sync::read_worktree_file(repo_path, relative_path).await })
652    }
653
654    fn is_worktree_clean(&self, repo_path: PathBuf) -> GitFuture<Result<bool, GitError>> {
655        Box::pin(async move { is_worktree_clean(repo_path).await })
656    }
657
658    fn worktree_status(&self, repo_path: PathBuf) -> GitFuture<Result<String, GitError>> {
659        Box::pin(async move { worktree_status(repo_path).await })
660    }
661
662    fn tracked_worktree_status(&self, repo_path: PathBuf) -> GitFuture<Result<String, GitError>> {
663        Box::pin(async move { tracked_worktree_status(repo_path).await })
664    }
665
666    fn has_commits_since(
667        &self,
668        repo_path: PathBuf,
669        base_branch: String,
670    ) -> GitFuture<Result<bool, GitError>> {
671        Box::pin(async move { has_commits_since(repo_path, base_branch).await })
672    }
673
674    fn pull_rebase(&self, repo_path: PathBuf) -> GitFuture<Result<PullRebaseResult, GitError>> {
675        Box::pin(async move { pull_rebase(repo_path).await })
676    }
677
678    fn push_current_branch(&self, repo_path: PathBuf) -> GitFuture<Result<String, GitError>> {
679        Box::pin(async move { push_current_branch(repo_path).await })
680    }
681
682    fn push_current_branch_to_remote_branch(
683        &self,
684        repo_path: PathBuf,
685        remote_branch_name: String,
686    ) -> GitFuture<Result<String, GitError>> {
687        Box::pin(async move {
688            push_current_branch_to_remote_branch(repo_path, remote_branch_name).await
689        })
690    }
691
692    fn remote_branch_exists(
693        &self,
694        repo_path: PathBuf,
695        remote_branch_name: String,
696    ) -> GitFuture<Result<bool, GitError>> {
697        Box::pin(async move { remote_branch_exists(repo_path, remote_branch_name).await })
698    }
699
700    fn current_upstream_reference(
701        &self,
702        repo_path: PathBuf,
703    ) -> GitFuture<Result<String, GitError>> {
704        Box::pin(async move { current_upstream_reference(repo_path).await })
705    }
706
707    fn fetch_remote(&self, repo_path: PathBuf) -> GitFuture<Result<(), GitError>> {
708        Box::pin(async move { fetch_remote(repo_path).await })
709    }
710
711    fn get_ahead_behind(&self, repo_path: PathBuf) -> GitFuture<Result<(u32, u32), GitError>> {
712        Box::pin(async move { get_ahead_behind(repo_path).await })
713    }
714
715    fn get_ref_ahead_behind(
716        &self,
717        repo_path: PathBuf,
718        left_ref: String,
719        right_ref: String,
720    ) -> GitFuture<Result<(u32, u32), GitError>> {
721        Box::pin(async move { get_ref_ahead_behind(repo_path, left_ref, right_ref).await })
722    }
723
724    fn has_merge_conflicts(
725        &self,
726        repo_path: PathBuf,
727        source_branch: String,
728        target_branch: String,
729    ) -> GitFuture<Result<bool, GitError>> {
730        Box::pin(async move { has_merge_conflicts(repo_path, source_branch, target_branch).await })
731    }
732
733    fn branch_tracking_statuses(
734        &self,
735        repo_path: PathBuf,
736    ) -> GitFuture<Result<BranchTrackingMap, GitError>> {
737        Box::pin(async move { branch_tracking_statuses(repo_path).await })
738    }
739
740    fn list_upstream_commit_titles(
741        &self,
742        repo_path: PathBuf,
743    ) -> GitFuture<Result<Vec<String>, GitError>> {
744        Box::pin(async move { list_upstream_commit_titles(repo_path).await })
745    }
746
747    fn list_local_commit_titles(
748        &self,
749        repo_path: PathBuf,
750    ) -> GitFuture<Result<Vec<String>, GitError>> {
751        Box::pin(async move { list_local_commit_titles(repo_path).await })
752    }
753
754    fn repo_url(&self, repo_path: PathBuf) -> GitFuture<Result<String, GitError>> {
755        Box::pin(async move { repo_url(repo_path).await })
756    }
757
758    fn main_repo_root(&self, repo_path: PathBuf) -> GitFuture<Result<PathBuf, GitError>> {
759        Box::pin(async move { main_repo_root(repo_path).await })
760    }
761
762    fn main_checkout_working_tree(
763        &self,
764        repo_path: PathBuf,
765    ) -> GitFuture<Result<Option<PathBuf>, GitError>> {
766        Box::pin(async move { main_checkout_working_tree(repo_path).await })
767    }
768}
769
770#[cfg(test)]
771mod tests {
772    use std::path::{Path, PathBuf};
773    use std::process::Command;
774    use std::time::Duration;
775    use std::{fs, thread};
776
777    use tempfile::tempdir;
778
779    use super::*;
780
781    /// Canonicalizes a test path for stable comparisons across symlinked
782    /// temporary directory roots (for example `/var` vs `/private/var`).
783    fn canonicalize_test_path(path: &Path) -> PathBuf {
784        fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
785    }
786
787    fn run_git_command(repo_path: &Path, args: &[&str]) {
788        let output = Command::new("git")
789            .args(args)
790            .current_dir(repo_path)
791            .output()
792            .expect("failed to run git command");
793
794        assert!(
795            output.status.success(),
796            "git command {:?} failed: {}",
797            args,
798            String::from_utf8_lossy(&output.stderr)
799        );
800    }
801
802    fn run_git_command_stdout(repo_path: &Path, args: &[&str]) -> String {
803        let output = Command::new("git")
804            .args(args)
805            .current_dir(repo_path)
806            .output()
807            .expect("failed to run git command");
808
809        assert!(
810            output.status.success(),
811            "git command {:?} failed: {}",
812            args,
813            String::from_utf8_lossy(&output.stderr)
814        );
815
816        String::from_utf8_lossy(&output.stdout).trim().to_string()
817    }
818
819    fn setup_test_git_repo(repo_path: &Path) {
820        run_git_command(repo_path, &["init", "-b", "main"]);
821        run_git_command(repo_path, &["config", "user.name", "Test User"]);
822        run_git_command(repo_path, &["config", "user.email", "test@example.com"]);
823
824        fs::write(repo_path.join("README.md"), "test repo").expect("failed to write file");
825        run_git_command(repo_path, &["add", "README.md"]);
826        run_git_command(repo_path, &["commit", "-m", "Initial commit"]);
827    }
828
829    #[tokio::test]
830    async fn test_real_git_client_detects_merge_conflicts() {
831        // Arrange
832        let dir = tempdir().expect("failed to create temp dir");
833        setup_test_git_repo(dir.path());
834        run_git_command(dir.path(), &["checkout", "-b", "session-branch"]);
835        fs::write(dir.path().join("README.md"), "session content")
836            .expect("failed to write session content");
837        run_git_command(dir.path(), &["add", "README.md"]);
838        run_git_command(dir.path(), &["commit", "-m", "Session change"]);
839        run_git_command(dir.path(), &["checkout", "main"]);
840        fs::write(dir.path().join("README.md"), "main content")
841            .expect("failed to write main content");
842        run_git_command(dir.path(), &["add", "README.md"]);
843        run_git_command(dir.path(), &["commit", "-m", "Main change"]);
844        let client = RealGitClient;
845
846        // Act
847        let has_conflicts = client
848            .has_merge_conflicts(
849                dir.path().to_path_buf(),
850                "session-branch".to_string(),
851                "main".to_string(),
852            )
853            .await
854            .expect("merge conflict query should succeed");
855
856        // Assert
857        assert!(has_conflicts);
858    }
859
860    #[tokio::test]
861    async fn test_real_git_client_reads_worktree_file() {
862        // Arrange
863        let dir = tempdir().expect("failed to create temp dir");
864        fs::write(dir.path().join("README.md"), "# Preview")
865            .expect("failed to write markdown file");
866        let client = RealGitClient;
867
868        // Act
869        let result = client
870            .read_worktree_file(dir.path().to_path_buf(), "README.md".to_string())
871            .await
872            .expect("failed to read worktree file");
873
874        // Assert
875        assert_eq!(result, WorktreeFileContent::Text("# Preview".to_string()));
876    }
877
878    #[tokio::test]
879    async fn test_real_git_client_lists_changed_files() {
880        // Arrange
881        let dir = tempdir().expect("failed to create temp dir");
882        setup_test_git_repo(dir.path());
883        fs::write(dir.path().join("new.txt"), "new content").expect("failed to write changed file");
884        let client = RealGitClient;
885
886        // Act
887        let changed_files = client
888            .diff_changed_files(dir.path().to_path_buf(), "main".to_string())
889            .await
890            .expect("failed to list changed files");
891
892        // Assert
893        assert_eq!(changed_files, vec!["new.txt".to_string()]);
894    }
895
896    #[tokio::test]
897    async fn test_squash_merge_returns_committed_when_changes_exist() {
898        // Arrange
899        let dir = tempdir().expect("failed to create temp dir");
900        setup_test_git_repo(dir.path());
901        run_git_command(dir.path(), &["checkout", "-b", "feature-branch"]);
902        fs::write(dir.path().join("feature.txt"), "feature content").expect("failed to write file");
903        run_git_command(dir.path(), &["add", "feature.txt"]);
904        run_git_command(dir.path(), &["commit", "-m", "Add feature"]);
905        run_git_command(dir.path(), &["checkout", "main"]);
906
907        // Act
908        let result = squash_merge(
909            dir.path().to_path_buf(),
910            "feature-branch".to_string(),
911            "main".to_string(),
912            "Squash merge feature".to_string(),
913        )
914        .await;
915
916        // Assert
917        assert_eq!(
918            result.expect("squash merge should succeed"),
919            SquashMergeOutcome::Committed,
920        );
921    }
922
923    #[tokio::test]
924    async fn test_squash_merge_returns_already_present_when_changes_exist_in_target() {
925        // Arrange
926        let dir = tempdir().expect("failed to create temp dir");
927        setup_test_git_repo(dir.path());
928        run_git_command(dir.path(), &["checkout", "-b", "session-branch"]);
929        fs::write(dir.path().join("session.txt"), "session change").expect("failed to write file");
930        run_git_command(dir.path(), &["add", "session.txt"]);
931        run_git_command(dir.path(), &["commit", "-m", "Session change"]);
932        run_git_command(dir.path(), &["checkout", "main"]);
933        fs::write(dir.path().join("session.txt"), "session change").expect("failed to write file");
934        run_git_command(dir.path(), &["add", "session.txt"]);
935        run_git_command(dir.path(), &["commit", "-m", "Apply same change on main"]);
936
937        // Act
938        let result = squash_merge(
939            dir.path().to_path_buf(),
940            "session-branch".to_string(),
941            "main".to_string(),
942            "Merge session".to_string(),
943        )
944        .await;
945
946        // Assert
947        assert_eq!(
948            result.expect("squash merge should succeed"),
949            SquashMergeOutcome::AlreadyPresentInTarget,
950        );
951    }
952
953    #[tokio::test]
954    async fn test_commit_all_preserving_single_commit_creates_first_commit() {
955        // Arrange
956        let dir = tempdir().expect("failed to create temp dir");
957        setup_test_git_repo(dir.path());
958        run_git_command(dir.path(), &["checkout", "-b", "session-branch"]);
959        let commit_message = "Session commit".to_string();
960        fs::write(dir.path().join("work.txt"), "first change").expect("failed to write file");
961
962        // Act
963        let result = commit_all_preserving_single_commit(
964            dir.path().to_path_buf(),
965            "main".to_string(),
966            commit_message.clone(),
967            SingleCommitMessageStrategy::Replace,
968            false,
969        )
970        .await;
971        let commit_count = run_git_command_stdout(dir.path(), &["rev-list", "--count", "HEAD"]);
972        let head_message = run_git_command_stdout(dir.path(), &["log", "-1", "--pretty=%B"]);
973
974        // Assert
975        assert!(
976            result.is_ok(),
977            "commit_all_preserving_single_commit should succeed: {result:?}"
978        );
979        assert_eq!(commit_count, "2");
980        assert_eq!(head_message, commit_message);
981    }
982
983    #[tokio::test]
984    async fn test_commit_all_preserving_single_commit_amends_existing_session_commit() {
985        // Arrange
986        let dir = tempdir().expect("failed to create temp dir");
987        setup_test_git_repo(dir.path());
988        run_git_command(dir.path(), &["checkout", "-b", "session-branch"]);
989        let commit_message = "Session commit".to_string();
990        fs::write(dir.path().join("work.txt"), "first change").expect("failed to write file");
991        commit_all_preserving_single_commit(
992            dir.path().to_path_buf(),
993            "main".to_string(),
994            commit_message.clone(),
995            SingleCommitMessageStrategy::Replace,
996            false,
997        )
998        .await
999        .expect("failed to create first session commit");
1000        let first_hash = run_git_command_stdout(dir.path(), &["rev-parse", "HEAD"]);
1001        let first_count = run_git_command_stdout(dir.path(), &["rev-list", "--count", "HEAD"]);
1002
1003        // Act
1004        fs::write(dir.path().join("work.txt"), "second change").expect("failed to write file");
1005        let result = commit_all_preserving_single_commit(
1006            dir.path().to_path_buf(),
1007            "main".to_string(),
1008            commit_message.clone(),
1009            SingleCommitMessageStrategy::Replace,
1010            false,
1011        )
1012        .await;
1013        let second_hash = run_git_command_stdout(dir.path(), &["rev-parse", "HEAD"]);
1014        let second_count = run_git_command_stdout(dir.path(), &["rev-list", "--count", "HEAD"]);
1015
1016        // Assert
1017        assert!(result.is_ok(), "amend commit should succeed: {result:?}");
1018        assert_ne!(first_hash, second_hash);
1019        assert_eq!(first_count, second_count);
1020    }
1021
1022    #[tokio::test]
1023    async fn test_commit_all_preserving_single_commit_replaces_amended_message() {
1024        // Arrange
1025        let dir = tempdir().expect("failed to create temp dir");
1026        setup_test_git_repo(dir.path());
1027        run_git_command(dir.path(), &["checkout", "-b", "session-branch"]);
1028        fs::write(dir.path().join("work.txt"), "first change").expect("failed to write file");
1029        commit_all_preserving_single_commit(
1030            dir.path().to_path_buf(),
1031            "main".to_string(),
1032            "First session message".to_string(),
1033            SingleCommitMessageStrategy::Replace,
1034            false,
1035        )
1036        .await
1037        .expect("failed to create first session commit");
1038
1039        // Act
1040        fs::write(dir.path().join("work.txt"), "second change").expect("failed to write file");
1041        let result = commit_all_preserving_single_commit(
1042            dir.path().to_path_buf(),
1043            "main".to_string(),
1044            "Refined session message".to_string(),
1045            SingleCommitMessageStrategy::Replace,
1046            false,
1047        )
1048        .await;
1049        let head_message = run_git_command_stdout(dir.path(), &["log", "-1", "--pretty=%B"]);
1050
1051        // Assert
1052        assert!(
1053            result.is_ok(),
1054            "replace amended message should succeed: {result:?}"
1055        );
1056        assert_eq!(head_message, "Refined session message");
1057    }
1058
1059    #[tokio::test]
1060    async fn test_commit_all_preserving_single_commit_retries_index_lock_and_succeeds() {
1061        // Arrange
1062        let dir = tempdir().expect("failed to create temp dir");
1063        setup_test_git_repo(dir.path());
1064        run_git_command(dir.path(), &["checkout", "-b", "session-branch"]);
1065        let commit_message = "Session commit".to_string();
1066        fs::write(dir.path().join("work.txt"), "locked change").expect("failed to write file");
1067        let index_lock_path = dir.path().join(".git").join("index.lock");
1068        fs::write(&index_lock_path, "stale lock").expect("failed to write lock file");
1069        let lock_cleanup = thread::spawn(move || {
1070            thread::sleep(Duration::from_millis(250));
1071            let _ = fs::remove_file(index_lock_path);
1072        });
1073
1074        // Act
1075        let result = commit_all_preserving_single_commit(
1076            dir.path().to_path_buf(),
1077            "main".to_string(),
1078            commit_message.clone(),
1079            SingleCommitMessageStrategy::Replace,
1080            false,
1081        )
1082        .await;
1083        lock_cleanup
1084            .join()
1085            .expect("failed to join lock cleanup thread");
1086        let head_message = run_git_command_stdout(dir.path(), &["log", "-1", "--pretty=%B"]);
1087
1088        // Assert
1089        assert!(
1090            result.is_ok(),
1091            "retry with index lock should succeed: {result:?}"
1092        );
1093        assert_eq!(head_message, commit_message);
1094    }
1095
1096    #[tokio::test]
1097    async fn test_diff_hides_leading_squash_merged_commit_for_non_rebased_session() {
1098        // Arrange
1099        let dir = tempdir().expect("failed to create temp dir");
1100        setup_test_git_repo(dir.path());
1101        run_git_command(dir.path(), &["checkout", "-b", "session-branch"]);
1102        fs::write(dir.path().join("merged.txt"), "already merged change")
1103            .expect("failed to write merged file");
1104        run_git_command(dir.path(), &["add", "merged.txt"]);
1105        run_git_command(dir.path(), &["commit", "-m", "Session change"]);
1106        run_git_command(dir.path(), &["checkout", "main"]);
1107        run_git_command(dir.path(), &["merge", "--squash", "session-branch"]);
1108        run_git_command(dir.path(), &["commit", "-m", "Squash merge session change"]);
1109        run_git_command(dir.path(), &["checkout", "session-branch"]);
1110
1111        // Act
1112        let diff_output = diff(dir.path().to_path_buf(), "main".to_string())
1113            .await
1114            .expect("failed to load diff");
1115
1116        // Assert
1117        assert!(
1118            diff_output.trim().is_empty(),
1119            "expected no diff, got: {diff_output}"
1120        );
1121    }
1122
1123    #[tokio::test]
1124    async fn test_diff_keeps_new_commits_after_leading_squash_merged_commit() {
1125        // Arrange
1126        let dir = tempdir().expect("failed to create temp dir");
1127        setup_test_git_repo(dir.path());
1128        run_git_command(dir.path(), &["checkout", "-b", "session-branch"]);
1129        fs::write(dir.path().join("merged.txt"), "already merged change")
1130            .expect("failed to write merged file");
1131        run_git_command(dir.path(), &["add", "merged.txt"]);
1132        run_git_command(dir.path(), &["commit", "-m", "Session change"]);
1133        run_git_command(dir.path(), &["checkout", "main"]);
1134        run_git_command(dir.path(), &["merge", "--squash", "session-branch"]);
1135        run_git_command(dir.path(), &["commit", "-m", "Squash merge session change"]);
1136        run_git_command(dir.path(), &["checkout", "session-branch"]);
1137        fs::write(dir.path().join("new.txt"), "new session-only change")
1138            .expect("failed to write new file");
1139        run_git_command(dir.path(), &["add", "new.txt"]);
1140        run_git_command(dir.path(), &["commit", "-m", "New session change"]);
1141
1142        // Act
1143        let diff_output = diff(dir.path().to_path_buf(), "main".to_string())
1144            .await
1145            .expect("failed to load diff");
1146
1147        // Assert
1148        assert!(diff_output.contains("new.txt"));
1149        assert!(!diff_output.contains("merged.txt"));
1150    }
1151
1152    #[tokio::test]
1153    async fn test_diff_does_not_include_base_only_commits() {
1154        // Arrange
1155        let dir = tempdir().expect("failed to create temp dir");
1156        setup_test_git_repo(dir.path());
1157        run_git_command(dir.path(), &["checkout", "-b", "session-branch"]);
1158        fs::write(dir.path().join("session.txt"), "session change").expect("failed to write file");
1159        run_git_command(dir.path(), &["add", "session.txt"]);
1160        run_git_command(dir.path(), &["commit", "-m", "Session change"]);
1161        run_git_command(dir.path(), &["checkout", "main"]);
1162        fs::write(dir.path().join("main-only.txt"), "base branch only")
1163            .expect("failed to write base-only file");
1164        run_git_command(dir.path(), &["add", "main-only.txt"]);
1165        run_git_command(dir.path(), &["commit", "-m", "Main branch change"]);
1166        run_git_command(dir.path(), &["checkout", "session-branch"]);
1167
1168        // Act
1169        let diff_output = diff(dir.path().to_path_buf(), "main".to_string())
1170            .await
1171            .expect("failed to load diff");
1172
1173        // Assert
1174        assert!(diff_output.contains("session.txt"));
1175        assert!(!diff_output.contains("main-only.txt"));
1176    }
1177
1178    #[tokio::test]
1179    async fn test_is_worktree_clean_returns_true_for_clean_repo() {
1180        // Arrange
1181        let dir = tempdir().expect("failed to create temp dir");
1182        setup_test_git_repo(dir.path());
1183
1184        // Act
1185        let is_clean = is_worktree_clean(dir.path().to_path_buf())
1186            .await
1187            .expect("failed to check worktree cleanliness");
1188
1189        // Assert
1190        assert!(is_clean);
1191    }
1192
1193    #[tokio::test]
1194    async fn test_is_worktree_clean_returns_false_for_dirty_repo() {
1195        // Arrange
1196        let dir = tempdir().expect("failed to create temp dir");
1197        setup_test_git_repo(dir.path());
1198        fs::write(dir.path().join("README.md"), "dirty change").expect("failed to write change");
1199
1200        // Act
1201        let is_clean = is_worktree_clean(dir.path().to_path_buf())
1202            .await
1203            .expect("failed to check worktree cleanliness");
1204
1205        // Assert
1206        assert!(!is_clean);
1207    }
1208
1209    #[tokio::test]
1210    async fn test_worktree_status_reports_dirty_repo_paths() {
1211        // Arrange
1212        let dir = tempdir().expect("failed to create temp dir");
1213        setup_test_git_repo(dir.path());
1214        fs::write(dir.path().join("README.md"), "dirty change").expect("failed to write change");
1215        fs::write(dir.path().join("new-file.txt"), "new").expect("failed to write new file");
1216
1217        // Act
1218        let status = worktree_status(dir.path().to_path_buf())
1219            .await
1220            .expect("failed to read worktree status");
1221
1222        // Assert
1223        assert!(status.contains("README.md"));
1224        assert!(status.contains("new-file.txt"));
1225    }
1226
1227    #[tokio::test]
1228    async fn test_tracked_worktree_status_ignores_untracked_repo_paths() {
1229        // Arrange
1230        let dir = tempdir().expect("failed to create temp dir");
1231        setup_test_git_repo(dir.path());
1232        fs::write(dir.path().join("README.md"), "dirty change").expect("failed to write change");
1233        fs::write(dir.path().join("new-file.txt"), "new").expect("failed to write new file");
1234
1235        // Act
1236        let status = tracked_worktree_status(dir.path().to_path_buf())
1237            .await
1238            .expect("failed to read tracked worktree status");
1239
1240        // Assert
1241        assert!(status.contains("README.md"));
1242        assert!(!status.contains("new-file.txt"));
1243    }
1244
1245    #[tokio::test]
1246    async fn test_main_repo_root_returns_repo_root_for_main_worktree() {
1247        // Arrange
1248        let dir = tempdir().expect("failed to create temp dir");
1249        setup_test_git_repo(dir.path());
1250
1251        // Act
1252        let repo_root = main_repo_root(dir.path().to_path_buf())
1253            .await
1254            .expect("failed to resolve main repo root");
1255
1256        // Assert
1257        assert_eq!(
1258            canonicalize_test_path(&repo_root),
1259            canonicalize_test_path(dir.path())
1260        );
1261    }
1262
1263    #[tokio::test]
1264    async fn test_main_repo_root_returns_shared_repo_root_for_linked_worktree() {
1265        // Arrange
1266        let dir = tempdir().expect("failed to create temp dir");
1267        setup_test_git_repo(dir.path());
1268        let linked_worktree = dir.path().join("linked-worktree");
1269        create_worktree(
1270            dir.path().to_path_buf(),
1271            linked_worktree.clone(),
1272            "wt/main-repo-root-test".to_string(),
1273            "main".to_string(),
1274        )
1275        .await
1276        .expect("failed to create linked worktree");
1277
1278        // Act
1279        let repo_root = main_repo_root(linked_worktree)
1280            .await
1281            .expect("failed to resolve shared repo root");
1282
1283        // Assert
1284        assert_eq!(
1285            canonicalize_test_path(&repo_root),
1286            canonicalize_test_path(dir.path())
1287        );
1288    }
1289
1290    #[tokio::test]
1291    async fn test_abort_rebase_returns_error_without_rebase_state_or_stale_metadata() {
1292        // Arrange
1293        let dir = tempdir().expect("failed to create temp dir");
1294        setup_test_git_repo(dir.path());
1295
1296        // Act
1297        let result = abort_rebase(dir.path().to_path_buf()).await;
1298
1299        // Assert
1300        assert!(result.is_err());
1301    }
1302
1303    #[tokio::test]
1304    async fn test_ref_hash_resolves_branch_head() {
1305        // Arrange
1306        let dir = tempdir().expect("failed to create temp dir");
1307        setup_test_git_repo(dir.path());
1308        let expected_hash = run_git_command_stdout(dir.path(), &["rev-parse", "main"]);
1309
1310        // Act
1311        let resolved_hash = ref_hash(dir.path().to_path_buf(), "main".to_string())
1312            .await
1313            .expect("failed to resolve main hash");
1314
1315        // Assert
1316        assert_eq!(resolved_hash, expected_hash);
1317    }
1318
1319    #[tokio::test]
1320    async fn test_rebase_onto_start_replays_commits_after_old_base() {
1321        // Arrange
1322        let dir = tempdir().expect("failed to create temp dir");
1323        setup_test_git_repo(dir.path());
1324        run_git_command(dir.path(), &["checkout", "-b", "parent"]);
1325        fs::write(dir.path().join("parent.txt"), "parent").expect("failed to write parent file");
1326        run_git_command(dir.path(), &["add", "parent.txt"]);
1327        run_git_command(dir.path(), &["commit", "-m", "Parent change"]);
1328        let parent_tip = run_git_command_stdout(dir.path(), &["rev-parse", "HEAD"]);
1329        run_git_command(dir.path(), &["checkout", "-b", "child"]);
1330        fs::write(dir.path().join("child.txt"), "child").expect("failed to write child file");
1331        run_git_command(dir.path(), &["add", "child.txt"]);
1332        run_git_command(dir.path(), &["commit", "-m", "Child change"]);
1333        run_git_command(dir.path(), &["checkout", "main"]);
1334        fs::write(dir.path().join("main.txt"), "main").expect("failed to write main file");
1335        run_git_command(dir.path(), &["add", "main.txt"]);
1336        run_git_command(dir.path(), &["commit", "-m", "Main change"]);
1337        run_git_command(dir.path(), &["checkout", "child"]);
1338
1339        // Act
1340        let result = rebase_onto_start(dir.path().to_path_buf(), "main".to_string(), parent_tip)
1341            .await
1342            .expect("failed to start rebase --onto");
1343        let child_only_subjects = run_git_command_stdout(
1344            dir.path(),
1345            &["log", "--format=%s", "--reverse", "main..HEAD"],
1346        );
1347
1348        // Assert
1349        assert_eq!(result, RebaseStepResult::Completed);
1350        assert_eq!(child_only_subjects, "Child change");
1351        assert!(!dir.path().join("parent.txt").exists());
1352        assert!(dir.path().join("child.txt").exists());
1353    }
1354
1355    #[tokio::test]
1356    async fn test_pull_rebase_returns_error_without_upstream() {
1357        // Arrange
1358        let dir = tempdir().expect("failed to create temp dir");
1359        setup_test_git_repo(dir.path());
1360
1361        // Act
1362        let result = pull_rebase(dir.path().to_path_buf()).await;
1363
1364        // Assert
1365        assert!(result.is_err());
1366    }
1367
1368    #[tokio::test]
1369    async fn test_pull_rebase_targets_single_upstream_when_merge_targets_are_ambiguous() {
1370        // Arrange
1371        let dir = tempdir().expect("failed to create temp dir");
1372        let remote_dir = tempdir().expect("failed to create remote temp dir");
1373        setup_test_git_repo(dir.path());
1374        run_git_command(remote_dir.path(), &["init", "--bare"]);
1375
1376        let remote_path = remote_dir.path().to_string_lossy().to_string();
1377        run_git_command(dir.path(), &["remote", "add", "origin", &remote_path]);
1378        run_git_command(dir.path(), &["push", "-u", "origin", "main"]);
1379
1380        run_git_command(dir.path(), &["checkout", "-b", "feature"]);
1381        fs::write(dir.path().join("feature.txt"), "feature change").expect("failed to write file");
1382        run_git_command(dir.path(), &["add", "feature.txt"]);
1383        run_git_command(dir.path(), &["commit", "-m", "Add feature branch"]);
1384        run_git_command(dir.path(), &["push", "-u", "origin", "feature"]);
1385        run_git_command(dir.path(), &["checkout", "main"]);
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 should complete: {result:?}"
1415        );
1416    }
1417
1418    #[tokio::test]
1419    async fn test_pull_rebase_targets_local_upstream_when_upstream_name_has_no_remote_prefix() {
1420        // Arrange
1421        let dir = tempdir().expect("failed to create temp dir");
1422        setup_test_git_repo(dir.path());
1423
1424        run_git_command(dir.path(), &["checkout", "-b", "feature"]);
1425        fs::write(dir.path().join("feature.txt"), "feature change").expect("failed to write file");
1426        run_git_command(dir.path(), &["add", "feature.txt"]);
1427        run_git_command(dir.path(), &["commit", "-m", "Add feature branch"]);
1428        run_git_command(dir.path(), &["checkout", "main"]);
1429
1430        run_git_command(dir.path(), &["config", "branch.main.remote", "."]);
1431        run_git_command(
1432            dir.path(),
1433            &[
1434                "config",
1435                "--replace-all",
1436                "branch.main.merge",
1437                "refs/heads/main",
1438            ],
1439        );
1440        run_git_command(
1441            dir.path(),
1442            &["config", "--add", "branch.main.merge", "refs/heads/feature"],
1443        );
1444
1445        let pull_without_explicit_target = Command::new("git")
1446            .args(["pull", "--rebase"])
1447            .current_dir(dir.path())
1448            .output()
1449            .expect("failed to run pull --rebase");
1450
1451        assert!(
1452            !pull_without_explicit_target.status.success(),
1453            "expected plain pull --rebase to fail in ambiguous merge-target setup"
1454        );
1455        assert!(
1456            String::from_utf8_lossy(&pull_without_explicit_target.stderr)
1457                .contains("Cannot rebase onto multiple branches"),
1458            "expected ambiguous merge-target failure"
1459        );
1460
1461        // Act
1462        let result = pull_rebase(dir.path().to_path_buf()).await;
1463
1464        // Assert
1465        assert!(
1466            matches!(result, Ok(PullRebaseResult::Completed)),
1467            "pull_rebase with local upstream should complete: {result:?}"
1468        );
1469    }
1470
1471    #[tokio::test]
1472    async fn test_list_upstream_commit_titles_returns_error_without_upstream() {
1473        // Arrange
1474        let dir = tempdir().expect("failed to create temp dir");
1475        setup_test_git_repo(dir.path());
1476
1477        // Act
1478        let result = list_upstream_commit_titles(dir.path().to_path_buf()).await;
1479
1480        // Assert
1481        assert!(result.is_err());
1482    }
1483
1484    #[tokio::test]
1485    async fn test_list_upstream_commit_titles_returns_new_upstream_commit_titles() {
1486        // Arrange
1487        let dir = tempdir().expect("failed to create temp dir");
1488        let remote_dir = tempdir().expect("failed to create remote temp dir");
1489        let contributor_dir = tempdir().expect("failed to create contributor temp dir");
1490        let contributor_clone_path = contributor_dir.path().join("clone");
1491        setup_test_git_repo(dir.path());
1492        run_git_command(remote_dir.path(), &["init", "--bare"]);
1493
1494        let remote_path = remote_dir.path().to_string_lossy().to_string();
1495        let contributor_clone_path_text = contributor_clone_path.to_string_lossy().to_string();
1496        run_git_command(dir.path(), &["remote", "add", "origin", &remote_path]);
1497        run_git_command(dir.path(), &["push", "-u", "origin", "main"]);
1498
1499        run_git_command(
1500            contributor_dir.path(),
1501            &["clone", &remote_path, &contributor_clone_path_text],
1502        );
1503        run_git_command(
1504            &contributor_clone_path,
1505            &["config", "user.name", "Contributor User"],
1506        );
1507        run_git_command(
1508            &contributor_clone_path,
1509            &["config", "user.email", "contributor@example.com"],
1510        );
1511        run_git_command(
1512            &contributor_clone_path,
1513            &["checkout", "-B", "main", "origin/main"],
1514        );
1515        fs::write(contributor_clone_path.join("remote.txt"), "remote change")
1516            .expect("failed to write remote change");
1517        run_git_command(&contributor_clone_path, &["add", "remote.txt"]);
1518        run_git_command(
1519            &contributor_clone_path,
1520            &["commit", "-m", "Remote commit title"],
1521        );
1522        run_git_command(&contributor_clone_path, &["push", "origin", "main"]);
1523        run_git_command(dir.path(), &["fetch", "origin"]);
1524
1525        // Act
1526        let titles = list_upstream_commit_titles(dir.path().to_path_buf())
1527            .await
1528            .expect("failed to list upstream commit titles");
1529
1530        // Assert
1531        assert_eq!(titles, vec!["Remote commit title".to_string()]);
1532    }
1533
1534    #[tokio::test]
1535    async fn test_list_local_commit_titles_returns_error_without_upstream() {
1536        // Arrange
1537        let dir = tempdir().expect("failed to create temp dir");
1538        setup_test_git_repo(dir.path());
1539
1540        // Act
1541        let result = list_local_commit_titles(dir.path().to_path_buf()).await;
1542
1543        // Assert
1544        assert!(result.is_err());
1545    }
1546
1547    #[tokio::test]
1548    async fn test_list_local_commit_titles_returns_new_local_commit_titles() {
1549        // Arrange
1550        let dir = tempdir().expect("failed to create temp dir");
1551        let remote_dir = tempdir().expect("failed to create remote temp dir");
1552        setup_test_git_repo(dir.path());
1553        run_git_command(remote_dir.path(), &["init", "--bare"]);
1554
1555        let remote_path = remote_dir.path().to_string_lossy().to_string();
1556        run_git_command(dir.path(), &["remote", "add", "origin", &remote_path]);
1557        run_git_command(dir.path(), &["push", "-u", "origin", "main"]);
1558
1559        fs::write(dir.path().join("local_1.txt"), "local change 1")
1560            .expect("failed to write local change 1");
1561        run_git_command(dir.path(), &["add", "local_1.txt"]);
1562        run_git_command(dir.path(), &["commit", "-m", "Local commit title one"]);
1563
1564        fs::write(dir.path().join("local_2.txt"), "local change 2")
1565            .expect("failed to write local change 2");
1566        run_git_command(dir.path(), &["add", "local_2.txt"]);
1567        run_git_command(dir.path(), &["commit", "-m", "Local commit title two"]);
1568
1569        // Act
1570        let titles = list_local_commit_titles(dir.path().to_path_buf())
1571            .await
1572            .expect("failed to list local commit titles");
1573
1574        // Assert
1575        assert_eq!(
1576            titles,
1577            vec![
1578                "Local commit title one".to_string(),
1579                "Local commit title two".to_string(),
1580            ]
1581        );
1582    }
1583
1584    #[tokio::test]
1585    async fn test_push_current_branch_returns_error_without_remote() {
1586        // Arrange
1587        let dir = tempdir().expect("failed to create temp dir");
1588        setup_test_git_repo(dir.path());
1589
1590        // Act
1591        let result = push_current_branch(dir.path().to_path_buf()).await;
1592
1593        // Assert
1594        assert!(result.is_err());
1595    }
1596
1597    #[tokio::test]
1598    async fn test_push_current_branch_returns_upstream_reference() {
1599        // Arrange
1600        let dir = tempdir().expect("failed to create temp dir");
1601        let remote_dir = tempdir().expect("failed to create remote temp dir");
1602        setup_test_git_repo(dir.path());
1603        run_git_command(remote_dir.path(), &["init", "--bare"]);
1604        let remote_path = remote_dir.path().to_string_lossy().to_string();
1605        run_git_command(dir.path(), &["remote", "add", "origin", &remote_path]);
1606
1607        // Act
1608        let upstream_reference = push_current_branch(dir.path().to_path_buf())
1609            .await
1610            .expect("push should set upstream");
1611
1612        // Assert
1613        assert_eq!(upstream_reference, "origin/main");
1614    }
1615
1616    #[tokio::test]
1617    async fn test_push_current_branch_to_remote_branch_returns_upstream_reference() {
1618        // Arrange
1619        let dir = tempdir().expect("failed to create temp dir");
1620        let remote_dir = tempdir().expect("failed to create remote temp dir");
1621        setup_test_git_repo(dir.path());
1622        run_git_command(remote_dir.path(), &["init", "--bare"]);
1623        let remote_path = remote_dir.path().to_string_lossy().to_string();
1624        run_git_command(dir.path(), &["remote", "add", "origin", &remote_path]);
1625
1626        // Act
1627        let upstream_reference = push_current_branch_to_remote_branch(
1628            dir.path().to_path_buf(),
1629            "review/custom-branch".to_string(),
1630        )
1631        .await
1632        .expect("push should set a custom upstream");
1633
1634        // Assert
1635        assert_eq!(upstream_reference, "origin/review/custom-branch");
1636    }
1637
1638    #[test]
1639    fn test_is_no_upstream_error_detects_upstream_hint() {
1640        // Arrange
1641        let detail = "fatal: The current branch main has no upstream branch.";
1642
1643        // Act
1644        let is_no_upstream = sync::is_no_upstream_error(detail);
1645
1646        // Assert
1647        assert!(is_no_upstream);
1648    }
1649
1650    #[test]
1651    fn test_is_rebase_conflict_detects_conflict_keyword() {
1652        // Arrange
1653        let detail = "CONFLICT (content): Merge conflict in src/main.rs";
1654
1655        // Act / Assert
1656        assert!(rebase::is_rebase_conflict(detail));
1657    }
1658
1659    #[test]
1660    fn test_is_rebase_conflict_detects_could_not_apply() {
1661        // Arrange
1662        let detail = "error: could not apply abc1234... Update handler";
1663
1664        // Act / Assert
1665        assert!(rebase::is_rebase_conflict(detail));
1666    }
1667
1668    #[test]
1669    fn test_is_rebase_conflict_detects_mark_as_resolved() {
1670        // Arrange
1671        let detail = "hint: mark them as resolved using git add";
1672
1673        // Act / Assert
1674        assert!(rebase::is_rebase_conflict(detail));
1675    }
1676
1677    #[test]
1678    fn test_is_rebase_conflict_detects_unresolved_conflict() {
1679        // Arrange
1680        let detail = "fatal: Exiting because of an unresolved conflict.";
1681
1682        // Act / Assert
1683        assert!(rebase::is_rebase_conflict(detail));
1684    }
1685
1686    #[test]
1687    fn test_is_rebase_conflict_detects_committing_not_possible() {
1688        // Arrange
1689        let detail = "error: Committing is not possible because you have unmerged files.";
1690
1691        // Act / Assert
1692        assert!(rebase::is_rebase_conflict(detail));
1693    }
1694
1695    #[test]
1696    fn test_is_rebase_conflict_returns_false_for_unrelated_error() {
1697        // Arrange
1698        let detail = "fatal: not a git repository (or any parent up to mount point /)";
1699
1700        // Act / Assert
1701        assert!(!rebase::is_rebase_conflict(detail));
1702    }
1703
1704    #[test]
1705    fn test_is_rebase_conflict_returns_false_for_index_lock_error() {
1706        // Arrange
1707        let detail = "fatal: Unable to create '.git/index.lock': File exists.";
1708
1709        // Act / Assert
1710        assert!(!rebase::is_rebase_conflict(detail));
1711    }
1712}