gnostr_asyncgit/sync/remotes/
mod.rs

1//!
2
3mod callbacks;
4pub(crate) mod push;
5pub(crate) mod tags;
6
7pub use callbacks::Callbacks;
8use crossbeam_channel::Sender;
9use git2::{BranchType, FetchOptions, ProxyOptions, Repository};
10use scopetime::scope_time;
11pub use tags::tags_missing_remote;
12use utils::bytes2string;
13
14use super::RepoPath;
15use crate::{
16    error::{Error, Result},
17    sync::{
18        cred::BasicAuthCredential, remotes::push::ProgressNotification, repository::repo, utils,
19    },
20    ProgressPercent,
21};
22
23/// origin
24pub const DEFAULT_REMOTE_NAME: &str = "origin";
25
26///
27pub fn proxy_auto<'a>() -> ProxyOptions<'a> {
28    let mut proxy = ProxyOptions::new();
29    proxy.auto();
30    proxy
31}
32
33///
34pub fn get_remotes(repo_path: &RepoPath) -> Result<Vec<String>> {
35    scope_time!("get_remotes");
36
37    let repo = repo(repo_path)?;
38    let remotes = repo.remotes()?;
39    let remotes: Vec<String> = remotes.iter().flatten().map(String::from).collect();
40
41    Ok(remotes)
42}
43
44/// tries to find origin or the only remote that is defined if any
45/// in case of multiple remotes and none named *origin* we fail
46pub fn get_default_remote(repo_path: &RepoPath) -> Result<String> {
47    let repo = repo(repo_path)?;
48    get_default_remote_in_repo(&repo)
49}
50
51/// Gets the current branch the user is on.
52/// Returns none if they are not on a branch
53/// and Err if there was a problem finding the branch
54fn get_current_branch(repo: &Repository) -> Result<Option<git2::Branch<'_>>> {
55    for b in repo.branches(None)? {
56        let branch = b?.0;
57        if branch.is_head() {
58            return Ok(Some(branch));
59        }
60    }
61    Ok(None)
62}
63
64/// Tries to find the default repo to fetch from based on
65/// configuration.
66///
67/// > branch.`<name>`.remote
68/// >
69/// > When on branch `<name>`, it tells `git fetch` and `git push`
70/// > which remote to fetch from or
71/// > push to. [...] If no remote is configured, or if you are not on
72/// > any branch and there is more
73/// > than one remote defined in the repository, it defaults to
74/// > `origin` for fetching [...].
75///
76/// [git-config-branch-name-remote]: https://git-scm.com/docs/git-config#Documentation/git-config.txt-branchltnamegtremote
77///
78/// Falls back to `get_default_remote_in_repo`.
79pub fn get_default_remote_for_fetch(repo_path: &RepoPath) -> Result<String> {
80    let repo = repo(repo_path)?;
81    get_default_remote_for_fetch_in_repo(&repo)
82}
83
84// TODO: Very similar to `get_default_remote_for_push_in_repo`. Can
85// probably be refactored.
86pub(crate) fn get_default_remote_for_fetch_in_repo(repo: &Repository) -> Result<String> {
87    scope_time!("get_default_remote_for_fetch_in_repo");
88
89    let config = repo.config()?;
90
91    let branch = get_current_branch(repo)?;
92
93    if let Some(branch) = branch {
94        let remote_name = bytes2string(branch.name_bytes()?)?;
95
96        let entry_name = format!("branch.{}.remote", &remote_name);
97
98        if let Ok(entry) = config.get_entry(&entry_name) {
99            return bytes2string(entry.value_bytes());
100        }
101    }
102
103    get_default_remote_in_repo(repo)
104}
105
106/// Tries to find the default repo to push to based on configuration.
107///
108/// > remote.pushDefault
109/// >
110/// > The remote to push to by default. Overrides
111/// > `branch.<name>.remote` for all branches, and is
112/// > overridden by `branch.<name>.pushRemote` for specific branches.
113///
114/// > branch.`<name>`.remote
115/// >
116/// > When on branch `<name>`, it tells `git fetch` and `git push`
117/// > which remote to fetch from or
118/// > push to. The remote to push to may be overridden with
119/// > `remote.pushDefault` (for all
120/// > branches). The remote to push to, for the current branch, may be
121/// > further overridden by
122/// > `branch.<name>.pushRemote`. If no remote is configured, or if
123/// > you are not on any branch and
124/// > there is more than one remote defined in the repository, it
125/// > defaults to `origin` for fetching
126/// > and `remote.pushDefault` for pushing.
127///
128/// [git-config-remote-push-default]: https://git-scm.com/docs/git-config#Documentation/git-config.txt-remotepushDefault
129/// [git-config-branch-name-remote]: https://git-scm.com/docs/git-config#Documentation/git-config.txt-branchltnamegtremote
130///
131/// Falls back to `get_default_remote_in_repo`.
132pub fn get_default_remote_for_push(repo_path: &RepoPath) -> Result<String> {
133    let repo = repo(repo_path)?;
134    get_default_remote_for_push_in_repo(&repo)
135}
136
137// TODO: Very similar to `get_default_remote_for_fetch_in_repo`. Can
138// probably be refactored.
139pub(crate) fn get_default_remote_for_push_in_repo(repo: &Repository) -> Result<String> {
140    scope_time!("get_default_remote_for_push_in_repo");
141
142    let config = repo.config()?;
143
144    let branch = get_current_branch(repo)?;
145
146    if let Some(branch) = branch {
147        let remote_name = bytes2string(branch.name_bytes()?)?;
148
149        let entry_name = format!("branch.{}.pushRemote", &remote_name);
150
151        if let Ok(entry) = config.get_entry(&entry_name) {
152            return bytes2string(entry.value_bytes());
153        }
154
155        if let Ok(entry) = config.get_entry("remote.pushDefault") {
156            return bytes2string(entry.value_bytes());
157        }
158
159        let entry_name = format!("branch.{}.remote", &remote_name);
160
161        if let Ok(entry) = config.get_entry(&entry_name) {
162            return bytes2string(entry.value_bytes());
163        }
164    }
165
166    get_default_remote_in_repo(repo)
167}
168
169/// see `get_default_remote`
170pub(crate) fn get_default_remote_in_repo(repo: &Repository) -> Result<String> {
171    scope_time!("get_default_remote_in_repo");
172
173    let remotes = repo.remotes()?;
174
175    // if `origin` exists return that
176    let found_origin = remotes
177        .iter()
178        .any(|r| r.is_some_and(|r| r == DEFAULT_REMOTE_NAME));
179    if found_origin {
180        return Ok(DEFAULT_REMOTE_NAME.into());
181    }
182
183    //if only one remote exists pick that
184    if remotes.len() == 1 {
185        let first_remote = remotes
186            .iter()
187            .next()
188            .flatten()
189            .map(String::from)
190            .ok_or_else(|| Error::Generic("no remote found".into()))?;
191
192        return Ok(first_remote);
193    }
194
195    //inconclusive
196    Err(Error::NoDefaultRemoteFound)
197}
198
199///
200fn fetch_from_remote(
201    repo_path: &RepoPath,
202    remote: &str,
203    basic_credential: Option<BasicAuthCredential>,
204    progress_sender: Option<Sender<ProgressNotification>>,
205) -> Result<()> {
206    let repo = repo(repo_path)?;
207
208    let mut remote = repo.find_remote(remote)?;
209
210    let mut options = FetchOptions::new();
211    let callbacks = Callbacks::new(progress_sender, basic_credential);
212    options.prune(git2::FetchPrune::On);
213    options.proxy_options(proxy_auto());
214    options.download_tags(git2::AutotagOption::All);
215    options.remote_callbacks(callbacks.callbacks());
216    remote.fetch(&[] as &[&str], Some(&mut options), None)?;
217    // fetch tags (also removing remotely deleted ones)
218    remote.fetch(&["refs/tags/*:refs/tags/*"], Some(&mut options), None)?;
219
220    Ok(())
221}
222
223/// updates/prunes all branches from all remotes
224pub fn fetch_all(
225    repo_path: &RepoPath,
226    basic_credential: &Option<BasicAuthCredential>,
227    progress_sender: &Option<Sender<ProgressPercent>>,
228) -> Result<()> {
229    scope_time!("fetch_all");
230
231    let repo = repo(repo_path)?;
232    let remotes = repo
233        .remotes()?
234        .iter()
235        .flatten()
236        .map(String::from)
237        .collect::<Vec<_>>();
238    let remotes_count = remotes.len();
239
240    for (idx, remote) in remotes.into_iter().enumerate() {
241        fetch_from_remote(repo_path, &remote, basic_credential.clone(), None)?;
242
243        if let Some(sender) = progress_sender {
244            let progress = ProgressPercent::new(idx, remotes_count);
245            sender.send(progress)?;
246        }
247    }
248
249    Ok(())
250}
251
252/// fetches from upstream/remote for local `branch`
253pub(crate) fn fetch(
254    repo_path: &RepoPath,
255    branch: &str,
256    basic_credential: Option<BasicAuthCredential>,
257    progress_sender: Option<Sender<ProgressNotification>>,
258) -> Result<usize> {
259    scope_time!("fetch");
260
261    let repo = repo(repo_path)?;
262    let branch_ref = repo
263        .find_branch(branch, BranchType::Local)?
264        .into_reference();
265    let branch_ref = bytes2string(branch_ref.name_bytes())?;
266    let remote_name = repo.branch_upstream_remote(&branch_ref)?;
267    let remote_name = bytes2string(&remote_name)?;
268    let mut remote = repo.find_remote(&remote_name)?;
269
270    let mut options = FetchOptions::new();
271    options.download_tags(git2::AutotagOption::All);
272    let callbacks = Callbacks::new(progress_sender, basic_credential);
273    options.remote_callbacks(callbacks.callbacks());
274    options.proxy_options(proxy_auto());
275
276    remote.fetch(&[branch], Some(&mut options), None)?;
277
278    Ok(remote.stats().received_bytes())
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284    use crate::sync::tests::{debug_cmd_print, repo_clone, repo_init};
285
286    #[test]
287    fn test_smoke() {
288        let (remote_dir, _remote) = repo_init().unwrap();
289        let remote_path = remote_dir.path().to_str().unwrap();
290        let (repo_dir, _repo) = repo_clone(remote_path).unwrap();
291        let repo_path: &RepoPath = &repo_dir.into_path().as_os_str().to_str().unwrap().into();
292
293        let remotes = get_remotes(repo_path).unwrap();
294
295        assert_eq!(remotes, vec![String::from("origin")]);
296
297        fetch(repo_path, "master", None, None).unwrap();
298    }
299
300    #[test]
301    fn test_default_remote() {
302        let (remote_dir, _remote) = repo_init().unwrap();
303        let remote_path = remote_dir.path().to_str().unwrap();
304        let (repo_dir, _repo) = repo_clone(remote_path).unwrap();
305        let repo_path: &RepoPath = &repo_dir.into_path().as_os_str().to_str().unwrap().into();
306
307        debug_cmd_print(
308            repo_path,
309            &format!("git remote add second {remote_path}")[..],
310        );
311
312        let remotes = get_remotes(repo_path).unwrap();
313
314        assert_eq!(
315            remotes,
316            vec![String::from("origin"), String::from("second")]
317        );
318
319        let first = get_default_remote_in_repo(&repo(repo_path).unwrap()).unwrap();
320        assert_eq!(first, String::from("origin"));
321    }
322
323    #[test]
324    fn test_default_remote_out_of_order() {
325        let (remote_dir, _remote) = repo_init().unwrap();
326        let remote_path = remote_dir.path().to_str().unwrap();
327        let (repo_dir, _repo) = repo_clone(remote_path).unwrap();
328        let repo_path: &RepoPath = &repo_dir.into_path().as_os_str().to_str().unwrap().into();
329
330        debug_cmd_print(repo_path, "git remote rename origin alternate");
331
332        debug_cmd_print(
333            repo_path,
334            &format!("git remote add origin {remote_path}")[..],
335        );
336
337        //NOTE: apparently remotes are not chronolically sorted but
338        // alphabetically
339        let remotes = get_remotes(repo_path).unwrap();
340
341        assert_eq!(
342            remotes,
343            vec![String::from("alternate"), String::from("origin")]
344        );
345
346        let first = get_default_remote_in_repo(&repo(repo_path).unwrap()).unwrap();
347        assert_eq!(first, String::from("origin"));
348    }
349
350    #[test]
351    fn test_default_remote_inconclusive() {
352        let (remote_dir, _remote) = repo_init().unwrap();
353        let remote_path = remote_dir.path().to_str().unwrap();
354        let (repo_dir, _repo) = repo_clone(remote_path).unwrap();
355        let repo_path: &RepoPath = &repo_dir.into_path().as_os_str().to_str().unwrap().into();
356
357        debug_cmd_print(repo_path, "git remote rename origin alternate");
358
359        debug_cmd_print(
360            repo_path,
361            &format!("git remote add someremote {remote_path}")[..],
362        );
363
364        let remotes = get_remotes(repo_path).unwrap();
365        assert_eq!(
366            remotes,
367            vec![String::from("alternate"), String::from("someremote")]
368        );
369
370        let default_remote = get_default_remote_in_repo(&repo(repo_path).unwrap());
371
372        assert!(matches!(default_remote, Err(Error::NoDefaultRemoteFound)));
373    }
374
375    #[test]
376    fn test_default_remote_for_fetch() {
377        let (remote_dir, _remote) = repo_init().unwrap();
378        let remote_path = remote_dir.path().to_str().unwrap();
379        let (repo_dir, repo) = repo_clone(remote_path).unwrap();
380        let repo_path: &RepoPath = &repo_dir.into_path().as_os_str().to_str().unwrap().into();
381
382        debug_cmd_print(repo_path, "git remote rename origin alternate");
383
384        debug_cmd_print(
385            repo_path,
386            &format!("git remote add someremote {remote_path}")[..],
387        );
388
389        let mut config = repo.config().unwrap();
390
391        config
392            .set_str("branch.master.remote", "branchremote")
393            .unwrap();
394
395        let default_fetch_remote = get_default_remote_for_fetch_in_repo(&repo);
396
397        assert!(matches!(default_fetch_remote, Ok(remote_name) if remote_name == "branchremote"));
398    }
399
400    #[test]
401    fn test_default_remote_for_push() {
402        let (remote_dir, _remote) = repo_init().unwrap();
403        let remote_path = remote_dir.path().to_str().unwrap();
404        let (repo_dir, repo) = repo_clone(remote_path).unwrap();
405        let repo_path: &RepoPath = &repo_dir.into_path().as_os_str().to_str().unwrap().into();
406
407        debug_cmd_print(repo_path, "git remote rename origin alternate");
408
409        debug_cmd_print(
410            repo_path,
411            &format!("git remote add someremote {remote_path}")[..],
412        );
413
414        let mut config = repo.config().unwrap();
415
416        config
417            .set_str("branch.master.remote", "branchremote")
418            .unwrap();
419
420        let default_push_remote = get_default_remote_for_push_in_repo(&repo);
421
422        assert!(matches!(default_push_remote, Ok(remote_name) if remote_name == "branchremote"));
423
424        config.set_str("remote.pushDefault", "pushdefault").unwrap();
425
426        let default_push_remote = get_default_remote_for_push_in_repo(&repo);
427
428        assert!(matches!(default_push_remote, Ok(remote_name) if remote_name == "pushdefault"));
429
430        config
431            .set_str("branch.master.pushRemote", "branchpushremote")
432            .unwrap();
433
434        let default_push_remote = get_default_remote_for_push_in_repo(&repo);
435
436        assert!(
437            matches!(default_push_remote, Ok(remote_name) if remote_name == "branchpushremote")
438        );
439    }
440}