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}