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_new_remote_branch,
22    push_current_branch_to_remote_branch, rebase, rebase_continue, rebase_onto_start, rebase_start,
23    ref_hash, remote_branch_exists, remove_worktree, repo_url, run_pre_commit_hook, squash_merge,
24    squash_merge_diff, stage_all, sync, 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    /// Runs the effective Git `pre-commit` hook against the current index.
55    ///
56    /// Missing hooks are accepted, matching normal Git commit behavior.
57    ///
58    /// # Errors
59    /// Returns an error when Git cannot run the hook or the hook rejects the
60    /// staged changes.
61    fn run_pre_commit_hook(&self, repo_path: PathBuf) -> GitFuture<Result<(), GitError>>;
62
63    /// Creates a new worktree at `worktree_path` on `branch_name` from
64    /// `start_ref` inside `repo_path`.
65    ///
66    /// # Errors
67    /// Returns an error when any underlying git command fails, when branches
68    /// cannot be resolved, or when the target worktree path cannot be created.
69    fn create_worktree(
70        &self,
71        repo_path: PathBuf,
72        worktree_path: PathBuf,
73        branch_name: String,
74        start_ref: String,
75    ) -> GitFuture<Result<(), GitError>>;
76
77    /// Removes the existing worktree at `worktree_path`.
78    ///
79    /// # Errors
80    /// Returns an error when the path is not a registered worktree or git
81    /// cannot remove it.
82    fn remove_worktree(&self, worktree_path: PathBuf) -> GitFuture<Result<(), GitError>>;
83
84    /// Returns the staged squash-merge preview diff from `source_branch` into
85    /// `target_branch` within `repo_path`.
86    ///
87    /// # Errors
88    /// Returns an error when either branch is missing or diff generation fails.
89    fn squash_merge_diff(
90        &self,
91        repo_path: PathBuf,
92        source_branch: String,
93        target_branch: String,
94    ) -> GitFuture<Result<String, GitError>>;
95
96    /// Performs a squash merge of `source_branch` into `target_branch` inside
97    /// `repo_path` using `commit_message`.
98    ///
99    /// # Errors
100    /// Returns an error when checkout, merge, or commit operations fail.
101    fn squash_merge(
102        &self,
103        repo_path: PathBuf,
104        source_branch: String,
105        target_branch: String,
106        commit_message: String,
107    ) -> GitFuture<Result<SquashMergeOutcome, GitError>>;
108
109    /// Runs `git rebase <target_branch>` in `repo_path`.
110    ///
111    /// # Errors
112    /// Returns an error when rebase setup fails or git reports a fatal error.
113    fn rebase(&self, repo_path: PathBuf, target_branch: String) -> GitFuture<Result<(), GitError>>;
114
115    /// Starts a rebase onto `target_branch` and reports whether it completed
116    /// immediately or stopped for conflicts.
117    ///
118    /// # Errors
119    /// Returns an error when rebase cannot be started.
120    fn rebase_start(
121        &self,
122        repo_path: PathBuf,
123        target_branch: String,
124    ) -> GitFuture<Result<RebaseStepResult, GitError>>;
125
126    /// Starts `git rebase --onto new_base old_base` in `repo_path`.
127    ///
128    /// # Errors
129    /// Returns an error when rebase cannot be started.
130    fn rebase_onto_start(
131        &self,
132        repo_path: PathBuf,
133        new_base: String,
134        old_base: String,
135    ) -> GitFuture<Result<RebaseStepResult, GitError>>;
136
137    /// Continues an in-progress rebase in `repo_path`.
138    ///
139    /// # Errors
140    /// Returns an error when there is no rebase to continue or git fails.
141    fn rebase_continue(&self, repo_path: PathBuf) -> GitFuture<Result<RebaseStepResult, GitError>>;
142
143    /// Aborts an in-progress rebase in `repo_path`.
144    ///
145    /// # Errors
146    /// Returns an error when abort fails or no rebase state exists.
147    fn abort_rebase(&self, repo_path: PathBuf) -> GitFuture<Result<(), GitError>>;
148
149    /// Returns whether rebase metadata exists in `repo_path`.
150    ///
151    /// # Errors
152    /// Returns an error when git state cannot be inspected.
153    fn is_rebase_in_progress(&self, repo_path: PathBuf) -> GitFuture<Result<bool, GitError>>;
154
155    /// Returns detected in-progress git operation metadata in `repo_path`.
156    ///
157    /// # Errors
158    /// Returns an error when git state cannot be inspected.
159    fn in_progress_operation(
160        &self,
161        repo_path: PathBuf,
162    ) -> GitFuture<Result<Option<InProgressGitOperation>, GitError>>;
163
164    /// Returns whether unmerged index entries remain in `repo_path`.
165    ///
166    /// # Errors
167    /// Returns an error when index status cannot be read.
168    fn has_unmerged_paths(&self, repo_path: PathBuf) -> GitFuture<Result<bool, GitError>>;
169
170    /// Filters `paths` to files that are staged and still contain conflict
171    /// markers in `repo_path`.
172    ///
173    /// # Errors
174    /// Returns an error when staged content cannot be inspected.
175    fn list_staged_conflict_marker_files(
176        &self,
177        repo_path: PathBuf,
178        paths: Vec<String>,
179    ) -> GitFuture<Result<Vec<String>, GitError>>;
180
181    /// Lists files currently marked conflicted in the index for `repo_path`.
182    ///
183    /// # Errors
184    /// Returns an error when conflict state cannot be queried.
185    fn list_conflicted_files(&self, repo_path: PathBuf)
186    -> GitFuture<Result<Vec<String>, GitError>>;
187
188    /// Stages and commits all changes in `repo_path` using `message`.
189    ///
190    /// # Errors
191    /// Returns an error when staging or commit creation fails.
192    fn commit_all(&self, repo_path: PathBuf, message: String) -> GitFuture<Result<(), GitError>>;
193
194    /// Commits all changes while preserving one evolving session commit in
195    /// `repo_path`.
196    ///
197    /// Uses `commit_message` for new or amended commit content.
198    ///
199    /// # Errors
200    /// Returns an error when staging, amend/create, or branch inspection fails.
201    fn commit_all_preserving_single_commit(
202        &self,
203        repo_path: PathBuf,
204        base_branch: String,
205        commit_message: String,
206        message_strategy: SingleCommitMessageStrategy,
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    /// Pushes the current branch to one explicit remote branch while requiring
343    /// that the remote branch does not exist.
344    ///
345    /// # Errors
346    /// Returns an error when the remote branch exists or the push fails.
347    fn push_current_branch_to_new_remote_branch(
348        &self,
349        repo_path: PathBuf,
350        remote_branch_name: String,
351    ) -> GitFuture<Result<String, GitError>>;
352
353    /// Checks whether `remote_branch_name` already exists on the remote for
354    /// the repository at `repo_path`.
355    ///
356    /// # Errors
357    /// Returns an error when the remote lookup command fails.
358    fn remote_branch_exists(
359        &self,
360        repo_path: PathBuf,
361        remote_branch_name: String,
362    ) -> GitFuture<Result<bool, GitError>>;
363
364    /// Resolves the current upstream reference for `repo_path`.
365    ///
366    /// # Errors
367    /// Returns an error when upstream tracking information is unavailable.
368    fn current_upstream_reference(&self, repo_path: PathBuf)
369    -> GitFuture<Result<String, GitError>>;
370
371    /// Fetches remote refs for `repo_path`.
372    ///
373    /// # Errors
374    /// Returns an error when fetch fails.
375    fn fetch_remote(&self, repo_path: PathBuf) -> GitFuture<Result<(), GitError>>;
376
377    /// Reads ahead/behind commit counts for `repo_path`.
378    ///
379    /// # Errors
380    /// Returns an error when upstream tracking information is unavailable.
381    fn get_ahead_behind(&self, repo_path: PathBuf) -> GitFuture<Result<(u32, u32), GitError>>;
382
383    /// Reads ahead/behind commit counts between two explicit refs.
384    ///
385    /// The returned tuple is `(ahead, behind)` from the perspective of
386    /// `left_ref`.
387    ///
388    /// # Errors
389    /// Returns an error when either ref cannot be resolved.
390    fn get_ref_ahead_behind(
391        &self,
392        repo_path: PathBuf,
393        left_ref: String,
394        right_ref: String,
395    ) -> GitFuture<Result<(u32, u32), GitError>>;
396
397    /// Returns whether merging `source_branch` into `target_branch` would
398    /// produce conflicts without changing the index or worktree.
399    ///
400    /// # Errors
401    /// Returns an error when either ref cannot be resolved or the merge
402    /// result cannot be computed.
403    fn has_merge_conflicts(
404        &self,
405        repo_path: PathBuf,
406        source_branch: String,
407        target_branch: String,
408    ) -> GitFuture<Result<bool, GitError>>;
409
410    /// Reads ahead/behind snapshots for all local branches that track an
411    /// upstream.
412    ///
413    /// The returned map is keyed by local branch name and stores `None` when
414    /// a branch has no tracked upstream or its upstream is gone.
415    ///
416    /// # Errors
417    /// Returns an error when branch tracking information cannot be queried.
418    fn branch_tracking_statuses(
419        &self,
420        repo_path: PathBuf,
421    ) -> GitFuture<Result<BranchTrackingMap, GitError>>;
422
423    /// Returns commit subjects that exist in upstream but not in local
424    /// `HEAD`.
425    ///
426    /// # Errors
427    /// Returns an error when upstream tracking data or commit history cannot be
428    /// read.
429    fn list_upstream_commit_titles(
430        &self,
431        repo_path: PathBuf,
432    ) -> GitFuture<Result<Vec<String>, GitError>>;
433
434    /// Returns commit subjects that exist in local `HEAD` but not in upstream.
435    ///
436    /// # Errors
437    /// Returns an error when upstream tracking data or commit history cannot be
438    /// read.
439    fn list_local_commit_titles(
440        &self,
441        repo_path: PathBuf,
442    ) -> GitFuture<Result<Vec<String>, GitError>>;
443
444    /// Reads the configured origin URL for `repo_path`.
445    ///
446    /// # Errors
447    /// Returns an error when origin is missing or cannot be resolved.
448    fn repo_url(&self, repo_path: PathBuf) -> GitFuture<Result<String, GitError>>;
449
450    /// Resolves the main repository root for a repository or worktree path.
451    ///
452    /// # Errors
453    /// Returns an error when the main repository cannot be resolved.
454    fn main_repo_root(&self, repo_path: PathBuf) -> GitFuture<Result<PathBuf, GitError>>;
455
456    /// Resolves the main working checkout for a repository or worktree path.
457    ///
458    /// Returns `None` when the shared repository is bare, because a bare
459    /// repository has no main working checkout.
460    ///
461    /// # Errors
462    /// Returns an error when the shared repository cannot be resolved.
463    fn main_checkout_working_tree(
464        &self,
465        repo_path: PathBuf,
466    ) -> GitFuture<Result<Option<PathBuf>, GitError>>;
467}
468
469/// Production [`GitClient`] implementation backed by real git commands.
470pub struct RealGitClient;
471
472impl GitClient for RealGitClient {
473    fn detect_git_info(&self, dir: PathBuf) -> GitFuture<Option<String>> {
474        Box::pin(async move { detect_git_info(dir).await })
475    }
476
477    fn find_git_repo_root(&self, dir: PathBuf) -> GitFuture<Option<PathBuf>> {
478        Box::pin(async move { find_git_repo_root(dir).await })
479    }
480
481    fn check_pre_commit_hook_ready(&self, repo_path: PathBuf) -> GitFuture<Result<(), GitError>> {
482        Box::pin(async move { check_pre_commit_hook_ready(repo_path).await })
483    }
484
485    fn run_pre_commit_hook(&self, repo_path: PathBuf) -> GitFuture<Result<(), GitError>> {
486        Box::pin(async move { run_pre_commit_hook(repo_path).await })
487    }
488
489    fn create_worktree(
490        &self,
491        repo_path: PathBuf,
492        worktree_path: PathBuf,
493        branch_name: String,
494        start_ref: String,
495    ) -> GitFuture<Result<(), GitError>> {
496        Box::pin(
497            async move { create_worktree(repo_path, worktree_path, branch_name, start_ref).await },
498        )
499    }
500
501    fn remove_worktree(&self, worktree_path: PathBuf) -> GitFuture<Result<(), GitError>> {
502        Box::pin(async move { remove_worktree(worktree_path).await })
503    }
504
505    fn squash_merge_diff(
506        &self,
507        repo_path: PathBuf,
508        source_branch: String,
509        target_branch: String,
510    ) -> GitFuture<Result<String, GitError>> {
511        Box::pin(async move { squash_merge_diff(repo_path, source_branch, target_branch).await })
512    }
513
514    fn squash_merge(
515        &self,
516        repo_path: PathBuf,
517        source_branch: String,
518        target_branch: String,
519        commit_message: String,
520    ) -> GitFuture<Result<SquashMergeOutcome, GitError>> {
521        Box::pin(async move {
522            squash_merge(repo_path, source_branch, target_branch, commit_message).await
523        })
524    }
525
526    fn rebase(&self, repo_path: PathBuf, target_branch: String) -> GitFuture<Result<(), GitError>> {
527        Box::pin(async move { rebase::rebase(repo_path, target_branch).await })
528    }
529
530    fn rebase_start(
531        &self,
532        repo_path: PathBuf,
533        target_branch: String,
534    ) -> GitFuture<Result<RebaseStepResult, GitError>> {
535        Box::pin(async move { rebase_start(repo_path, target_branch).await })
536    }
537
538    fn rebase_onto_start(
539        &self,
540        repo_path: PathBuf,
541        new_base: String,
542        old_base: String,
543    ) -> GitFuture<Result<RebaseStepResult, GitError>> {
544        Box::pin(async move { rebase_onto_start(repo_path, new_base, old_base).await })
545    }
546
547    fn rebase_continue(&self, repo_path: PathBuf) -> GitFuture<Result<RebaseStepResult, GitError>> {
548        Box::pin(async move { rebase_continue(repo_path).await })
549    }
550
551    fn abort_rebase(&self, repo_path: PathBuf) -> GitFuture<Result<(), GitError>> {
552        Box::pin(async move { abort_rebase(repo_path).await })
553    }
554
555    fn is_rebase_in_progress(&self, repo_path: PathBuf) -> GitFuture<Result<bool, GitError>> {
556        Box::pin(async move { is_rebase_in_progress(repo_path).await })
557    }
558
559    fn in_progress_operation(
560        &self,
561        repo_path: PathBuf,
562    ) -> GitFuture<Result<Option<InProgressGitOperation>, GitError>> {
563        Box::pin(async move { in_progress_operation(repo_path).await })
564    }
565
566    fn has_unmerged_paths(&self, repo_path: PathBuf) -> GitFuture<Result<bool, GitError>> {
567        Box::pin(async move { has_unmerged_paths(repo_path).await })
568    }
569
570    fn list_staged_conflict_marker_files(
571        &self,
572        repo_path: PathBuf,
573        paths: Vec<String>,
574    ) -> GitFuture<Result<Vec<String>, GitError>> {
575        Box::pin(async move { list_staged_conflict_marker_files(repo_path, paths).await })
576    }
577
578    fn list_conflicted_files(
579        &self,
580        repo_path: PathBuf,
581    ) -> GitFuture<Result<Vec<String>, GitError>> {
582        Box::pin(async move { list_conflicted_files(repo_path).await })
583    }
584
585    fn commit_all(&self, repo_path: PathBuf, message: String) -> GitFuture<Result<(), GitError>> {
586        Box::pin(async move { commit_all(repo_path, message).await })
587    }
588
589    fn commit_all_preserving_single_commit(
590        &self,
591        repo_path: PathBuf,
592        base_branch: String,
593        commit_message: String,
594        message_strategy: SingleCommitMessageStrategy,
595    ) -> GitFuture<Result<(), GitError>> {
596        Box::pin(async move {
597            commit_all_preserving_single_commit(
598                repo_path,
599                base_branch,
600                commit_message,
601                message_strategy,
602            )
603            .await
604        })
605    }
606
607    fn stage_all(&self, repo_path: PathBuf) -> GitFuture<Result<(), GitError>> {
608        Box::pin(async move { stage_all(repo_path).await })
609    }
610
611    fn head_short_hash(&self, repo_path: PathBuf) -> GitFuture<Result<String, GitError>> {
612        Box::pin(async move { head_short_hash(repo_path).await })
613    }
614
615    fn head_hash(&self, repo_path: PathBuf) -> GitFuture<Result<String, GitError>> {
616        Box::pin(async move { head_hash(repo_path).await })
617    }
618
619    fn ref_hash(
620        &self,
621        repo_path: PathBuf,
622        reference: String,
623    ) -> GitFuture<Result<String, GitError>> {
624        Box::pin(async move { ref_hash(repo_path, reference).await })
625    }
626
627    fn head_commit_message(
628        &self,
629        repo_path: PathBuf,
630    ) -> GitFuture<Result<Option<String>, GitError>> {
631        Box::pin(async move { head_commit_message(repo_path).await })
632    }
633
634    fn delete_branch(
635        &self,
636        repo_path: PathBuf,
637        branch_name: String,
638    ) -> GitFuture<Result<(), GitError>> {
639        Box::pin(async move { delete_branch(repo_path, branch_name).await })
640    }
641
642    fn diff(&self, repo_path: PathBuf, base_branch: String) -> GitFuture<Result<String, GitError>> {
643        Box::pin(async move { diff(repo_path, base_branch).await })
644    }
645
646    fn diff_changed_files(
647        &self,
648        repo_path: PathBuf,
649        base_branch: String,
650    ) -> GitFuture<Result<Vec<String>, GitError>> {
651        Box::pin(async move { diff_changed_files(repo_path, base_branch).await })
652    }
653
654    fn read_worktree_file(
655        &self,
656        repo_path: PathBuf,
657        relative_path: String,
658    ) -> GitFuture<Result<WorktreeFileContent, GitError>> {
659        Box::pin(async move { sync::read_worktree_file(repo_path, relative_path).await })
660    }
661
662    fn is_worktree_clean(&self, repo_path: PathBuf) -> GitFuture<Result<bool, GitError>> {
663        Box::pin(async move { is_worktree_clean(repo_path).await })
664    }
665
666    fn worktree_status(&self, repo_path: PathBuf) -> GitFuture<Result<String, GitError>> {
667        Box::pin(async move { worktree_status(repo_path).await })
668    }
669
670    fn tracked_worktree_status(&self, repo_path: PathBuf) -> GitFuture<Result<String, GitError>> {
671        Box::pin(async move { tracked_worktree_status(repo_path).await })
672    }
673
674    fn has_commits_since(
675        &self,
676        repo_path: PathBuf,
677        base_branch: String,
678    ) -> GitFuture<Result<bool, GitError>> {
679        Box::pin(async move { has_commits_since(repo_path, base_branch).await })
680    }
681
682    fn pull_rebase(&self, repo_path: PathBuf) -> GitFuture<Result<PullRebaseResult, GitError>> {
683        Box::pin(async move { pull_rebase(repo_path).await })
684    }
685
686    fn push_current_branch(&self, repo_path: PathBuf) -> GitFuture<Result<String, GitError>> {
687        Box::pin(async move { push_current_branch(repo_path).await })
688    }
689
690    fn push_current_branch_to_remote_branch(
691        &self,
692        repo_path: PathBuf,
693        remote_branch_name: String,
694    ) -> GitFuture<Result<String, GitError>> {
695        Box::pin(async move {
696            push_current_branch_to_remote_branch(repo_path, remote_branch_name).await
697        })
698    }
699
700    fn push_current_branch_to_new_remote_branch(
701        &self,
702        repo_path: PathBuf,
703        remote_branch_name: String,
704    ) -> GitFuture<Result<String, GitError>> {
705        Box::pin(async move {
706            push_current_branch_to_new_remote_branch(repo_path, remote_branch_name).await
707        })
708    }
709
710    fn remote_branch_exists(
711        &self,
712        repo_path: PathBuf,
713        remote_branch_name: String,
714    ) -> GitFuture<Result<bool, GitError>> {
715        Box::pin(async move { remote_branch_exists(repo_path, remote_branch_name).await })
716    }
717
718    fn current_upstream_reference(
719        &self,
720        repo_path: PathBuf,
721    ) -> GitFuture<Result<String, GitError>> {
722        Box::pin(async move { current_upstream_reference(repo_path).await })
723    }
724
725    fn fetch_remote(&self, repo_path: PathBuf) -> GitFuture<Result<(), GitError>> {
726        Box::pin(async move { fetch_remote(repo_path).await })
727    }
728
729    fn get_ahead_behind(&self, repo_path: PathBuf) -> GitFuture<Result<(u32, u32), GitError>> {
730        Box::pin(async move { get_ahead_behind(repo_path).await })
731    }
732
733    fn get_ref_ahead_behind(
734        &self,
735        repo_path: PathBuf,
736        left_ref: String,
737        right_ref: String,
738    ) -> GitFuture<Result<(u32, u32), GitError>> {
739        Box::pin(async move { get_ref_ahead_behind(repo_path, left_ref, right_ref).await })
740    }
741
742    fn has_merge_conflicts(
743        &self,
744        repo_path: PathBuf,
745        source_branch: String,
746        target_branch: String,
747    ) -> GitFuture<Result<bool, GitError>> {
748        Box::pin(async move { has_merge_conflicts(repo_path, source_branch, target_branch).await })
749    }
750
751    fn branch_tracking_statuses(
752        &self,
753        repo_path: PathBuf,
754    ) -> GitFuture<Result<BranchTrackingMap, GitError>> {
755        Box::pin(async move { branch_tracking_statuses(repo_path).await })
756    }
757
758    fn list_upstream_commit_titles(
759        &self,
760        repo_path: PathBuf,
761    ) -> GitFuture<Result<Vec<String>, GitError>> {
762        Box::pin(async move { list_upstream_commit_titles(repo_path).await })
763    }
764
765    fn list_local_commit_titles(
766        &self,
767        repo_path: PathBuf,
768    ) -> GitFuture<Result<Vec<String>, GitError>> {
769        Box::pin(async move { list_local_commit_titles(repo_path).await })
770    }
771
772    fn repo_url(&self, repo_path: PathBuf) -> GitFuture<Result<String, GitError>> {
773        Box::pin(async move { repo_url(repo_path).await })
774    }
775
776    fn main_repo_root(&self, repo_path: PathBuf) -> GitFuture<Result<PathBuf, GitError>> {
777        Box::pin(async move { main_repo_root(repo_path).await })
778    }
779
780    fn main_checkout_working_tree(
781        &self,
782        repo_path: PathBuf,
783    ) -> GitFuture<Result<Option<PathBuf>, GitError>> {
784        Box::pin(async move { main_checkout_working_tree(repo_path).await })
785    }
786}
787
788#[cfg(test)]
789mod tests {
790    use std::fs;
791    use std::path::{Path, PathBuf};
792    use std::process::Command;
793    use std::time::Duration;
794
795    use tempfile::tempdir;
796
797    use super::*;
798
799    /// Canonicalizes a test path for stable comparisons across symlinked
800    /// temporary directory roots (for example `/var` vs `/private/var`).
801    fn canonicalize_test_path(path: &Path) -> PathBuf {
802        fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
803    }
804
805    fn run_git_command(repo_path: &Path, args: &[&str]) {
806        let output = Command::new("git")
807            .args(args)
808            .current_dir(repo_path)
809            .output()
810            .expect("failed to run git command");
811
812        assert!(
813            output.status.success(),
814            "git command {:?} failed: {}",
815            args,
816            String::from_utf8_lossy(&output.stderr)
817        );
818    }
819
820    fn run_git_command_stdout(repo_path: &Path, args: &[&str]) -> String {
821        let output = Command::new("git")
822            .args(args)
823            .current_dir(repo_path)
824            .output()
825            .expect("failed to run git command");
826
827        assert!(
828            output.status.success(),
829            "git command {:?} failed: {}",
830            args,
831            String::from_utf8_lossy(&output.stderr)
832        );
833
834        String::from_utf8_lossy(&output.stdout).trim().to_string()
835    }
836
837    fn setup_test_git_repo(repo_path: &Path) {
838        run_git_command(repo_path, &["init", "-b", "main"]);
839        run_git_command(repo_path, &["config", "user.name", "Test User"]);
840        run_git_command(repo_path, &["config", "user.email", "test@example.com"]);
841
842        fs::write(repo_path.join("README.md"), "test repo").expect("failed to write file");
843        run_git_command(repo_path, &["add", "README.md"]);
844        run_git_command(repo_path, &["commit", "-m", "Initial commit"]);
845    }
846
847    #[tokio::test]
848    async fn test_real_git_client_runs_hook_checks_and_commits() {
849        // Arrange
850        let dir = tempdir().expect("failed to create temp dir");
851        setup_test_git_repo(dir.path());
852        fs::write(dir.path().join("README.md"), "updated repo")
853            .expect("failed to update tracked file");
854        let client = RealGitClient;
855
856        // Act
857        client
858            .check_pre_commit_hook_ready(dir.path().to_path_buf())
859            .await
860            .expect("repository without hook configuration should be ready");
861        client
862            .run_pre_commit_hook(dir.path().to_path_buf())
863            .await
864            .expect("missing pre-commit hook should be accepted");
865        client
866            .commit_all(
867                dir.path().to_path_buf(),
868                "Update repository documentation".to_string(),
869            )
870            .await
871            .expect("real git client should commit changes");
872
873        // Assert
874        assert_eq!(
875            run_git_command_stdout(dir.path(), &["log", "-1", "--pretty=%s"]),
876            "Update repository documentation"
877        );
878    }
879
880    #[tokio::test]
881    async fn test_real_git_client_detects_merge_conflicts() {
882        // Arrange
883        let dir = tempdir().expect("failed to create temp dir");
884        setup_test_git_repo(dir.path());
885        run_git_command(dir.path(), &["checkout", "-b", "session-branch"]);
886        fs::write(dir.path().join("README.md"), "session content")
887            .expect("failed to write session content");
888        run_git_command(dir.path(), &["add", "README.md"]);
889        run_git_command(dir.path(), &["commit", "-m", "Session change"]);
890        run_git_command(dir.path(), &["checkout", "main"]);
891        fs::write(dir.path().join("README.md"), "main content")
892            .expect("failed to write main content");
893        run_git_command(dir.path(), &["add", "README.md"]);
894        run_git_command(dir.path(), &["commit", "-m", "Main change"]);
895        let client = RealGitClient;
896
897        // Act
898        let has_conflicts = client
899            .has_merge_conflicts(
900                dir.path().to_path_buf(),
901                "session-branch".to_string(),
902                "main".to_string(),
903            )
904            .await
905            .expect("merge conflict query should succeed");
906
907        // Assert
908        assert!(has_conflicts);
909    }
910
911    #[tokio::test]
912    async fn test_real_git_client_reads_worktree_file() {
913        // Arrange
914        let dir = tempdir().expect("failed to create temp dir");
915        fs::write(dir.path().join("README.md"), "# Preview")
916            .expect("failed to write markdown file");
917        let client = RealGitClient;
918
919        // Act
920        let result = client
921            .read_worktree_file(dir.path().to_path_buf(), "README.md".to_string())
922            .await
923            .expect("failed to read worktree file");
924
925        // Assert
926        assert_eq!(result, WorktreeFileContent::Text("# Preview".to_string()));
927    }
928
929    #[tokio::test]
930    async fn test_real_git_client_lists_changed_files() {
931        // Arrange
932        let dir = tempdir().expect("failed to create temp dir");
933        setup_test_git_repo(dir.path());
934        fs::write(dir.path().join("new.txt"), "new content").expect("failed to write changed file");
935        let client = RealGitClient;
936
937        // Act
938        let changed_files = client
939            .diff_changed_files(dir.path().to_path_buf(), "main".to_string())
940            .await
941            .expect("failed to list changed files");
942
943        // Assert
944        assert_eq!(changed_files, vec!["new.txt".to_string()]);
945    }
946
947    #[tokio::test]
948    async fn test_real_git_client_pushes_new_remote_branch() {
949        // Arrange
950        let repo_dir = tempdir().expect("failed to create temp dir");
951        let remote_dir = tempdir().expect("failed to create remote temp dir");
952        setup_test_git_repo(repo_dir.path());
953        run_git_command(remote_dir.path(), &["init", "--bare"]);
954        let remote_path = remote_dir.path().to_string_lossy().to_string();
955        run_git_command(repo_dir.path(), &["remote", "add", "origin", &remote_path]);
956        let client = RealGitClient;
957
958        // Act
959        let upstream_reference = client
960            .push_current_branch_to_new_remote_branch(
961                repo_dir.path().to_path_buf(),
962                "review/new-branch".to_string(),
963            )
964            .await
965            .expect("new remote branch push should succeed");
966        let local_head = run_git_command_stdout(repo_dir.path(), &["rev-parse", "HEAD"]);
967        let remote_head = run_git_command_stdout(
968            remote_dir.path(),
969            &["rev-parse", "refs/heads/review/new-branch"],
970        );
971
972        // Assert
973        assert_eq!(upstream_reference, "origin/review/new-branch");
974        assert_eq!(local_head, remote_head);
975    }
976
977    #[tokio::test]
978    async fn test_squash_merge_returns_committed_when_changes_exist() {
979        // Arrange
980        let dir = tempdir().expect("failed to create temp dir");
981        setup_test_git_repo(dir.path());
982        run_git_command(dir.path(), &["checkout", "-b", "feature-branch"]);
983        fs::write(dir.path().join("feature.txt"), "feature content").expect("failed to write file");
984        run_git_command(dir.path(), &["add", "feature.txt"]);
985        run_git_command(dir.path(), &["commit", "-m", "Add feature"]);
986        run_git_command(dir.path(), &["checkout", "main"]);
987
988        // Act
989        let result = squash_merge(
990            dir.path().to_path_buf(),
991            "feature-branch".to_string(),
992            "main".to_string(),
993            "Squash merge feature".to_string(),
994        )
995        .await;
996
997        // Assert
998        assert_eq!(
999            result.expect("squash merge should succeed"),
1000            SquashMergeOutcome::Committed,
1001        );
1002    }
1003
1004    #[tokio::test]
1005    async fn test_squash_merge_returns_already_present_when_changes_exist_in_target() {
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("session.txt"), "session change").expect("failed to write file");
1011        run_git_command(dir.path(), &["add", "session.txt"]);
1012        run_git_command(dir.path(), &["commit", "-m", "Session change"]);
1013        run_git_command(dir.path(), &["checkout", "main"]);
1014        fs::write(dir.path().join("session.txt"), "session change").expect("failed to write file");
1015        run_git_command(dir.path(), &["add", "session.txt"]);
1016        run_git_command(dir.path(), &["commit", "-m", "Apply same change on main"]);
1017
1018        // Act
1019        let result = squash_merge(
1020            dir.path().to_path_buf(),
1021            "session-branch".to_string(),
1022            "main".to_string(),
1023            "Merge session".to_string(),
1024        )
1025        .await;
1026
1027        // Assert
1028        assert_eq!(
1029            result.expect("squash merge should succeed"),
1030            SquashMergeOutcome::AlreadyPresentInTarget,
1031        );
1032    }
1033
1034    #[tokio::test]
1035    async fn test_commit_all_preserving_single_commit_creates_first_commit() {
1036        // Arrange
1037        let dir = tempdir().expect("failed to create temp dir");
1038        setup_test_git_repo(dir.path());
1039        run_git_command(dir.path(), &["checkout", "-b", "session-branch"]);
1040        let commit_message = "Session commit".to_string();
1041        fs::write(dir.path().join("work.txt"), "first change").expect("failed to write file");
1042
1043        // Act
1044        let result = commit_all_preserving_single_commit(
1045            dir.path().to_path_buf(),
1046            "main".to_string(),
1047            commit_message.clone(),
1048            SingleCommitMessageStrategy::Replace,
1049        )
1050        .await;
1051        let commit_count = run_git_command_stdout(dir.path(), &["rev-list", "--count", "HEAD"]);
1052        let head_message = run_git_command_stdout(dir.path(), &["log", "-1", "--pretty=%B"]);
1053
1054        // Assert
1055        assert!(
1056            result.is_ok(),
1057            "commit_all_preserving_single_commit should succeed: {result:?}"
1058        );
1059        assert_eq!(commit_count, "2");
1060        assert_eq!(head_message, commit_message);
1061    }
1062
1063    #[tokio::test]
1064    async fn test_commit_all_preserving_single_commit_amends_existing_session_commit() {
1065        // Arrange
1066        let dir = tempdir().expect("failed to create temp dir");
1067        setup_test_git_repo(dir.path());
1068        run_git_command(dir.path(), &["checkout", "-b", "session-branch"]);
1069        let commit_message = "Session commit".to_string();
1070        fs::write(dir.path().join("work.txt"), "first change").expect("failed to write file");
1071        commit_all_preserving_single_commit(
1072            dir.path().to_path_buf(),
1073            "main".to_string(),
1074            commit_message.clone(),
1075            SingleCommitMessageStrategy::Replace,
1076        )
1077        .await
1078        .expect("failed to create first session commit");
1079        let first_hash = run_git_command_stdout(dir.path(), &["rev-parse", "HEAD"]);
1080        let first_count = run_git_command_stdout(dir.path(), &["rev-list", "--count", "HEAD"]);
1081
1082        // Act
1083        fs::write(dir.path().join("work.txt"), "second change").expect("failed to write file");
1084        let result = commit_all_preserving_single_commit(
1085            dir.path().to_path_buf(),
1086            "main".to_string(),
1087            commit_message.clone(),
1088            SingleCommitMessageStrategy::Replace,
1089        )
1090        .await;
1091        let second_hash = run_git_command_stdout(dir.path(), &["rev-parse", "HEAD"]);
1092        let second_count = run_git_command_stdout(dir.path(), &["rev-list", "--count", "HEAD"]);
1093
1094        // Assert
1095        assert!(result.is_ok(), "amend commit should succeed: {result:?}");
1096        assert_ne!(first_hash, second_hash);
1097        assert_eq!(first_count, second_count);
1098    }
1099
1100    #[tokio::test]
1101    async fn test_commit_all_preserving_single_commit_replaces_amended_message() {
1102        // Arrange
1103        let dir = tempdir().expect("failed to create temp dir");
1104        setup_test_git_repo(dir.path());
1105        run_git_command(dir.path(), &["checkout", "-b", "session-branch"]);
1106        fs::write(dir.path().join("work.txt"), "first change").expect("failed to write file");
1107        commit_all_preserving_single_commit(
1108            dir.path().to_path_buf(),
1109            "main".to_string(),
1110            "First session message".to_string(),
1111            SingleCommitMessageStrategy::Replace,
1112        )
1113        .await
1114        .expect("failed to create first session commit");
1115
1116        // Act
1117        fs::write(dir.path().join("work.txt"), "second change").expect("failed to write file");
1118        let result = commit_all_preserving_single_commit(
1119            dir.path().to_path_buf(),
1120            "main".to_string(),
1121            "Refined session message".to_string(),
1122            SingleCommitMessageStrategy::Replace,
1123        )
1124        .await;
1125        let head_message = run_git_command_stdout(dir.path(), &["log", "-1", "--pretty=%B"]);
1126
1127        // Assert
1128        assert!(
1129            result.is_ok(),
1130            "replace amended message should succeed: {result:?}"
1131        );
1132        assert_eq!(head_message, "Refined session message");
1133    }
1134
1135    #[tokio::test]
1136    async fn test_commit_all_preserving_single_commit_retries_index_lock_and_succeeds() {
1137        // Arrange
1138        let dir = tempdir().expect("failed to create temp dir");
1139        setup_test_git_repo(dir.path());
1140        run_git_command(dir.path(), &["checkout", "-b", "session-branch"]);
1141        let commit_message = "Session commit".to_string();
1142        fs::write(dir.path().join("work.txt"), "locked change").expect("failed to write file");
1143        let index_lock_path = dir.path().join(".git").join("index.lock");
1144        fs::write(&index_lock_path, "active writer").expect("failed to write lock file");
1145        let lock_cleanup = tokio::spawn(async move {
1146            tokio::time::sleep(Duration::from_secs(1)).await;
1147            fs::remove_file(index_lock_path).expect("writer should release its lock");
1148        });
1149
1150        // Act
1151        let result = commit_all_preserving_single_commit(
1152            dir.path().to_path_buf(),
1153            "main".to_string(),
1154            commit_message.clone(),
1155            SingleCommitMessageStrategy::Replace,
1156        )
1157        .await;
1158        lock_cleanup
1159            .await
1160            .expect("failed to join lock cleanup task");
1161        let head_message = run_git_command_stdout(dir.path(), &["log", "-1", "--pretty=%B"]);
1162
1163        // Assert
1164        assert!(
1165            result.is_ok(),
1166            "retry with index lock should succeed: {result:?}"
1167        );
1168        assert_eq!(head_message, commit_message);
1169    }
1170
1171    #[tokio::test]
1172    async fn test_diff_hides_leading_squash_merged_commit_for_non_rebased_session() {
1173        // Arrange
1174        let dir = tempdir().expect("failed to create temp dir");
1175        setup_test_git_repo(dir.path());
1176        run_git_command(dir.path(), &["checkout", "-b", "session-branch"]);
1177        fs::write(dir.path().join("merged.txt"), "already merged change")
1178            .expect("failed to write merged file");
1179        run_git_command(dir.path(), &["add", "merged.txt"]);
1180        run_git_command(dir.path(), &["commit", "-m", "Session change"]);
1181        run_git_command(dir.path(), &["checkout", "main"]);
1182        run_git_command(dir.path(), &["merge", "--squash", "session-branch"]);
1183        run_git_command(dir.path(), &["commit", "-m", "Squash merge session change"]);
1184        run_git_command(dir.path(), &["checkout", "session-branch"]);
1185
1186        // Act
1187        let diff_output = diff(dir.path().to_path_buf(), "main".to_string())
1188            .await
1189            .expect("failed to load diff");
1190
1191        // Assert
1192        assert!(
1193            diff_output.trim().is_empty(),
1194            "expected no diff, got: {diff_output}"
1195        );
1196    }
1197
1198    #[tokio::test]
1199    async fn test_diff_keeps_new_commits_after_leading_squash_merged_commit() {
1200        // Arrange
1201        let dir = tempdir().expect("failed to create temp dir");
1202        setup_test_git_repo(dir.path());
1203        run_git_command(dir.path(), &["checkout", "-b", "session-branch"]);
1204        fs::write(dir.path().join("merged.txt"), "already merged change")
1205            .expect("failed to write merged file");
1206        run_git_command(dir.path(), &["add", "merged.txt"]);
1207        run_git_command(dir.path(), &["commit", "-m", "Session change"]);
1208        run_git_command(dir.path(), &["checkout", "main"]);
1209        run_git_command(dir.path(), &["merge", "--squash", "session-branch"]);
1210        run_git_command(dir.path(), &["commit", "-m", "Squash merge session change"]);
1211        run_git_command(dir.path(), &["checkout", "session-branch"]);
1212        fs::write(dir.path().join("new.txt"), "new session-only change")
1213            .expect("failed to write new file");
1214        run_git_command(dir.path(), &["add", "new.txt"]);
1215        run_git_command(dir.path(), &["commit", "-m", "New session change"]);
1216
1217        // Act
1218        let diff_output = diff(dir.path().to_path_buf(), "main".to_string())
1219            .await
1220            .expect("failed to load diff");
1221
1222        // Assert
1223        assert!(diff_output.contains("new.txt"));
1224        assert!(!diff_output.contains("merged.txt"));
1225    }
1226
1227    #[tokio::test]
1228    async fn test_diff_does_not_include_base_only_commits() {
1229        // Arrange
1230        let dir = tempdir().expect("failed to create temp dir");
1231        setup_test_git_repo(dir.path());
1232        run_git_command(dir.path(), &["checkout", "-b", "session-branch"]);
1233        fs::write(dir.path().join("session.txt"), "session change").expect("failed to write file");
1234        run_git_command(dir.path(), &["add", "session.txt"]);
1235        run_git_command(dir.path(), &["commit", "-m", "Session change"]);
1236        run_git_command(dir.path(), &["checkout", "main"]);
1237        fs::write(dir.path().join("main-only.txt"), "base branch only")
1238            .expect("failed to write base-only file");
1239        run_git_command(dir.path(), &["add", "main-only.txt"]);
1240        run_git_command(dir.path(), &["commit", "-m", "Main branch change"]);
1241        run_git_command(dir.path(), &["checkout", "session-branch"]);
1242
1243        // Act
1244        let diff_output = diff(dir.path().to_path_buf(), "main".to_string())
1245            .await
1246            .expect("failed to load diff");
1247
1248        // Assert
1249        assert!(diff_output.contains("session.txt"));
1250        assert!(!diff_output.contains("main-only.txt"));
1251    }
1252
1253    #[tokio::test]
1254    async fn test_is_worktree_clean_returns_true_for_clean_repo() {
1255        // Arrange
1256        let dir = tempdir().expect("failed to create temp dir");
1257        setup_test_git_repo(dir.path());
1258
1259        // Act
1260        let is_clean = is_worktree_clean(dir.path().to_path_buf())
1261            .await
1262            .expect("failed to check worktree cleanliness");
1263
1264        // Assert
1265        assert!(is_clean);
1266    }
1267
1268    #[tokio::test]
1269    async fn test_is_worktree_clean_returns_false_for_dirty_repo() {
1270        // Arrange
1271        let dir = tempdir().expect("failed to create temp dir");
1272        setup_test_git_repo(dir.path());
1273        fs::write(dir.path().join("README.md"), "dirty change").expect("failed to write change");
1274
1275        // Act
1276        let is_clean = is_worktree_clean(dir.path().to_path_buf())
1277            .await
1278            .expect("failed to check worktree cleanliness");
1279
1280        // Assert
1281        assert!(!is_clean);
1282    }
1283
1284    #[tokio::test]
1285    async fn test_worktree_status_reports_dirty_repo_paths() {
1286        // Arrange
1287        let dir = tempdir().expect("failed to create temp dir");
1288        setup_test_git_repo(dir.path());
1289        fs::write(dir.path().join("README.md"), "dirty change").expect("failed to write change");
1290        fs::write(dir.path().join("new-file.txt"), "new").expect("failed to write new file");
1291
1292        // Act
1293        let status = worktree_status(dir.path().to_path_buf())
1294            .await
1295            .expect("failed to read worktree status");
1296
1297        // Assert
1298        assert!(status.contains("README.md"));
1299        assert!(status.contains("new-file.txt"));
1300    }
1301
1302    #[tokio::test]
1303    async fn test_status_reads_preserve_index_with_stale_file_metadata() {
1304        // Arrange
1305        let dir = tempdir().expect("failed to create temp dir");
1306        setup_test_git_repo(dir.path());
1307        let index_path = dir.path().join(".git/index");
1308        let original_index = fs::read(&index_path).expect("failed to read index");
1309        let readme = fs::File::options()
1310            .write(true)
1311            .open(dir.path().join("README.md"))
1312            .expect("failed to open tracked file");
1313        readme
1314            .set_times(fs::FileTimes::new().set_modified(std::time::SystemTime::UNIX_EPOCH))
1315            .expect("failed to invalidate cached file metadata");
1316
1317        // Act
1318        let status = worktree_status(dir.path().to_path_buf())
1319            .await
1320            .expect("failed to read worktree status");
1321        let tracked_status = tracked_worktree_status(dir.path().to_path_buf())
1322            .await
1323            .expect("failed to read tracked status");
1324        let sync_status = crate::repo::run_git_command_sync(
1325            dir.path(),
1326            &["status", "--porcelain"],
1327            "Failed to read synchronous status",
1328        )
1329        .expect("failed to read synchronous status");
1330
1331        // Assert
1332        assert_eq!(status, "");
1333        assert_eq!(tracked_status, "");
1334        assert_eq!(sync_status, "");
1335        assert_eq!(
1336            fs::read(&index_path).expect("failed to read index"),
1337            original_index
1338        );
1339        // Prove the fixture actually needs an index refresh when optional
1340        // writes are enabled, rather than merely reading an unchanged index.
1341        let refresh = Command::new("git")
1342            .args(["status", "--porcelain"])
1343            .env("GIT_OPTIONAL_LOCKS", "1")
1344            .current_dir(dir.path())
1345            .output()
1346            .expect("failed to refresh index");
1347        assert!(refresh.status.success());
1348        assert_ne!(
1349            fs::read(index_path).expect("failed to read refreshed index"),
1350            original_index
1351        );
1352    }
1353
1354    #[tokio::test]
1355    async fn test_tracked_worktree_status_ignores_untracked_repo_paths() {
1356        // Arrange
1357        let dir = tempdir().expect("failed to create temp dir");
1358        setup_test_git_repo(dir.path());
1359        fs::write(dir.path().join("README.md"), "dirty change").expect("failed to write change");
1360        fs::write(dir.path().join("new-file.txt"), "new").expect("failed to write new file");
1361
1362        // Act
1363        let status = tracked_worktree_status(dir.path().to_path_buf())
1364            .await
1365            .expect("failed to read tracked worktree status");
1366
1367        // Assert
1368        assert!(status.contains("README.md"));
1369        assert!(!status.contains("new-file.txt"));
1370    }
1371
1372    #[tokio::test]
1373    async fn test_main_repo_root_returns_repo_root_for_main_worktree() {
1374        // Arrange
1375        let dir = tempdir().expect("failed to create temp dir");
1376        setup_test_git_repo(dir.path());
1377
1378        // Act
1379        let repo_root = main_repo_root(dir.path().to_path_buf())
1380            .await
1381            .expect("failed to resolve main repo root");
1382
1383        // Assert
1384        assert_eq!(
1385            canonicalize_test_path(&repo_root),
1386            canonicalize_test_path(dir.path())
1387        );
1388    }
1389
1390    #[tokio::test]
1391    async fn test_main_repo_root_returns_shared_repo_root_for_linked_worktree() {
1392        // Arrange
1393        let dir = tempdir().expect("failed to create temp dir");
1394        setup_test_git_repo(dir.path());
1395        let linked_worktree = dir.path().join("linked-worktree");
1396        create_worktree(
1397            dir.path().to_path_buf(),
1398            linked_worktree.clone(),
1399            "wt/main-repo-root-test".to_string(),
1400            "main".to_string(),
1401        )
1402        .await
1403        .expect("failed to create linked worktree");
1404
1405        // Act
1406        let repo_root = main_repo_root(linked_worktree)
1407            .await
1408            .expect("failed to resolve shared repo root");
1409
1410        // Assert
1411        assert_eq!(
1412            canonicalize_test_path(&repo_root),
1413            canonicalize_test_path(dir.path())
1414        );
1415    }
1416
1417    #[tokio::test]
1418    async fn test_abort_rebase_returns_error_without_rebase_state_or_stale_metadata() {
1419        // Arrange
1420        let dir = tempdir().expect("failed to create temp dir");
1421        setup_test_git_repo(dir.path());
1422
1423        // Act
1424        let result = abort_rebase(dir.path().to_path_buf()).await;
1425
1426        // Assert
1427        assert!(result.is_err());
1428    }
1429
1430    #[tokio::test]
1431    async fn test_ref_hash_resolves_branch_head() {
1432        // Arrange
1433        let dir = tempdir().expect("failed to create temp dir");
1434        setup_test_git_repo(dir.path());
1435        let expected_hash = run_git_command_stdout(dir.path(), &["rev-parse", "main"]);
1436
1437        // Act
1438        let resolved_hash = ref_hash(dir.path().to_path_buf(), "main".to_string())
1439            .await
1440            .expect("failed to resolve main hash");
1441
1442        // Assert
1443        assert_eq!(resolved_hash, expected_hash);
1444    }
1445
1446    #[tokio::test]
1447    async fn test_rebase_onto_start_replays_commits_after_old_base() {
1448        // Arrange
1449        let dir = tempdir().expect("failed to create temp dir");
1450        setup_test_git_repo(dir.path());
1451        run_git_command(dir.path(), &["checkout", "-b", "parent"]);
1452        fs::write(dir.path().join("parent.txt"), "parent").expect("failed to write parent file");
1453        run_git_command(dir.path(), &["add", "parent.txt"]);
1454        run_git_command(dir.path(), &["commit", "-m", "Parent change"]);
1455        let parent_tip = run_git_command_stdout(dir.path(), &["rev-parse", "HEAD"]);
1456        run_git_command(dir.path(), &["checkout", "-b", "child"]);
1457        fs::write(dir.path().join("child.txt"), "child").expect("failed to write child file");
1458        run_git_command(dir.path(), &["add", "child.txt"]);
1459        run_git_command(dir.path(), &["commit", "-m", "Child change"]);
1460        run_git_command(dir.path(), &["checkout", "main"]);
1461        fs::write(dir.path().join("main.txt"), "main").expect("failed to write main file");
1462        run_git_command(dir.path(), &["add", "main.txt"]);
1463        run_git_command(dir.path(), &["commit", "-m", "Main change"]);
1464        run_git_command(dir.path(), &["checkout", "child"]);
1465
1466        // Act
1467        let result = rebase_onto_start(dir.path().to_path_buf(), "main".to_string(), parent_tip)
1468            .await
1469            .expect("failed to start rebase --onto");
1470        let child_only_subjects = run_git_command_stdout(
1471            dir.path(),
1472            &["log", "--format=%s", "--reverse", "main..HEAD"],
1473        );
1474
1475        // Assert
1476        assert_eq!(result, RebaseStepResult::Completed);
1477        assert_eq!(child_only_subjects, "Child change");
1478        assert!(!dir.path().join("parent.txt").exists());
1479        assert!(dir.path().join("child.txt").exists());
1480    }
1481
1482    #[tokio::test]
1483    async fn test_pull_rebase_returns_error_without_upstream() {
1484        // Arrange
1485        let dir = tempdir().expect("failed to create temp dir");
1486        setup_test_git_repo(dir.path());
1487
1488        // Act
1489        let result = pull_rebase(dir.path().to_path_buf()).await;
1490
1491        // Assert
1492        assert!(result.is_err());
1493    }
1494
1495    #[tokio::test]
1496    async fn test_pull_rebase_targets_single_upstream_when_merge_targets_are_ambiguous() {
1497        // Arrange
1498        let dir = tempdir().expect("failed to create temp dir");
1499        let remote_dir = tempdir().expect("failed to create remote temp dir");
1500        setup_test_git_repo(dir.path());
1501        run_git_command(remote_dir.path(), &["init", "--bare"]);
1502
1503        let remote_path = remote_dir.path().to_string_lossy().to_string();
1504        run_git_command(dir.path(), &["remote", "add", "origin", &remote_path]);
1505        run_git_command(dir.path(), &["push", "-u", "origin", "main"]);
1506
1507        run_git_command(dir.path(), &["checkout", "-b", "feature"]);
1508        fs::write(dir.path().join("feature.txt"), "feature change").expect("failed to write file");
1509        run_git_command(dir.path(), &["add", "feature.txt"]);
1510        run_git_command(dir.path(), &["commit", "-m", "Add feature branch"]);
1511        run_git_command(dir.path(), &["push", "-u", "origin", "feature"]);
1512        run_git_command(dir.path(), &["checkout", "main"]);
1513
1514        run_git_command(
1515            dir.path(),
1516            &["config", "--add", "branch.main.merge", "refs/heads/feature"],
1517        );
1518
1519        let pull_without_explicit_target = Command::new("git")
1520            .args(["pull", "--rebase"])
1521            .current_dir(dir.path())
1522            .output()
1523            .expect("failed to run pull --rebase");
1524
1525        assert!(
1526            !pull_without_explicit_target.status.success(),
1527            "expected plain pull --rebase to fail in ambiguous merge-target setup"
1528        );
1529        assert!(
1530            String::from_utf8_lossy(&pull_without_explicit_target.stderr)
1531                .contains("Cannot rebase onto multiple branches"),
1532            "expected ambiguous merge-target failure"
1533        );
1534
1535        // Act
1536        let result = pull_rebase(dir.path().to_path_buf()).await;
1537
1538        // Assert
1539        assert!(
1540            matches!(result, Ok(PullRebaseResult::Completed)),
1541            "pull_rebase should complete: {result:?}"
1542        );
1543    }
1544
1545    #[tokio::test]
1546    async fn test_pull_rebase_targets_local_upstream_when_upstream_name_has_no_remote_prefix() {
1547        // Arrange
1548        let dir = tempdir().expect("failed to create temp dir");
1549        setup_test_git_repo(dir.path());
1550
1551        run_git_command(dir.path(), &["checkout", "-b", "feature"]);
1552        fs::write(dir.path().join("feature.txt"), "feature change").expect("failed to write file");
1553        run_git_command(dir.path(), &["add", "feature.txt"]);
1554        run_git_command(dir.path(), &["commit", "-m", "Add feature branch"]);
1555        run_git_command(dir.path(), &["checkout", "main"]);
1556
1557        run_git_command(dir.path(), &["config", "branch.main.remote", "."]);
1558        run_git_command(
1559            dir.path(),
1560            &[
1561                "config",
1562                "--replace-all",
1563                "branch.main.merge",
1564                "refs/heads/main",
1565            ],
1566        );
1567        run_git_command(
1568            dir.path(),
1569            &["config", "--add", "branch.main.merge", "refs/heads/feature"],
1570        );
1571
1572        let pull_without_explicit_target = Command::new("git")
1573            .args(["pull", "--rebase"])
1574            .current_dir(dir.path())
1575            .output()
1576            .expect("failed to run pull --rebase");
1577
1578        assert!(
1579            !pull_without_explicit_target.status.success(),
1580            "expected plain pull --rebase to fail in ambiguous merge-target setup"
1581        );
1582        assert!(
1583            String::from_utf8_lossy(&pull_without_explicit_target.stderr)
1584                .contains("Cannot rebase onto multiple branches"),
1585            "expected ambiguous merge-target failure"
1586        );
1587
1588        // Act
1589        let result = pull_rebase(dir.path().to_path_buf()).await;
1590
1591        // Assert
1592        assert!(
1593            matches!(result, Ok(PullRebaseResult::Completed)),
1594            "pull_rebase with local upstream should complete: {result:?}"
1595        );
1596    }
1597
1598    #[tokio::test]
1599    async fn test_list_upstream_commit_titles_returns_error_without_upstream() {
1600        // Arrange
1601        let dir = tempdir().expect("failed to create temp dir");
1602        setup_test_git_repo(dir.path());
1603
1604        // Act
1605        let result = list_upstream_commit_titles(dir.path().to_path_buf()).await;
1606
1607        // Assert
1608        assert!(result.is_err());
1609    }
1610
1611    #[tokio::test]
1612    async fn test_list_upstream_commit_titles_returns_new_upstream_commit_titles() {
1613        // Arrange
1614        let dir = tempdir().expect("failed to create temp dir");
1615        let remote_dir = tempdir().expect("failed to create remote temp dir");
1616        let contributor_dir = tempdir().expect("failed to create contributor temp dir");
1617        let contributor_clone_path = contributor_dir.path().join("clone");
1618        setup_test_git_repo(dir.path());
1619        run_git_command(remote_dir.path(), &["init", "--bare"]);
1620
1621        let remote_path = remote_dir.path().to_string_lossy().to_string();
1622        let contributor_clone_path_text = contributor_clone_path.to_string_lossy().to_string();
1623        run_git_command(dir.path(), &["remote", "add", "origin", &remote_path]);
1624        run_git_command(dir.path(), &["push", "-u", "origin", "main"]);
1625
1626        run_git_command(
1627            contributor_dir.path(),
1628            &["clone", &remote_path, &contributor_clone_path_text],
1629        );
1630        run_git_command(
1631            &contributor_clone_path,
1632            &["config", "user.name", "Contributor User"],
1633        );
1634        run_git_command(
1635            &contributor_clone_path,
1636            &["config", "user.email", "contributor@example.com"],
1637        );
1638        run_git_command(
1639            &contributor_clone_path,
1640            &["checkout", "-B", "main", "origin/main"],
1641        );
1642        fs::write(contributor_clone_path.join("remote.txt"), "remote change")
1643            .expect("failed to write remote change");
1644        run_git_command(&contributor_clone_path, &["add", "remote.txt"]);
1645        run_git_command(
1646            &contributor_clone_path,
1647            &["commit", "-m", "Remote commit title"],
1648        );
1649        run_git_command(&contributor_clone_path, &["push", "origin", "main"]);
1650        run_git_command(dir.path(), &["fetch", "origin"]);
1651
1652        // Act
1653        let titles = list_upstream_commit_titles(dir.path().to_path_buf())
1654            .await
1655            .expect("failed to list upstream commit titles");
1656
1657        // Assert
1658        assert_eq!(titles, vec!["Remote commit title".to_string()]);
1659    }
1660
1661    #[tokio::test]
1662    async fn test_list_local_commit_titles_returns_error_without_upstream() {
1663        // Arrange
1664        let dir = tempdir().expect("failed to create temp dir");
1665        setup_test_git_repo(dir.path());
1666
1667        // Act
1668        let result = list_local_commit_titles(dir.path().to_path_buf()).await;
1669
1670        // Assert
1671        assert!(result.is_err());
1672    }
1673
1674    #[tokio::test]
1675    async fn test_list_local_commit_titles_returns_new_local_commit_titles() {
1676        // Arrange
1677        let dir = tempdir().expect("failed to create temp dir");
1678        let remote_dir = tempdir().expect("failed to create remote temp dir");
1679        setup_test_git_repo(dir.path());
1680        run_git_command(remote_dir.path(), &["init", "--bare"]);
1681
1682        let remote_path = remote_dir.path().to_string_lossy().to_string();
1683        run_git_command(dir.path(), &["remote", "add", "origin", &remote_path]);
1684        run_git_command(dir.path(), &["push", "-u", "origin", "main"]);
1685
1686        fs::write(dir.path().join("local_1.txt"), "local change 1")
1687            .expect("failed to write local change 1");
1688        run_git_command(dir.path(), &["add", "local_1.txt"]);
1689        run_git_command(dir.path(), &["commit", "-m", "Local commit title one"]);
1690
1691        fs::write(dir.path().join("local_2.txt"), "local change 2")
1692            .expect("failed to write local change 2");
1693        run_git_command(dir.path(), &["add", "local_2.txt"]);
1694        run_git_command(dir.path(), &["commit", "-m", "Local commit title two"]);
1695
1696        // Act
1697        let titles = list_local_commit_titles(dir.path().to_path_buf())
1698            .await
1699            .expect("failed to list local commit titles");
1700
1701        // Assert
1702        assert_eq!(
1703            titles,
1704            vec![
1705                "Local commit title one".to_string(),
1706                "Local commit title two".to_string(),
1707            ]
1708        );
1709    }
1710
1711    #[tokio::test]
1712    async fn test_push_current_branch_returns_error_without_remote() {
1713        // Arrange
1714        let dir = tempdir().expect("failed to create temp dir");
1715        setup_test_git_repo(dir.path());
1716
1717        // Act
1718        let result = push_current_branch(dir.path().to_path_buf()).await;
1719
1720        // Assert
1721        assert!(result.is_err());
1722    }
1723
1724    #[tokio::test]
1725    async fn test_push_current_branch_returns_upstream_reference() {
1726        // Arrange
1727        let dir = tempdir().expect("failed to create temp dir");
1728        let remote_dir = tempdir().expect("failed to create remote temp dir");
1729        setup_test_git_repo(dir.path());
1730        run_git_command(remote_dir.path(), &["init", "--bare"]);
1731        let remote_path = remote_dir.path().to_string_lossy().to_string();
1732        run_git_command(dir.path(), &["remote", "add", "origin", &remote_path]);
1733
1734        // Act
1735        let upstream_reference = push_current_branch(dir.path().to_path_buf())
1736            .await
1737            .expect("push should set upstream");
1738
1739        // Assert
1740        assert_eq!(upstream_reference, "origin/main");
1741    }
1742
1743    #[tokio::test]
1744    async fn test_push_current_branch_to_remote_branch_returns_upstream_reference() {
1745        // Arrange
1746        let dir = tempdir().expect("failed to create temp dir");
1747        let remote_dir = tempdir().expect("failed to create remote temp dir");
1748        setup_test_git_repo(dir.path());
1749        run_git_command(remote_dir.path(), &["init", "--bare"]);
1750        let remote_path = remote_dir.path().to_string_lossy().to_string();
1751        run_git_command(dir.path(), &["remote", "add", "origin", &remote_path]);
1752
1753        // Act
1754        let upstream_reference = push_current_branch_to_remote_branch(
1755            dir.path().to_path_buf(),
1756            "review/custom-branch".to_string(),
1757        )
1758        .await
1759        .expect("push should set a custom upstream");
1760
1761        // Assert
1762        assert_eq!(upstream_reference, "origin/review/custom-branch");
1763    }
1764
1765    #[test]
1766    fn test_is_no_upstream_error_detects_upstream_hint() {
1767        // Arrange
1768        let detail = "fatal: The current branch main has no upstream branch.";
1769
1770        // Act
1771        let is_no_upstream = sync::is_no_upstream_error(detail);
1772
1773        // Assert
1774        assert!(is_no_upstream);
1775    }
1776
1777    #[test]
1778    fn test_is_rebase_conflict_detects_conflict_keyword() {
1779        // Arrange
1780        let detail = "CONFLICT (content): Merge conflict in src/main.rs";
1781
1782        // Act / Assert
1783        assert!(rebase::is_rebase_conflict(detail));
1784    }
1785
1786    #[test]
1787    fn test_is_rebase_conflict_detects_could_not_apply() {
1788        // Arrange
1789        let detail = "error: could not apply abc1234... Update handler";
1790
1791        // Act / Assert
1792        assert!(rebase::is_rebase_conflict(detail));
1793    }
1794
1795    #[test]
1796    fn test_is_rebase_conflict_detects_mark_as_resolved() {
1797        // Arrange
1798        let detail = "hint: mark them as resolved using git add";
1799
1800        // Act / Assert
1801        assert!(rebase::is_rebase_conflict(detail));
1802    }
1803
1804    #[test]
1805    fn test_is_rebase_conflict_detects_unresolved_conflict() {
1806        // Arrange
1807        let detail = "fatal: Exiting because of an unresolved conflict.";
1808
1809        // Act / Assert
1810        assert!(rebase::is_rebase_conflict(detail));
1811    }
1812
1813    #[test]
1814    fn test_is_rebase_conflict_detects_committing_not_possible() {
1815        // Arrange
1816        let detail = "error: Committing is not possible because you have unmerged files.";
1817
1818        // Act / Assert
1819        assert!(rebase::is_rebase_conflict(detail));
1820    }
1821
1822    #[test]
1823    fn test_is_rebase_conflict_returns_false_for_unrelated_error() {
1824        // Arrange
1825        let detail = "fatal: not a git repository (or any parent up to mount point /)";
1826
1827        // Act / Assert
1828        assert!(!rebase::is_rebase_conflict(detail));
1829    }
1830
1831    #[test]
1832    fn test_is_rebase_conflict_returns_false_for_index_lock_error() {
1833        // Arrange
1834        let detail = "fatal: Unable to create '.git/index.lock': File exists.";
1835
1836        // Act / Assert
1837        assert!(!rebase::is_rebase_conflict(detail));
1838    }
1839}