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