Skip to main content

gitkraft_core/features/remotes/
ops.rs

1//! Remote operations — list, fetch, pull, and push.
2
3use anyhow::{Context, Result};
4use git2::Repository;
5use tracing::debug;
6
7use super::types::RemoteInfo;
8
9/// List all configured remotes in the repository.
10pub fn list_remotes(repo: &Repository) -> Result<Vec<RemoteInfo>> {
11    let remote_names = repo.remotes().context("failed to list remotes")?;
12    let mut remotes = Vec::with_capacity(remote_names.len());
13
14    for name in remote_names.iter() {
15        let name = name.unwrap_or("<invalid utf-8>");
16        let remote = repo
17            .find_remote(name)
18            .with_context(|| format!("failed to find remote '{name}'"))?;
19
20        let url = remote.url().map(String::from);
21
22        let mut fetch_refspecs = Vec::new();
23        let refspecs = remote.refspecs();
24        for refspec in refspecs {
25            if refspec.direction() == git2::Direction::Fetch {
26                if let Some(s) = refspec.str() {
27                    fetch_refspecs.push(s.to_string());
28                }
29            }
30        }
31
32        remotes.push(RemoteInfo {
33            name: name.to_string(),
34            url,
35            fetch_refspecs,
36        });
37    }
38
39    debug!("found {} remotes", remotes.len());
40    Ok(remotes)
41}
42
43/// Fetch from a remote by name.
44///
45/// This works for public repositories. Repositories requiring authentication
46/// will return an error — callers should handle auth setup via `git2` callbacks
47/// or credential helpers.
48pub fn fetch_remote(repo: &Repository, remote_name: &str) -> Result<()> {
49    let mut remote = repo
50        .find_remote(remote_name)
51        .with_context(|| format!("remote '{remote_name}' not found"))?;
52
53    debug!("fetching from remote '{}'", remote_name);
54
55    remote
56        .fetch(&[] as &[&str], None, None)
57        .with_context(|| format!("failed to fetch from remote '{remote_name}'"))?;
58
59    Ok(())
60}
61
62/// Pull from a remote: fetch + fast-forward merge of the given branch.
63///
64/// If a fast-forward is not possible (diverged histories), this returns an error
65/// rather than creating a merge commit — callers can handle that case with
66/// [`crate::features::branches::merge_branch`].
67pub fn pull(repo: &Repository, remote_name: &str, branch: &str) -> Result<()> {
68    // Step 1: fetch
69    fetch_remote(repo, remote_name)?;
70
71    // Step 2: look up FETCH_HEAD
72    let fetch_head = repo
73        .find_reference("FETCH_HEAD")
74        .context("FETCH_HEAD not found after fetch")?;
75    let fetch_commit_oid = fetch_head
76        .target()
77        .context("FETCH_HEAD is not a direct reference")?;
78    let fetch_commit = repo
79        .find_commit(fetch_commit_oid)
80        .context("failed to find FETCH_HEAD commit")?;
81
82    // Step 3: try to fast-forward the local branch
83    let refname = format!("refs/heads/{branch}");
84    match repo.find_reference(&refname) {
85        Ok(mut local_ref) => {
86            let local_oid = local_ref
87                .target()
88                .context("local branch ref is not direct")?;
89
90            // Check if fast-forward is possible
91            let (ahead, behind) = repo
92                .graph_ahead_behind(local_oid, fetch_commit_oid)
93                .context("failed to compute ahead/behind")?;
94
95            if behind == 0 {
96                debug!(
97                    "local branch '{}' is already up to date (ahead by {})",
98                    branch, ahead
99                );
100                return Ok(());
101            }
102
103            if ahead > 0 {
104                anyhow::bail!(
105                    "cannot fast-forward: local branch '{branch}' has diverged \
106                     ({ahead} ahead, {behind} behind). Use merge instead."
107                );
108            }
109
110            // Fast-forward: update the reference
111            debug!(
112                "fast-forwarding '{}' from {} to {}",
113                branch, local_oid, fetch_commit_oid
114            );
115            local_ref
116                .set_target(
117                    fetch_commit_oid,
118                    &format!("pull: fast-forward {branch} to {fetch_commit_oid}"),
119                )
120                .context("failed to fast-forward branch reference")?;
121
122            // Update the working directory
123            repo.set_head(&refname)
124                .context("failed to set HEAD after fast-forward")?;
125            repo.checkout_head(Some(git2::build::CheckoutBuilder::default().force()))
126                .context("failed to checkout HEAD after fast-forward")?;
127        }
128        Err(_) => {
129            // Local branch doesn't exist — create it pointing at the fetched commit
130            debug!(
131                "local branch '{}' not found, creating at {}",
132                branch, fetch_commit_oid
133            );
134            repo.branch(branch, &fetch_commit, false)
135                .with_context(|| format!("failed to create branch '{branch}'"))?;
136        }
137    }
138
139    Ok(())
140}
141
142/// Push a local branch to a remote.
143///
144/// This will fail without authentication for non-local remotes — that is
145/// expected. Callers should configure credential helpers or push callbacks.
146pub fn push(repo: &Repository, remote_name: &str, branch: &str) -> Result<()> {
147    let mut remote = repo
148        .find_remote(remote_name)
149        .with_context(|| format!("remote '{remote_name}' not found"))?;
150
151    let refspec = format!("refs/heads/{branch}:refs/heads/{branch}");
152
153    debug!(
154        "pushing '{}' to remote '{}' with refspec '{}'",
155        branch, remote_name, refspec
156    );
157
158    remote
159        .push(&[&refspec], None)
160        .with_context(|| format!("failed to push '{branch}' to remote '{remote_name}'"))?;
161
162    Ok(())
163}