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 fetch_head = match repo.find_reference(&remote_ref) {
41        Ok(r) => r,
42        Err(_) => {
43            // Remote branch doesn't exist yet
44            return Ok(());
45        }
46    };
47    let fetch_commit = repo.reference_to_annotated_commit(&fetch_head)?;
48
49    try_fast_forward(&repo, &format!("refs/heads/{}", branch), &fetch_commit)?;
50    Ok(())
51}
52
53fn try_fast_forward(
54    repo: &Repository,
55    local_ref: &str,
56    fetch_commit: &AnnotatedCommit,
57) -> Result<()> {
58    let analysis = repo.merge_analysis(&[fetch_commit])?;
59    if analysis.0.is_up_to_date() {
60        return Ok(());
61    }
62    if analysis.0.is_fast_forward() {
63        // Safety gate: never force-checkout over local changes
64        if is_worktree_dirty(repo)? {
65            anyhow::bail!(
66                "Cannot fast-forward: working tree has uncommitted changes. Please commit or stash before pulling."
67            );
68        }
69        // TODO(3): Migrate to gitoxide when worktree update support is added upstream
70        // (currently marked incomplete in gitoxide README)
71        // Ensure HEAD points to the target branch (avoid detach and ensure proper reflog)
72        repo.set_head(local_ref)?;
73        // Atomically move ref, index, and working tree to the fetched commit
74        let obj = repo.find_object(fetch_commit.id(), None)?;
75        repo.reset(
76            &obj,
77            git2::ResetType::Hard,
78            Some(git2::build::CheckoutBuilder::default().force()),
79        )?;
80        return Ok(());
81    }
82    anyhow::bail!(
83        "Non fast-forward update required (local and remote have diverged; rebase or merge needed)."
84    )
85}