gitkraft_core/features/remotes/
ops.rs1use anyhow::{Context, Result};
4use git2::Repository;
5use tracing::debug;
6
7use super::types::RemoteInfo;
8
9pub 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
43pub 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
62pub fn pull(repo: &Repository, remote_name: &str, branch: &str) -> Result<()> {
68 fetch_remote(repo, remote_name)?;
70
71 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 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 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 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 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 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
142pub 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}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168 use tempfile::TempDir;
169
170 fn setup_repo() -> (TempDir, Repository) {
171 let dir = TempDir::new().unwrap();
172 let repo = Repository::init(dir.path()).unwrap();
173 let sig = git2::Signature::now("Test", "test@test.com").unwrap();
175 let tree_id = repo.index().unwrap().write_tree().unwrap();
176 {
177 let tree = repo.find_tree(tree_id).unwrap();
178 repo.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
179 .unwrap();
180 }
181 (dir, repo)
182 }
183
184 #[test]
185 fn list_remotes_empty_repo() {
186 let (_dir, repo) = setup_repo();
187 let remotes = list_remotes(&repo).unwrap();
188 assert!(remotes.is_empty());
189 }
190
191 #[test]
192 fn list_remotes_with_remote() {
193 let (_dir, repo) = setup_repo();
194 repo.remote("origin", "https://example.com/repo.git")
195 .unwrap();
196 let remotes = list_remotes(&repo).unwrap();
197 assert_eq!(remotes.len(), 1);
198 assert_eq!(remotes[0].name, "origin");
199 assert_eq!(
200 remotes[0].url.as_deref(),
201 Some("https://example.com/repo.git")
202 );
203 assert_eq!(
204 remotes[0].fetch_refspecs,
205 vec!["+refs/heads/*:refs/remotes/origin/*"]
206 );
207 }
208
209 #[test]
210 fn list_remotes_multiple() {
211 let (_dir, repo) = setup_repo();
212 repo.remote("origin", "https://example.com/repo.git")
213 .unwrap();
214 repo.remote("upstream", "https://example.com/upstream.git")
215 .unwrap();
216 let remotes = list_remotes(&repo).unwrap();
217 assert_eq!(remotes.len(), 2);
218 let names: Vec<&str> = remotes.iter().map(|r| r.name.as_str()).collect();
219 assert!(names.contains(&"origin"));
220 assert!(names.contains(&"upstream"));
221 }
222
223 #[test]
224 fn list_remotes_url_and_refspecs() {
225 let (_dir, repo) = setup_repo();
226 repo.remote("origin", "git@github.com:user/repo.git")
227 .unwrap();
228 let remotes = list_remotes(&repo).unwrap();
229 assert_eq!(remotes.len(), 1);
230 let r = &remotes[0];
231 assert_eq!(r.name, "origin");
232 assert_eq!(r.url.as_deref(), Some("git@github.com:user/repo.git"));
233 assert!(!r.fetch_refspecs.is_empty());
234 }
235}