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