thoughts_tool/git/
pull.rs

1use anyhow::{Context, Result};
2use git2::{AnnotatedCommit, Repository};
3use std::path::Path;
4
5use crate::git::shell_fetch;
6use crate::git::utils::is_worktree_dirty;
7
8/// Fast-forward-only pull of the current branch from remote_name (default "origin")
9/// Uses shell git for fetch (to trigger 1Password SSH prompts) and git2 for fast-forward
10pub fn pull_ff_only(repo_path: &Path, remote_name: &str, branch: Option<&str>) -> Result<()> {
11    // First check if remote exists
12    {
13        let repo = Repository::open(repo_path)
14            .with_context(|| format!("Failed to open repository at {}", repo_path.display()))?;
15        if repo.find_remote(remote_name).is_err() {
16            // No remote - nothing to fetch
17            return Ok(());
18        }
19    }
20
21    let branch = branch.unwrap_or("main");
22
23    // Fetch using shell git (uses system SSH, triggers 1Password)
24    shell_fetch::fetch(repo_path, remote_name).with_context(|| {
25        format!(
26            "Fetch failed for remote '{}' in '{}'",
27            remote_name,
28            repo_path.display()
29        )
30    })?;
31
32    // Re-open repository to see the fetched refs
33    let repo = Repository::open(repo_path)
34        .with_context(|| format!("Failed to re-open repository at {}", repo_path.display()))?;
35
36    // Now do the fast-forward using git2
37    let remote_ref = format!("refs/remotes/{}/{}", remote_name, branch);
38    let fetch_head = match repo.find_reference(&remote_ref) {
39        Ok(r) => r,
40        Err(_) => {
41            // Remote branch doesn't exist yet
42            return Ok(());
43        }
44    };
45    let fetch_commit = repo.reference_to_annotated_commit(&fetch_head)?;
46
47    try_fast_forward(&repo, &format!("refs/heads/{}", branch), &fetch_commit)?;
48    Ok(())
49}
50
51fn try_fast_forward(
52    repo: &Repository,
53    local_ref: &str,
54    fetch_commit: &AnnotatedCommit,
55) -> Result<()> {
56    let analysis = repo.merge_analysis(&[fetch_commit])?;
57    if analysis.0.is_up_to_date() {
58        return Ok(());
59    }
60    if analysis.0.is_fast_forward() {
61        // Safety gate: never force-checkout over local changes
62        if is_worktree_dirty(repo)? {
63            anyhow::bail!(
64                "Cannot fast-forward: working tree has uncommitted changes. Please commit or stash before pulling."
65            );
66        }
67        // TODO(3): Migrate to gitoxide when worktree update support is added upstream
68        // (currently marked incomplete in gitoxide README)
69        // Ensure HEAD points to the target branch (avoid detach and ensure proper reflog)
70        repo.set_head(local_ref)?;
71        // Atomically move ref, index, and working tree to the fetched commit
72        let obj = repo.find_object(fetch_commit.id(), None)?;
73        repo.reset(
74            &obj,
75            git2::ResetType::Hard,
76            Some(git2::build::CheckoutBuilder::default().force()),
77        )?;
78        return Ok(());
79    }
80    anyhow::bail!(
81        "Non fast-forward update required (local and remote have diverged; rebase or merge needed)."
82    )
83}