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