Skip to main content

git_wok/
repo.rs

1use std::{fmt, fs, io::ErrorKind, path, process::Command};
2
3use anyhow::*;
4use git2::StatusOptions;
5use git2::build::CheckoutBuilder;
6use std::result::Result::Ok;
7
8#[derive(Debug, Clone, PartialEq)]
9pub enum MergeResult {
10    UpToDate,
11    FastForward,
12    Merged,
13    Rebased,
14    Conflicts,
15}
16
17#[derive(Debug, Clone, PartialEq)]
18pub enum RemoteComparison {
19    UpToDate,
20    Ahead(usize),
21    Behind(usize),
22    Diverged(usize, usize),
23    NoRemote,
24}
25
26pub struct Repo {
27    pub git_repo: git2::Repository,
28    pub work_dir: path::PathBuf,
29    pub head: String,
30    pub subrepos: Vec<Repo>,
31}
32
33pub fn init_configured_submodules(
34    work_dir: &path::Path,
35    configured_paths: &[path::PathBuf],
36) -> Result<()> {
37    let git_repo = git2::Repository::open(work_dir)
38        .with_context(|| format!("Cannot open repo at `{}`", work_dir.display()))?;
39
40    // Initialize shallower paths first so nested configured paths can be
41    // initialized through already materialized parent worktrees.
42    let mut sorted_paths = configured_paths.to_vec();
43    sorted_paths.sort_by_key(|path| path.components().count());
44
45    for configured_path in &sorted_paths {
46        init_configured_submodule_path(&git_repo, work_dir, configured_path)
47            .with_context(|| {
48                format!(
49                    "Cannot initialize configured submodule `{}`",
50                    configured_path.display()
51                )
52            })?;
53    }
54
55    Ok(())
56}
57
58fn init_configured_submodule_path(
59    git_repo: &git2::Repository,
60    work_dir: &path::Path,
61    configured_path: &path::Path,
62) -> Result<()> {
63    if configured_path.as_os_str().is_empty() {
64        return Ok(());
65    }
66
67    let mut components = configured_path.components();
68    let first_component = match components.next() {
69        Some(component) => component.as_os_str(),
70        None => return Ok(()),
71    };
72
73    let first_component_str = first_component.to_str().with_context(|| {
74        format!(
75            "Configured submodule path `{}` is not valid UTF-8",
76            configured_path.display()
77        )
78    })?;
79
80    let mut submodule = match git_repo.find_submodule(first_component_str) {
81        Ok(submodule) => submodule,
82        Err(err) if err.code() == git2::ErrorCode::NotFound => return Ok(()),
83        Err(err) => return Err(err.into()),
84    };
85
86    submodule.init(false).with_context(|| {
87        format!(
88            "Cannot initialize submodule `{}` in `{}`",
89            first_component_str,
90            work_dir.display()
91        )
92    })?;
93
94    let submodule_work_dir = work_dir.join(first_component);
95    let is_initialized = submodule.open().is_ok();
96    if !is_initialized {
97        let module_git_dir = git_repo.path().join("modules").join(first_component);
98        if module_git_dir.exists() && !submodule_work_dir.exists() {
99            fs::create_dir_all(&submodule_work_dir).with_context(|| {
100                format!(
101                    "Cannot create submodule worktree directory `{}`",
102                    submodule_work_dir.display()
103                )
104            })?;
105        }
106
107        if let Err(initial_err) = submodule.update(false, None) {
108            submodule.update(true, None).with_context(|| {
109                format!(
110                    "Cannot update submodule `{}` in `{}` (initial attempt with init=false failed: {})",
111                    first_component_str,
112                    work_dir.display(),
113                    initial_err,
114                )
115            })?;
116        }
117    }
118
119    let remaining_path = components.as_path();
120    if remaining_path.as_os_str().is_empty() {
121        return Ok(());
122    }
123
124    let child_work_dir = submodule_work_dir;
125    let child_repo = git2::Repository::open(&child_work_dir).with_context(|| {
126        format!(
127            "Cannot open initialized submodule repo at `{}`",
128            child_work_dir.display()
129        )
130    })?;
131
132    init_configured_submodule_path(&child_repo, &child_work_dir, remaining_path)
133}
134
135impl Repo {
136    pub fn new(work_dir: &path::Path, head_name: Option<&str>) -> Result<Self> {
137        let git_repo = git2::Repository::open(work_dir)
138            .with_context(|| format!("Cannot open repo at `{}`", work_dir.display()))?;
139
140        let head = match head_name {
141            Some(name) => String::from(name),
142            None => {
143                let is_detached = git_repo.head_detached().with_context(|| {
144                    format!(
145                        "Cannot determine head state for repo at `{}`",
146                        work_dir.display()
147                    )
148                })?;
149                if is_detached {
150                    String::from("<detached>")
151                } else {
152                    String::from(git_repo.head().with_context(|| {
153                        format!(
154                            "Cannot find the head branch for repo at `{}`. Is it detached?",
155                            work_dir.display()
156                        )
157                    })?.shorthand().with_context(|| {
158                        format!(
159                            "Cannot find a human readable representation of the head ref for repo at `{}`",
160                            work_dir.display(),
161                        )
162                    })?)
163                }
164            },
165        };
166
167        let subrepos = git_repo
168            .submodules()
169            .with_context(|| {
170                format!(
171                    "Cannot load submodules for repo at `{}`",
172                    work_dir.display()
173                )
174            })?
175            .iter()
176            .map(|submodule| Repo::new(&work_dir.join(submodule.path()), None))
177            .collect::<Result<Vec<Repo>>>()?;
178
179        Ok(Repo {
180            git_repo,
181            work_dir: path::PathBuf::from(work_dir),
182            head,
183            subrepos,
184        })
185    }
186
187    pub fn get_subrepo_by_path(&self, subrepo_path: &path::PathBuf) -> Option<&Repo> {
188        self.subrepos
189            .iter()
190            .find(|subrepo| subrepo.work_dir == self.work_dir.join(subrepo_path))
191    }
192
193    pub fn sync(&self) -> Result<()> {
194        self.switch(&self.head)?;
195        Ok(())
196    }
197
198    pub fn uses_lfs(&self) -> Result<bool> {
199        let attributes_path = self.work_dir.join(".gitattributes");
200        if attributes_path.exists() {
201            let attributes =
202                fs::read_to_string(&attributes_path).with_context(|| {
203                    format!("Cannot read `{}`", attributes_path.display())
204                })?;
205            if attributes.lines().any(|line| {
206                let trimmed = line.trim();
207                !trimmed.is_empty()
208                    && !trimmed.starts_with('#')
209                    && trimmed.contains("filter=lfs")
210            }) {
211                return Ok(true);
212            }
213        }
214
215        if self.work_dir.join(".lfsconfig").exists() {
216            return Ok(true);
217        }
218
219        // Repository::path() points to the actual git directory, including for
220        // submodules/worktrees where `.git` in the work tree is a redirect file.
221        if self.git_repo.path().join("lfs").exists() {
222            return Ok(true);
223        }
224
225        Ok(false)
226    }
227
228    pub fn lfs_pull_if_needed(&self) -> Result<()> {
229        if self.uses_lfs()? {
230            self.run_git_lfs(&["pull"])?;
231        }
232        Ok(())
233    }
234
235    pub fn lfs_push_if_needed(
236        &self,
237        remote_name: &str,
238        branch_name: &str,
239    ) -> Result<()> {
240        if self.uses_lfs()? {
241            self.run_git_lfs(&["push", remote_name, branch_name])?;
242        }
243        Ok(())
244    }
245
246    fn run_git_lfs(&self, args: &[&str]) -> Result<()> {
247        let output = Command::new("git-lfs")
248            .args(args)
249            .current_dir(&self.work_dir)
250            .output()
251            .map_err(|err| {
252                if err.kind() == ErrorKind::NotFound {
253                    anyhow!(
254                        "Git LFS support required for `{}` but `git-lfs` is not installed.\n\
255                        Install it on Fedora with:\n\
256                        sudo dnf install git-lfs\n\
257                        git lfs install",
258                        self.work_dir.display()
259                    )
260                } else {
261                    anyhow!(err).context(format!(
262                        "Cannot execute git-lfs in `{}`",
263                        self.work_dir.display()
264                    ))
265                }
266            })?;
267
268        if !output.status.success() {
269            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
270            let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
271            let details = if !stderr.is_empty() {
272                stderr
273            } else if !stdout.is_empty() {
274                stdout
275            } else {
276                "no output".to_string()
277            };
278            bail!(
279                "git-lfs {} failed in `{}`: {}",
280                args.join(" "),
281                self.work_dir.display(),
282                details
283            );
284        }
285
286        Ok(())
287    }
288
289    pub fn switch(&self, head: &str) -> Result<()> {
290        self.git_repo.set_head(&self.resolve_reference(head)?)?;
291        let checkout_result = self.git_repo.checkout_head(None);
292        checkout_result?;
293        Ok(())
294    }
295
296    pub fn switch_force(&self, head: &str) -> Result<()> {
297        self.git_repo.set_head(&self.resolve_reference(head)?)?;
298        let mut checkout = CheckoutBuilder::new();
299        checkout.force();
300        let checkout_result = self.git_repo.checkout_head(Some(&mut checkout));
301        checkout_result?;
302        Ok(())
303    }
304
305    pub fn refresh_worktree(&self) -> Result<()> {
306        let checkout_result = self.git_repo.checkout_head(None);
307        checkout_result?;
308        Ok(())
309    }
310
311    pub fn refresh_worktree_force(&self) -> Result<()> {
312        let mut checkout = CheckoutBuilder::new();
313        checkout.force();
314        let checkout_result = self.git_repo.checkout_head(Some(&mut checkout));
315        checkout_result?;
316        Ok(())
317    }
318
319    pub fn checkout_path_from_head(&self, path: &path::Path) -> Result<()> {
320        let mut checkout = CheckoutBuilder::new();
321        checkout.force().path(path);
322        self.git_repo.checkout_head(Some(&mut checkout))?;
323        Ok(())
324    }
325
326    fn switch_forced(&self, head: &str) -> Result<()> {
327        self.git_repo.set_head(&self.resolve_reference(head)?)?;
328        let mut checkout = CheckoutBuilder::new();
329        checkout.force();
330        self.git_repo.checkout_head(Some(&mut checkout))?;
331        Ok(())
332    }
333
334    pub fn fetch(&self) -> Result<()> {
335        if self.git_repo.head_detached().with_context(|| {
336            format!(
337                "Cannot determine head state for repo at `{}`",
338                self.work_dir.display()
339            )
340        })? {
341            return Ok(());
342        }
343
344        // Get the remote for the current branch
345        let head_ref = self.git_repo.head()?;
346        let branch_name = head_ref.shorthand().with_context(|| {
347            format!(
348                "Cannot get branch name for repo at `{}`",
349                self.work_dir.display()
350            )
351        })?;
352
353        let tracking = match self.tracking_branch(branch_name)? {
354            Some(tracking) => tracking,
355            None => {
356                // No upstream configured, skip fetch
357                return Ok(());
358            },
359        };
360
361        // Check if remote exists
362        match self.git_repo.find_remote(&tracking.remote) {
363            Ok(mut remote) => {
364                let mut fetch_options = git2::FetchOptions::new();
365                fetch_options.remote_callbacks(self.remote_callbacks()?);
366
367                remote
368                    .fetch::<&str>(&[], Some(&mut fetch_options), None)
369                    .with_context(|| {
370                        format!(
371                            "Failed to fetch from remote '{}' for repo at `{}`\n\
372                            \n\
373                            Possible causes:\n\
374                            - SSH agent not running or not accessible (check SSH_AUTH_SOCK)\n\
375                            - SSH keys not properly configured in ~/.ssh/\n\
376                            - Credential helper not configured (git config credential.helper)\n\
377                            - Network/firewall issues\n\
378                            \n\
379                            Try running: git fetch --verbose\n\
380                            Or check authentication with: git-wok test-auth",
381                            tracking.remote,
382                            self.work_dir.display()
383                        )
384                    })?;
385            },
386            Err(_) => {
387                // No remote configured, skip fetch
388                return Ok(());
389            },
390        }
391
392        Ok(())
393    }
394
395    pub fn ensure_on_branch(&self, branch_name: &str) -> Result<()> {
396        if !self.is_worktree_clean()? {
397            bail!(
398                "Refusing to switch branches with uncommitted changes in `{}`",
399                self.work_dir.display()
400            );
401        }
402
403        if !self.git_repo.head_detached().with_context(|| {
404            format!(
405                "Cannot determine head state for repo at `{}`",
406                self.work_dir.display()
407            )
408        })? && let Ok(head) = self.git_repo.head()
409            && head.shorthand() == Some(branch_name)
410        {
411            return Ok(());
412        }
413
414        let local_ref = format!("refs/heads/{}", branch_name);
415        if self.git_repo.find_reference(&local_ref).is_ok() {
416            self.switch_forced(branch_name)?;
417            return Ok(());
418        }
419
420        let remote_name = self.get_remote_name_for_branch(branch_name)?;
421        if let Ok(mut remote) = self.git_repo.find_remote(&remote_name) {
422            let mut fetch_options = git2::FetchOptions::new();
423            fetch_options.remote_callbacks(self.remote_callbacks()?);
424            remote.fetch::<&str>(&[], Some(&mut fetch_options), None)?;
425        }
426
427        let remote_ref = format!("refs/remotes/{}/{}", remote_name, branch_name);
428        if let Ok(remote_oid) = self.git_repo.refname_to_id(&remote_ref) {
429            let remote_commit = self.git_repo.find_commit(remote_oid)?;
430            self.git_repo.branch(branch_name, &remote_commit, false)?;
431            let mut local_branch = self
432                .git_repo
433                .find_branch(branch_name, git2::BranchType::Local)?;
434            local_branch
435                .set_upstream(Some(&format!("{}/{}", remote_name, branch_name)))?;
436            self.switch(branch_name)?;
437            return Ok(());
438        }
439
440        let head = self.git_repo.head()?;
441        let current_commit = head.peel_to_commit()?;
442        self.git_repo.branch(branch_name, &current_commit, false)?;
443        self.switch(branch_name)?;
444        Ok(())
445    }
446
447    pub fn ensure_on_branch_existing_or_remote(
448        &self,
449        branch_name: &str,
450        create: bool,
451    ) -> Result<()> {
452        if !self.is_worktree_clean()? {
453            bail!(
454                "Refusing to switch branches with uncommitted changes in `{}`",
455                self.work_dir.display()
456            );
457        }
458
459        if !self.git_repo.head_detached().with_context(|| {
460            format!(
461                "Cannot determine head state for repo at `{}`",
462                self.work_dir.display()
463            )
464        })? && let Ok(head) = self.git_repo.head()
465            && head.shorthand() == Some(branch_name)
466        {
467            return Ok(());
468        }
469
470        let local_ref = format!("refs/heads/{}", branch_name);
471        if self.git_repo.find_reference(&local_ref).is_ok() {
472            self.switch(branch_name)?;
473            return Ok(());
474        }
475
476        let remote_name = self.get_remote_name_for_branch(branch_name)?;
477        if let Ok(mut remote) = self.git_repo.find_remote(&remote_name) {
478            let mut fetch_options = git2::FetchOptions::new();
479            fetch_options.remote_callbacks(self.remote_callbacks()?);
480            remote.fetch::<&str>(&[], Some(&mut fetch_options), None)?;
481        }
482
483        let remote_ref = format!("refs/remotes/{}/{}", remote_name, branch_name);
484        if let Ok(remote_oid) = self.git_repo.refname_to_id(&remote_ref) {
485            let remote_commit = self.git_repo.find_commit(remote_oid)?;
486            self.git_repo.branch(branch_name, &remote_commit, false)?;
487            let mut local_branch = self
488                .git_repo
489                .find_branch(branch_name, git2::BranchType::Local)?;
490            local_branch
491                .set_upstream(Some(&format!("{}/{}", remote_name, branch_name)))?;
492            self.switch_forced(branch_name)?;
493            return Ok(());
494        }
495
496        if create {
497            let head = self.git_repo.head()?;
498            let current_commit = head.peel_to_commit()?;
499            self.git_repo.branch(branch_name, &current_commit, false)?;
500            self.switch_forced(branch_name)?;
501            return Ok(());
502        }
503
504        bail!(
505            "Branch '{}' does not exist and --create not specified",
506            branch_name
507        );
508    }
509
510    fn rebase(
511        &self,
512        _branch_name: &str,
513        remote_commit: &git2::Commit,
514    ) -> Result<MergeResult> {
515        let _local_commit = self.git_repo.head()?.peel_to_commit()?;
516        let remote_oid = remote_commit.id();
517
518        // Prepare annotated commit for rebase
519        let remote_annotated = self.git_repo.find_annotated_commit(remote_oid)?;
520
521        // Initialize rebase operation
522        let signature = self.git_repo.signature()?;
523        let mut rebase = self.git_repo.rebase(
524            None,                    // branch to rebase (None = HEAD)
525            Some(&remote_annotated), // upstream
526            None,                    // onto (None = upstream)
527            None,                    // options
528        )?;
529
530        // Process each commit in the rebase
531        let mut has_conflicts = false;
532        while let Some(op) = rebase.next() {
533            match op {
534                Ok(_rebase_op) => {
535                    // Check for conflicts
536                    let index = self.git_repo.index()?;
537                    if index.has_conflicts() {
538                        has_conflicts = true;
539                        break;
540                    }
541
542                    // Commit the rebased changes
543                    if rebase.commit(None, &signature, None).is_err() {
544                        has_conflicts = true;
545                        break;
546                    }
547                },
548                Err(_) => {
549                    has_conflicts = true;
550                    break;
551                },
552            }
553        }
554
555        if has_conflicts {
556            // Leave repository in state with conflicts for user to resolve
557            return Ok(MergeResult::Conflicts);
558        }
559
560        // Finish the rebase
561        rebase.finish(Some(&signature))?;
562
563        Ok(MergeResult::Rebased)
564    }
565
566    pub fn merge(&self, branch_name: &str) -> Result<MergeResult> {
567        // First, fetch the latest changes
568        self.fetch()?;
569
570        // Resolve the tracking branch reference
571        let tracking = match self.tracking_branch(branch_name)? {
572            Some(tracking) => tracking,
573            None => {
574                // No upstream configured, treat as up to date
575                return Ok(MergeResult::UpToDate);
576            },
577        };
578
579        // Check if remote branch exists
580        let remote_branch_oid = match self.git_repo.refname_to_id(&tracking.remote_ref)
581        {
582            Ok(oid) => oid,
583            Err(_) => {
584                // No remote branch, just return up to date
585                return Ok(MergeResult::UpToDate);
586            },
587        };
588
589        let remote_commit = self.git_repo.find_commit(remote_branch_oid)?;
590        let local_commit = self.git_repo.head()?.peel_to_commit()?;
591
592        // Check if we're already up to date
593        if local_commit.id() == remote_commit.id() {
594            return Ok(MergeResult::UpToDate);
595        }
596
597        // Check if we can fast-forward (works for both merge and rebase)
598        if self
599            .git_repo
600            .graph_descendant_of(remote_commit.id(), local_commit.id())?
601        {
602            // Fast-forward merge
603            self.git_repo.reference(
604                &format!("refs/heads/{}", branch_name),
605                remote_commit.id(),
606                true,
607                &format!("Fast-forward '{}' to {}", branch_name, tracking.remote_ref),
608            )?;
609            self.git_repo
610                .set_head(&format!("refs/heads/{}", branch_name))?;
611            let mut checkout = CheckoutBuilder::new();
612            checkout.force();
613            self.git_repo.checkout_head(Some(&mut checkout))?;
614            self.lfs_pull_if_needed()?;
615            return Ok(MergeResult::FastForward);
616        }
617
618        // Determine pull strategy from git config
619        let pull_strategy = self.get_pull_strategy(branch_name)?;
620
621        match pull_strategy {
622            PullStrategy::Rebase => {
623                // Perform rebase
624                let result = self.rebase(branch_name, &remote_commit)?;
625                if matches!(result, MergeResult::Rebased) {
626                    self.lfs_pull_if_needed()?;
627                }
628                Ok(result)
629            },
630            PullStrategy::Merge => {
631                // Perform merge (existing logic)
632                let result = self.do_merge(
633                    branch_name,
634                    &local_commit,
635                    &remote_commit,
636                    &tracking,
637                )?;
638                if matches!(result, MergeResult::Merged) {
639                    self.lfs_pull_if_needed()?;
640                }
641                Ok(result)
642            },
643        }
644    }
645
646    fn do_merge(
647        &self,
648        branch_name: &str,
649        local_commit: &git2::Commit,
650        remote_commit: &git2::Commit,
651        tracking: &TrackingBranch,
652    ) -> Result<MergeResult> {
653        // Perform a merge
654        let mut merge_opts = git2::MergeOptions::new();
655        merge_opts.fail_on_conflict(false); // Don't fail on conflicts, we'll handle them
656
657        let _merge_result = self.git_repo.merge_commits(
658            local_commit,
659            remote_commit,
660            Some(&merge_opts),
661        )?;
662
663        // Check if there are conflicts by examining the index
664        let mut index = self.git_repo.index()?;
665        let has_conflicts = index.has_conflicts();
666
667        if !has_conflicts {
668            // No conflicts, merge was successful
669            let signature = self.git_repo.signature()?;
670            let tree_id = index.write_tree()?;
671            let tree = self.git_repo.find_tree(tree_id)?;
672
673            self.git_repo.commit(
674                Some(&format!("refs/heads/{}", branch_name)),
675                &signature,
676                &signature,
677                &format!("Merge remote-tracking branch '{}'", tracking.remote_ref),
678                &tree,
679                &[local_commit, remote_commit],
680            )?;
681
682            self.git_repo.cleanup_state()?;
683
684            Ok(MergeResult::Merged)
685        } else {
686            // There are conflicts
687            Ok(MergeResult::Conflicts)
688        }
689    }
690
691    pub fn get_remote_name_for_branch(&self, branch_name: &str) -> Result<String> {
692        if let Some(tracking) = self.tracking_branch(branch_name)? {
693            Ok(tracking.remote)
694        } else {
695            // Fall back to origin if no tracking branch is configured
696            Ok("origin".to_string())
697        }
698    }
699
700    /// Get the ahead/behind count relative to the remote tracking branch
701    pub fn get_remote_comparison(
702        &self,
703        branch_name: &str,
704    ) -> Result<Option<RemoteComparison>> {
705        // Get the tracking branch info
706        let tracking = match self.tracking_branch(branch_name)? {
707            Some(tracking) => tracking,
708            None => return Ok(None), // No tracking branch configured
709        };
710
711        // Check if remote branch exists
712        let remote_oid = match self.git_repo.refname_to_id(&tracking.remote_ref) {
713            Ok(oid) => oid,
714            Err(_) => {
715                // Remote branch doesn't exist
716                return Ok(Some(RemoteComparison::NoRemote));
717            },
718        };
719
720        // Get local branch OID
721        let local_oid = self.git_repo.head()?.peel_to_commit()?.id();
722
723        // If they're the same, we're up to date
724        if local_oid == remote_oid {
725            return Ok(Some(RemoteComparison::UpToDate));
726        }
727
728        // Calculate ahead/behind using git's graph functions
729        let (ahead, behind) =
730            self.git_repo.graph_ahead_behind(local_oid, remote_oid)?;
731
732        if ahead > 0 && behind > 0 {
733            Ok(Some(RemoteComparison::Diverged(ahead, behind)))
734        } else if ahead > 0 {
735            Ok(Some(RemoteComparison::Ahead(ahead)))
736        } else if behind > 0 {
737            Ok(Some(RemoteComparison::Behind(behind)))
738        } else {
739            Ok(Some(RemoteComparison::UpToDate))
740        }
741    }
742
743    pub fn remote_callbacks(&self) -> Result<git2::RemoteCallbacks<'static>> {
744        self.remote_callbacks_impl(false)
745    }
746
747    pub fn remote_callbacks_verbose(&self) -> Result<git2::RemoteCallbacks<'static>> {
748        self.remote_callbacks_impl(true)
749    }
750
751    fn remote_callbacks_impl(
752        &self,
753        verbose: bool,
754    ) -> Result<git2::RemoteCallbacks<'static>> {
755        let config = self.git_repo.config()?;
756
757        let mut callbacks = git2::RemoteCallbacks::new();
758        callbacks.credentials(move |url, username_from_url, allowed| {
759            if verbose {
760                eprintln!("DEBUG: Credential callback invoked");
761                eprintln!("  URL: {}", url);
762                eprintln!("  Username from URL: {:?}", username_from_url);
763                eprintln!("  Allowed types: {:?}", allowed);
764            }
765
766            // Try SSH key from agent (only if SSH_AUTH_SOCK is set)
767            if allowed.contains(git2::CredentialType::SSH_KEY) {
768                if let Some(username) = username_from_url {
769                    // Check if SSH agent is actually available
770                    if std::env::var("SSH_AUTH_SOCK").is_ok() {
771                        if verbose {
772                            eprintln!(
773                                "  Attempting: SSH key from agent for user '{}'",
774                                username
775                            );
776                        }
777                        match git2::Cred::ssh_key_from_agent(username) {
778                            Ok(cred) => {
779                                if verbose {
780                                    eprintln!("  SUCCESS: SSH key from agent");
781                                }
782                                return Ok(cred);
783                            },
784                            Err(e) => {
785                                if verbose {
786                                    eprintln!("  FAILED: SSH key from agent - {}", e);
787                                }
788                            },
789                        }
790                    } else if verbose {
791                        eprintln!(
792                            "  SKIPPED: SSH key from agent (SSH_AUTH_SOCK not set)"
793                        );
794                    }
795                } else if verbose {
796                    eprintln!("  SKIPPED: SSH key from agent (no username provided)");
797                }
798
799                // Try SSH key files directly
800                if let Some(username) = username_from_url
801                    && let Ok(home) = std::env::var("HOME")
802                {
803                    let key_paths = vec![
804                        format!("{}/.ssh/id_ed25519", home),
805                        format!("{}/.ssh/id_rsa", home),
806                        format!("{}/.ssh/id_ecdsa", home),
807                    ];
808
809                    for key_path in key_paths {
810                        if path::Path::new(&key_path).exists() {
811                            if verbose {
812                                eprintln!("  Attempting: SSH key file at {}", key_path);
813                            }
814                            match git2::Cred::ssh_key(
815                                username,
816                                None, // no public key path
817                                path::Path::new(&key_path),
818                                None, // no passphrase
819                            ) {
820                                Ok(cred) => {
821                                    if verbose {
822                                        eprintln!("  SUCCESS: SSH key file");
823                                    }
824                                    return Ok(cred);
825                                },
826                                Err(e) => {
827                                    if verbose {
828                                        eprintln!("  FAILED: SSH key file - {}", e);
829                                    }
830                                },
831                            }
832                        }
833                    }
834                }
835            }
836
837            // Try credential helper
838            if allowed.contains(git2::CredentialType::USER_PASS_PLAINTEXT)
839                || allowed.contains(git2::CredentialType::SSH_KEY)
840                || allowed.contains(git2::CredentialType::DEFAULT)
841            {
842                if verbose {
843                    eprintln!("  Attempting: Credential helper");
844                }
845                match git2::Cred::credential_helper(&config, url, username_from_url) {
846                    Ok(cred) => {
847                        if verbose {
848                            eprintln!("  SUCCESS: Credential helper");
849                        }
850                        return Ok(cred);
851                    },
852                    Err(e) => {
853                        if verbose {
854                            eprintln!("  FAILED: Credential helper - {}", e);
855                        }
856                    },
857                }
858            }
859
860            // Try username only
861            if allowed.contains(git2::CredentialType::USERNAME) {
862                let username = username_from_url.unwrap_or("git");
863                if verbose {
864                    eprintln!("  Attempting: Username only ('{}')", username);
865                }
866                match git2::Cred::username(username) {
867                    Ok(cred) => {
868                        if verbose {
869                            eprintln!("  SUCCESS: Username");
870                        }
871                        return Ok(cred);
872                    },
873                    Err(e) => {
874                        if verbose {
875                            eprintln!("  FAILED: Username - {}", e);
876                        }
877                    },
878                }
879            }
880
881            // Try default
882            if verbose {
883                eprintln!("  Attempting: Default credentials");
884            }
885            match git2::Cred::default() {
886                Ok(cred) => {
887                    if verbose {
888                        eprintln!("  SUCCESS: Default credentials");
889                    }
890                    Ok(cred)
891                },
892                Err(e) => {
893                    if verbose {
894                        eprintln!("  FAILED: All credential methods exhausted");
895                        eprintln!("  Last error: {}", e);
896                    }
897                    Err(e)
898                },
899            }
900        });
901
902        Ok(callbacks)
903    }
904
905    fn resolve_reference(&self, short_name: &str) -> Result<String> {
906        Ok(self
907            .git_repo
908            .resolve_reference_from_short_name(short_name)?
909            .name()
910            .with_context(|| {
911                format!(
912                    "Cannot resolve head reference for repo at `{}`",
913                    self.work_dir.display()
914                )
915            })?
916            .to_owned())
917    }
918
919    pub fn tracking_branch(&self, branch_name: &str) -> Result<Option<TrackingBranch>> {
920        let config = self.git_repo.config()?;
921
922        let remote_key = format!("branch.{}.remote", branch_name);
923        let merge_key = format!("branch.{}.merge", branch_name);
924
925        let remote = match config.get_string(&remote_key) {
926            Ok(name) => name,
927            Err(err) if err.code() == git2::ErrorCode::NotFound => return Ok(None),
928            Err(err) => return Err(err.into()),
929        };
930
931        let merge_ref = match config.get_string(&merge_key) {
932            Ok(name) => name,
933            Err(err) if err.code() == git2::ErrorCode::NotFound => return Ok(None),
934            Err(err) => return Err(err.into()),
935        };
936
937        let branch_short = merge_ref
938            .strip_prefix("refs/heads/")
939            .unwrap_or(&merge_ref)
940            .to_owned();
941
942        let remote_ref = format!("refs/remotes/{}/{}", remote, branch_short);
943
944        Ok(Some(TrackingBranch { remote, remote_ref }))
945    }
946
947    fn get_pull_strategy(&self, branch_name: &str) -> Result<PullStrategy> {
948        let config = self.git_repo.config()?;
949
950        // First check branch-specific rebase setting (highest priority)
951        let branch_rebase_key = format!("branch.{}.rebase", branch_name);
952        if let Ok(value) = config.get_string(&branch_rebase_key) {
953            return Ok(parse_rebase_config(&value));
954        }
955
956        // Then check global pull.rebase setting
957        if let Ok(value) = config.get_string("pull.rebase") {
958            return Ok(parse_rebase_config(&value));
959        }
960
961        // Try as boolean for backward compatibility
962        if let Ok(value) = config.get_bool("pull.rebase") {
963            return Ok(if value {
964                PullStrategy::Rebase
965            } else {
966                PullStrategy::Merge
967            });
968        }
969
970        // Default to merge
971        Ok(PullStrategy::Merge)
972    }
973
974    fn is_worktree_clean(&self) -> Result<bool> {
975        let mut status_options = StatusOptions::new();
976        status_options.include_ignored(false);
977        status_options.include_untracked(true);
978        let statuses = self.git_repo.statuses(Some(&mut status_options))?;
979        Ok(statuses.is_empty())
980    }
981}
982
983impl fmt::Debug for Repo {
984    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
985        f.debug_struct("Repo")
986            .field("work_dir", &self.work_dir)
987            .field("head", &self.head)
988            .field("subrepos", &self.subrepos)
989            .finish()
990    }
991}
992
993pub struct TrackingBranch {
994    pub remote: String,
995    pub remote_ref: String,
996}
997
998#[derive(Debug, Clone, PartialEq)]
999enum PullStrategy {
1000    Merge,
1001    Rebase,
1002}
1003
1004fn parse_rebase_config(value: &str) -> PullStrategy {
1005    match value.to_lowercase().as_str() {
1006        "true" | "interactive" | "i" | "merges" | "m" => PullStrategy::Rebase,
1007        "false" => PullStrategy::Merge,
1008        _ => PullStrategy::Merge, // Default to merge for unknown values
1009    }
1010}