Skip to main content

thoughts_tool/git/
pull.rs

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