rustic_git/commands/
remote.rs

1use std::path::Path;
2
3use crate::utils::git;
4use crate::{Repository, Result};
5
6/// Represents a Git remote with its URLs
7#[derive(Debug, Clone, PartialEq)]
8pub struct Remote {
9    /// The name of the remote (e.g., "origin")
10    pub name: String,
11    /// The fetch URL for the remote
12    pub fetch_url: String,
13    /// The push URL for the remote (if different from fetch URL)
14    pub push_url: Option<String>,
15}
16
17impl Remote {
18    /// Create a new Remote instance
19    pub fn new(name: String, fetch_url: String, push_url: Option<String>) -> Self {
20        Self {
21            name,
22            fetch_url,
23            push_url,
24        }
25    }
26
27    /// Get the effective push URL (returns push_url if set, otherwise fetch_url)
28    pub fn push_url(&self) -> &str {
29        self.push_url.as_ref().unwrap_or(&self.fetch_url)
30    }
31}
32
33/// A collection of remotes in a repository
34#[derive(Debug)]
35pub struct RemoteList {
36    remotes: Vec<Remote>,
37}
38
39impl RemoteList {
40    /// Create a new empty RemoteList
41    pub fn new(remotes: Vec<Remote>) -> Self {
42        Self { remotes }
43    }
44
45    /// Find a remote by name
46    pub fn find(&self, name: &str) -> Option<&Remote> {
47        self.remotes.iter().find(|r| r.name == name)
48    }
49
50    /// Get an iterator over all remotes
51    pub fn iter(&self) -> impl Iterator<Item = &Remote> {
52        self.remotes.iter()
53    }
54
55    /// Get the number of remotes
56    pub fn len(&self) -> usize {
57        self.remotes.len()
58    }
59
60    /// Check if the remote list is empty
61    pub fn is_empty(&self) -> bool {
62        self.remotes.is_empty()
63    }
64}
65
66/// Options for fetch operations
67#[derive(Default, Debug)]
68pub struct FetchOptions {
69    /// Prune remote-tracking branches that no longer exist on the remote
70    pub prune: bool,
71    /// Fetch tags from the remote
72    pub tags: bool,
73    /// Fetch from all remotes instead of just one
74    pub all_remotes: bool,
75}
76
77impl FetchOptions {
78    /// Create new FetchOptions with default values
79    pub fn new() -> Self {
80        Self::default()
81    }
82
83    /// Enable pruning of remote-tracking branches
84    pub fn with_prune(mut self) -> Self {
85        self.prune = true;
86        self
87    }
88
89    /// Enable fetching tags
90    pub fn with_tags(mut self) -> Self {
91        self.tags = true;
92        self
93    }
94
95    /// Enable fetching from all remotes
96    pub fn with_all_remotes(mut self) -> Self {
97        self.all_remotes = true;
98        self
99    }
100}
101
102/// Options for push operations
103#[derive(Default, Debug)]
104pub struct PushOptions {
105    /// Force push (overwrites remote changes)
106    pub force: bool,
107    /// Push tags along with commits
108    pub tags: bool,
109    /// Set upstream tracking for the branch
110    pub set_upstream: bool,
111}
112
113impl PushOptions {
114    /// Create new PushOptions with default values
115    pub fn new() -> Self {
116        Self::default()
117    }
118
119    /// Enable force push
120    pub fn with_force(mut self) -> Self {
121        self.force = true;
122        self
123    }
124
125    /// Enable pushing tags
126    pub fn with_tags(mut self) -> Self {
127        self.tags = true;
128        self
129    }
130
131    /// Set upstream tracking for the branch
132    pub fn with_set_upstream(mut self) -> Self {
133        self.set_upstream = true;
134        self
135    }
136}
137
138impl Repository {
139    /// Add a new remote to the repository
140    ///
141    /// # Arguments
142    ///
143    /// * `name` - The name for the remote (e.g., "origin")
144    /// * `url` - The URL for the remote repository
145    ///
146    /// # Example
147    ///
148    /// ```rust
149    /// use rustic_git::Repository;
150    /// use std::{env, fs};
151    ///
152    /// let test_path = env::temp_dir().join("remote_add_test");
153    /// if test_path.exists() {
154    ///     fs::remove_dir_all(&test_path).unwrap();
155    /// }
156    ///
157    /// let repo = Repository::init(&test_path, false)?;
158    /// repo.add_remote("origin", "https://github.com/user/repo.git")?;
159    ///
160    /// // Clean up
161    /// fs::remove_dir_all(&test_path).unwrap();
162    /// # Ok::<(), rustic_git::GitError>(())
163    /// ```
164    pub fn add_remote(&self, name: &str, url: &str) -> Result<()> {
165        Self::ensure_git()?;
166        git(&["remote", "add", name, url], Some(self.repo_path()))?;
167        Ok(())
168    }
169
170    /// Remove a remote from the repository
171    ///
172    /// # Arguments
173    ///
174    /// * `name` - The name of the remote to remove
175    ///
176    /// # Example
177    ///
178    /// ```rust
179    /// use rustic_git::Repository;
180    /// use std::{env, fs};
181    ///
182    /// let test_path = env::temp_dir().join("remote_remove_test");
183    /// if test_path.exists() {
184    ///     fs::remove_dir_all(&test_path).unwrap();
185    /// }
186    ///
187    /// let repo = Repository::init(&test_path, false)?;
188    /// repo.add_remote("origin", "https://github.com/user/repo.git")?;
189    /// repo.remove_remote("origin")?;
190    ///
191    /// // Clean up
192    /// fs::remove_dir_all(&test_path).unwrap();
193    /// # Ok::<(), rustic_git::GitError>(())
194    /// ```
195    pub fn remove_remote(&self, name: &str) -> Result<()> {
196        Self::ensure_git()?;
197        git(&["remote", "remove", name], Some(self.repo_path()))?;
198        Ok(())
199    }
200
201    /// Rename a remote
202    ///
203    /// # Arguments
204    ///
205    /// * `old_name` - The current name of the remote
206    /// * `new_name` - The new name for the remote
207    ///
208    /// # Example
209    ///
210    /// ```rust
211    /// use rustic_git::Repository;
212    /// use std::{env, fs};
213    ///
214    /// let test_path = env::temp_dir().join("remote_rename_test");
215    /// if test_path.exists() {
216    ///     fs::remove_dir_all(&test_path).unwrap();
217    /// }
218    ///
219    /// let repo = Repository::init(&test_path, false)?;
220    /// repo.add_remote("origin", "https://github.com/user/repo.git")?;
221    /// repo.rename_remote("origin", "upstream")?;
222    ///
223    /// // Clean up
224    /// fs::remove_dir_all(&test_path).unwrap();
225    /// # Ok::<(), rustic_git::GitError>(())
226    /// ```
227    pub fn rename_remote(&self, old_name: &str, new_name: &str) -> Result<()> {
228        Self::ensure_git()?;
229        git(
230            &["remote", "rename", old_name, new_name],
231            Some(self.repo_path()),
232        )?;
233        Ok(())
234    }
235
236    /// Get the URL for a specific remote
237    ///
238    /// # Arguments
239    ///
240    /// * `name` - The name of the remote
241    ///
242    /// # Returns
243    ///
244    /// The fetch URL for the remote
245    ///
246    /// # Example
247    ///
248    /// ```rust
249    /// use rustic_git::Repository;
250    /// use std::{env, fs};
251    ///
252    /// let test_path = env::temp_dir().join("remote_url_test");
253    /// if test_path.exists() {
254    ///     fs::remove_dir_all(&test_path).unwrap();
255    /// }
256    ///
257    /// let repo = Repository::init(&test_path, false)?;
258    /// let url = "https://github.com/user/repo.git";
259    /// repo.add_remote("origin", url)?;
260    /// let fetched_url = repo.get_remote_url("origin")?;
261    /// assert_eq!(fetched_url, url);
262    ///
263    /// // Clean up
264    /// fs::remove_dir_all(&test_path).unwrap();
265    /// # Ok::<(), rustic_git::GitError>(())
266    /// ```
267    pub fn get_remote_url(&self, name: &str) -> Result<String> {
268        Self::ensure_git()?;
269        let output = git(&["remote", "get-url", name], Some(self.repo_path()))?;
270        Ok(output.trim().to_string())
271    }
272
273    /// List all remotes in the repository
274    ///
275    /// # Returns
276    ///
277    /// A `RemoteList` containing all remotes with their URLs
278    ///
279    /// # Example
280    ///
281    /// ```rust
282    /// use rustic_git::Repository;
283    /// use std::{env, fs};
284    ///
285    /// let test_path = env::temp_dir().join("remote_list_test");
286    /// if test_path.exists() {
287    ///     fs::remove_dir_all(&test_path).unwrap();
288    /// }
289    ///
290    /// let repo = Repository::init(&test_path, false)?;
291    /// repo.add_remote("origin", "https://github.com/user/repo.git")?;
292    /// repo.add_remote("upstream", "https://github.com/original/repo.git")?;
293    ///
294    /// let remotes = repo.list_remotes()?;
295    /// assert_eq!(remotes.len(), 2);
296    /// assert!(remotes.find("origin").is_some());
297    /// assert!(remotes.find("upstream").is_some());
298    ///
299    /// // Clean up
300    /// fs::remove_dir_all(&test_path).unwrap();
301    /// # Ok::<(), rustic_git::GitError>(())
302    /// ```
303    pub fn list_remotes(&self) -> Result<RemoteList> {
304        Self::ensure_git()?;
305
306        // Get remote names
307        let names_output = git(&["remote"], Some(self.repo_path()))?;
308        if names_output.trim().is_empty() {
309            return Ok(RemoteList::new(Vec::new()));
310        }
311
312        let mut remotes = Vec::new();
313
314        for name in names_output.lines() {
315            let name = name.trim();
316            if name.is_empty() {
317                continue;
318            }
319
320            // Get fetch URL
321            let fetch_url = match git(&["remote", "get-url", name], Some(self.repo_path())) {
322                Ok(url) => url.trim().to_string(),
323                Err(_) => continue, // Skip this remote if we can't get its URL
324            };
325
326            // Try to get push URL (might be different from fetch URL)
327            let push_url = git(
328                &["remote", "get-url", "--push", name],
329                Some(self.repo_path()),
330            )
331            .ok()
332            .map(|url| url.trim().to_string())
333            .filter(|url| url != &fetch_url); // Only store if different
334
335            remotes.push(Remote::new(name.to_string(), fetch_url, push_url));
336        }
337
338        Ok(RemoteList::new(remotes))
339    }
340
341    /// Fetch changes from a remote repository
342    ///
343    /// # Arguments
344    ///
345    /// * `remote` - The name of the remote to fetch from
346    ///
347    /// # Example
348    ///
349    /// ```rust,no_run
350    /// use rustic_git::Repository;
351    ///
352    /// let repo = Repository::open(".")?;
353    /// repo.fetch("origin")?;
354    /// # Ok::<(), rustic_git::GitError>(())
355    /// ```
356    pub fn fetch(&self, remote: &str) -> Result<()> {
357        self.fetch_with_options(remote, FetchOptions::default())
358    }
359
360    /// Fetch changes from a remote repository with custom options
361    ///
362    /// # Arguments
363    ///
364    /// * `remote` - The name of the remote to fetch from
365    /// * `options` - Fetch options to customize the operation
366    ///
367    /// # Example
368    ///
369    /// ```rust,no_run
370    /// use rustic_git::{Repository, FetchOptions};
371    ///
372    /// let repo = Repository::open(".")?;
373    /// let options = FetchOptions::new().with_prune().with_tags();
374    /// repo.fetch_with_options("origin", options)?;
375    /// # Ok::<(), rustic_git::GitError>(())
376    /// ```
377    pub fn fetch_with_options(&self, remote: &str, options: FetchOptions) -> Result<()> {
378        Self::ensure_git()?;
379
380        let mut args = vec!["fetch"];
381
382        if options.prune {
383            args.push("--prune");
384        }
385
386        if options.tags {
387            args.push("--tags");
388        }
389
390        if options.all_remotes {
391            args.push("--all");
392        } else {
393            args.push(remote);
394        }
395
396        git(&args, Some(self.repo_path()))?;
397        Ok(())
398    }
399
400    /// Push changes to a remote repository
401    ///
402    /// # Arguments
403    ///
404    /// * `remote` - The name of the remote to push to
405    /// * `branch` - The name of the branch to push
406    ///
407    /// # Example
408    ///
409    /// ```rust,no_run
410    /// use rustic_git::Repository;
411    ///
412    /// let repo = Repository::open(".")?;
413    /// repo.push("origin", "main")?;
414    /// # Ok::<(), rustic_git::GitError>(())
415    /// ```
416    pub fn push(&self, remote: &str, branch: &str) -> Result<()> {
417        self.push_with_options(remote, branch, PushOptions::default())
418    }
419
420    /// Push changes to a remote repository with custom options
421    ///
422    /// # Arguments
423    ///
424    /// * `remote` - The name of the remote to push to
425    /// * `branch` - The name of the branch to push
426    /// * `options` - Push options to customize the operation
427    ///
428    /// # Example
429    ///
430    /// ```rust,no_run
431    /// use rustic_git::{Repository, PushOptions};
432    ///
433    /// let repo = Repository::open(".")?;
434    /// let options = PushOptions::new().with_set_upstream();
435    /// repo.push_with_options("origin", "main", options)?;
436    /// # Ok::<(), rustic_git::GitError>(())
437    /// ```
438    pub fn push_with_options(
439        &self,
440        remote: &str,
441        branch: &str,
442        options: PushOptions,
443    ) -> Result<()> {
444        Self::ensure_git()?;
445
446        let mut args = vec!["push"];
447
448        if options.force {
449            args.push("--force");
450        }
451
452        if options.set_upstream {
453            args.push("--set-upstream");
454        }
455
456        args.push(remote);
457        args.push(branch);
458
459        if options.tags {
460            args.push("--tags");
461        }
462
463        git(&args, Some(self.repo_path()))?;
464        Ok(())
465    }
466
467    /// Clone a remote repository to a local path
468    ///
469    /// # Arguments
470    ///
471    /// * `url` - The URL of the remote repository to clone
472    /// * `path` - The local path where the repository should be cloned
473    ///
474    /// # Returns
475    ///
476    /// A `Repository` instance pointing to the cloned repository
477    ///
478    /// # Example
479    ///
480    /// ```rust,no_run
481    /// use rustic_git::Repository;
482    ///
483    /// let repo = Repository::clone("https://github.com/user/repo.git", "./local-repo")?;
484    /// # Ok::<(), rustic_git::GitError>(())
485    /// ```
486    pub fn clone<P: AsRef<Path>>(url: &str, path: P) -> Result<Repository> {
487        Self::ensure_git()?;
488
489        let path_ref = path.as_ref();
490        git(&["clone", url, &path_ref.to_string_lossy()], None)?;
491
492        Repository::open(path)
493    }
494}
495
496#[cfg(test)]
497mod tests {
498    use super::*;
499    use std::env;
500    use std::fs;
501
502    fn create_test_repo(path: &std::path::Path) -> Repository {
503        // Clean up if exists
504        if path.exists() {
505            fs::remove_dir_all(path).unwrap();
506        }
507
508        Repository::init(path, false).unwrap()
509    }
510
511    #[test]
512    fn test_remote_new() {
513        let remote = Remote::new(
514            "origin".to_string(),
515            "https://github.com/user/repo.git".to_string(),
516            Some("git@github.com:user/repo.git".to_string()),
517        );
518
519        assert_eq!(remote.name, "origin");
520        assert_eq!(remote.fetch_url, "https://github.com/user/repo.git");
521        assert_eq!(remote.push_url(), "git@github.com:user/repo.git");
522    }
523
524    #[test]
525    fn test_remote_push_url_fallback() {
526        let remote = Remote::new(
527            "origin".to_string(),
528            "https://github.com/user/repo.git".to_string(),
529            None,
530        );
531
532        assert_eq!(remote.push_url(), "https://github.com/user/repo.git");
533    }
534
535    #[test]
536    fn test_remote_list_operations() {
537        let remotes = vec![
538            Remote::new("origin".to_string(), "url1".to_string(), None),
539            Remote::new("upstream".to_string(), "url2".to_string(), None),
540        ];
541
542        let list = RemoteList::new(remotes);
543
544        assert_eq!(list.len(), 2);
545        assert!(!list.is_empty());
546        assert!(list.find("origin").is_some());
547        assert!(list.find("nonexistent").is_none());
548        assert_eq!(list.iter().count(), 2);
549    }
550
551    #[test]
552    fn test_fetch_options_builder() {
553        let options = FetchOptions::new()
554            .with_prune()
555            .with_tags()
556            .with_all_remotes();
557
558        assert!(options.prune);
559        assert!(options.tags);
560        assert!(options.all_remotes);
561    }
562
563    #[test]
564    fn test_push_options_builder() {
565        let options = PushOptions::new()
566            .with_force()
567            .with_tags()
568            .with_set_upstream();
569
570        assert!(options.force);
571        assert!(options.tags);
572        assert!(options.set_upstream);
573    }
574
575    #[test]
576    fn test_add_remove_remote() {
577        let test_path = env::temp_dir().join("test_add_remove_remote");
578        let repo = create_test_repo(&test_path);
579
580        // Add a remote
581        repo.add_remote("origin", "https://github.com/user/repo.git")
582            .unwrap();
583
584        // Verify it was added
585        let remotes = repo.list_remotes().unwrap();
586        assert_eq!(remotes.len(), 1);
587        assert!(remotes.find("origin").is_some());
588
589        // Remove the remote
590        repo.remove_remote("origin").unwrap();
591
592        // Verify it was removed
593        let remotes = repo.list_remotes().unwrap();
594        assert_eq!(remotes.len(), 0);
595
596        // Clean up
597        fs::remove_dir_all(&test_path).unwrap();
598    }
599
600    #[test]
601    fn test_rename_remote() {
602        let test_path = env::temp_dir().join("test_rename_remote");
603        let repo = create_test_repo(&test_path);
604
605        // Add a remote
606        repo.add_remote("origin", "https://github.com/user/repo.git")
607            .unwrap();
608
609        // Rename it
610        repo.rename_remote("origin", "upstream").unwrap();
611
612        // Verify the rename
613        let remotes = repo.list_remotes().unwrap();
614        assert_eq!(remotes.len(), 1);
615        assert!(remotes.find("upstream").is_some());
616        assert!(remotes.find("origin").is_none());
617
618        // Clean up
619        fs::remove_dir_all(&test_path).unwrap();
620    }
621
622    #[test]
623    fn test_get_remote_url() {
624        let test_path = env::temp_dir().join("test_get_remote_url");
625        let repo = create_test_repo(&test_path);
626
627        let url = "https://github.com/user/repo.git";
628        repo.add_remote("origin", url).unwrap();
629
630        let fetched_url = repo.get_remote_url("origin").unwrap();
631        assert_eq!(fetched_url, url);
632
633        // Clean up
634        fs::remove_dir_all(&test_path).unwrap();
635    }
636
637    #[test]
638    fn test_list_multiple_remotes() {
639        let test_path = env::temp_dir().join("test_list_multiple_remotes");
640        let repo = create_test_repo(&test_path);
641
642        // Add multiple remotes
643        repo.add_remote("origin", "https://github.com/user/repo.git")
644            .unwrap();
645        repo.add_remote("upstream", "https://github.com/original/repo.git")
646            .unwrap();
647
648        let remotes = repo.list_remotes().unwrap();
649        assert_eq!(remotes.len(), 2);
650
651        let origin = remotes.find("origin").unwrap();
652        assert_eq!(origin.fetch_url, "https://github.com/user/repo.git");
653
654        let upstream = remotes.find("upstream").unwrap();
655        assert_eq!(upstream.fetch_url, "https://github.com/original/repo.git");
656
657        // Clean up
658        fs::remove_dir_all(&test_path).unwrap();
659    }
660
661    #[test]
662    fn test_list_remotes_empty() {
663        let test_path = env::temp_dir().join("test_list_remotes_empty");
664        let repo = create_test_repo(&test_path);
665
666        let remotes = repo.list_remotes().unwrap();
667        assert_eq!(remotes.len(), 0);
668        assert!(remotes.is_empty());
669
670        // Clean up
671        fs::remove_dir_all(&test_path).unwrap();
672    }
673
674    #[test]
675    fn test_remove_nonexistent_remote() {
676        let test_path = env::temp_dir().join("test_remove_nonexistent_remote");
677        let repo = create_test_repo(&test_path);
678
679        // Try to remove a non-existent remote
680        let result = repo.remove_remote("nonexistent");
681        assert!(result.is_err());
682
683        // Clean up
684        fs::remove_dir_all(&test_path).unwrap();
685    }
686}