rustic_git/commands/
files.rs

1//! File lifecycle operations for working with files in a Git repository.
2//!
3//! This module provides functionality for:
4//! - Restoring files from different sources (checkout_file, restore)
5//! - Unstaging files (reset_file)
6//! - Removing files from repository (rm)
7//! - Moving/renaming files (mv)
8//! - Managing .gitignore patterns
9//!
10//! All operations follow Git's standard behavior and safety principles.
11
12use crate::{Repository, Result, utils::git};
13use std::path::Path;
14
15/// Options for restore operations
16#[derive(Debug, Clone, Default)]
17pub struct RestoreOptions {
18    /// Source to restore from (commit hash, branch name, or "HEAD")
19    pub source: Option<String>,
20    /// Restore staged files
21    pub staged: bool,
22    /// Restore working tree files
23    pub worktree: bool,
24}
25
26impl RestoreOptions {
27    /// Create new restore options with default values
28    pub fn new() -> Self {
29        Self::default()
30    }
31
32    /// Set the source to restore from
33    pub fn with_source<S: Into<String>>(mut self, source: S) -> Self {
34        self.source = Some(source.into());
35        self
36    }
37
38    /// Enable restoring staged files
39    pub fn with_staged(mut self) -> Self {
40        self.staged = true;
41        self
42    }
43
44    /// Enable restoring working tree files
45    pub fn with_worktree(mut self) -> Self {
46        self.worktree = true;
47        self
48    }
49}
50
51/// Options for file removal operations
52#[derive(Debug, Clone, Default)]
53pub struct RemoveOptions {
54    /// Force removal of files
55    pub force: bool,
56    /// Remove files recursively (for directories)
57    pub recursive: bool,
58    /// Only remove from index, keep in working tree
59    pub cached: bool,
60    /// Don't fail if files don't match
61    pub ignore_unmatch: bool,
62}
63
64impl RemoveOptions {
65    /// Create new remove options with default values
66    pub fn new() -> Self {
67        Self::default()
68    }
69
70    /// Enable force removal
71    pub fn with_force(mut self) -> Self {
72        self.force = true;
73        self
74    }
75
76    /// Enable recursive removal
77    pub fn with_recursive(mut self) -> Self {
78        self.recursive = true;
79        self
80    }
81
82    /// Remove only from index, keep files in working tree
83    pub fn with_cached(mut self) -> Self {
84        self.cached = true;
85        self
86    }
87
88    /// Don't fail if files don't match
89    pub fn with_ignore_unmatch(mut self) -> Self {
90        self.ignore_unmatch = true;
91        self
92    }
93}
94
95/// Options for move operations
96#[derive(Debug, Clone, Default)]
97pub struct MoveOptions {
98    /// Force move even if destination exists
99    pub force: bool,
100    /// Show verbose output
101    pub verbose: bool,
102    /// Dry run - don't actually move files
103    pub dry_run: bool,
104}
105
106impl MoveOptions {
107    /// Create new move options with default values
108    pub fn new() -> Self {
109        Self::default()
110    }
111
112    /// Enable force move
113    pub fn with_force(mut self) -> Self {
114        self.force = true;
115        self
116    }
117
118    /// Enable verbose output
119    pub fn with_verbose(mut self) -> Self {
120        self.verbose = true;
121        self
122    }
123
124    /// Enable dry run mode
125    pub fn with_dry_run(mut self) -> Self {
126        self.dry_run = true;
127        self
128    }
129}
130
131impl Repository {
132    /// Restore file from HEAD, discarding local changes
133    ///
134    /// This is equivalent to `git checkout HEAD -- <file>` and will restore
135    /// the file to its state in the last commit, discarding any local changes.
136    ///
137    /// # Arguments
138    /// * `path` - Path to the file to restore
139    ///
140    /// # Example
141    /// ```rust,no_run
142    /// use rustic_git::Repository;
143    /// # use std::env;
144    /// # let repo_path = env::temp_dir().join("test_checkout_file");
145    /// # std::fs::create_dir_all(&repo_path).unwrap();
146    /// # let repo = Repository::init(&repo_path, false).unwrap();
147    ///
148    /// // Restore a modified file to its last committed state
149    /// repo.checkout_file("modified_file.txt")?;
150    /// # Ok::<(), rustic_git::GitError>(())
151    /// ```
152    pub fn checkout_file<P: AsRef<Path>>(&self, path: P) -> Result<()> {
153        Repository::ensure_git()?;
154
155        let path_str = path.as_ref().to_string_lossy();
156        git(
157            &["checkout", "HEAD", "--", &path_str],
158            Some(self.repo_path()),
159        )?;
160
161        Ok(())
162    }
163
164    /// Restore files with advanced options
165    ///
166    /// This provides access to git's `restore` command with full control over
167    /// source, staging area, and working tree restoration.
168    ///
169    /// # Arguments
170    /// * `paths` - Paths to restore
171    /// * `options` - Restore options
172    ///
173    /// # Example
174    /// ```rust,no_run
175    /// use rustic_git::{Repository, RestoreOptions};
176    /// # use std::env;
177    /// # let repo_path = env::temp_dir().join("test_restore");
178    /// # std::fs::create_dir_all(&repo_path).unwrap();
179    /// # let repo = Repository::init(&repo_path, false).unwrap();
180    ///
181    /// // Restore from a specific commit
182    /// let options = RestoreOptions::new()
183    ///     .with_source("HEAD~1")
184    ///     .with_worktree();
185    /// repo.restore(&["file.txt"], options)?;
186    /// # Ok::<(), rustic_git::GitError>(())
187    /// ```
188    pub fn restore<P: AsRef<Path>>(&self, paths: &[P], options: RestoreOptions) -> Result<()> {
189        Repository::ensure_git()?;
190
191        let mut args = vec!["restore"];
192
193        if let Some(ref source) = options.source {
194            args.push("--source");
195            args.push(source);
196        }
197
198        if options.staged {
199            args.push("--staged");
200        }
201
202        if options.worktree {
203            args.push("--worktree");
204        }
205
206        if !options.staged && !options.worktree {
207            // Default to worktree if neither specified
208            args.push("--worktree");
209        }
210
211        args.push("--");
212
213        let path_strings: Vec<String> = paths
214            .iter()
215            .map(|p| p.as_ref().to_string_lossy().to_string())
216            .collect();
217        let path_refs: Vec<&str> = path_strings.iter().map(String::as_str).collect();
218        args.extend(path_refs);
219
220        git(&args, Some(self.repo_path()))?;
221
222        Ok(())
223    }
224
225    /// Unstage a specific file, removing it from the staging area
226    ///
227    /// This is equivalent to `git reset HEAD -- <file>` and removes the file
228    /// from the staging area while keeping changes in the working directory.
229    ///
230    /// # Arguments
231    /// * `path` - Path to the file to unstage
232    ///
233    /// # Example
234    /// ```rust,no_run
235    /// use rustic_git::Repository;
236    /// # use std::env;
237    /// # let repo_path = env::temp_dir().join("test_reset_file");
238    /// # std::fs::create_dir_all(&repo_path).unwrap();
239    /// # let repo = Repository::init(&repo_path, false).unwrap();
240    ///
241    /// // Unstage a previously staged file
242    /// repo.reset_file("staged_file.txt")?;
243    /// # Ok::<(), rustic_git::GitError>(())
244    /// ```
245    pub fn reset_file<P: AsRef<Path>>(&self, path: P) -> Result<()> {
246        Repository::ensure_git()?;
247
248        let path_str = path.as_ref().to_string_lossy();
249        git(&["reset", "HEAD", "--", &path_str], Some(self.repo_path()))?;
250
251        Ok(())
252    }
253
254    /// Remove files from the repository
255    ///
256    /// This removes files from both the working directory and the repository,
257    /// equivalent to `git rm <files>`.
258    ///
259    /// # Arguments
260    /// * `paths` - Paths to remove
261    ///
262    /// # Example
263    /// ```rust,no_run
264    /// use rustic_git::Repository;
265    /// # use std::env;
266    /// # let repo_path = env::temp_dir().join("test_rm");
267    /// # std::fs::create_dir_all(&repo_path).unwrap();
268    /// # let repo = Repository::init(&repo_path, false).unwrap();
269    ///
270    /// // Remove files from repository
271    /// repo.rm(&["unwanted_file.txt", "old_dir/"])?;
272    /// # Ok::<(), rustic_git::GitError>(())
273    /// ```
274    pub fn rm<P: AsRef<Path>>(&self, paths: &[P]) -> Result<()> {
275        self.rm_with_options(paths, RemoveOptions::new())
276    }
277
278    /// Remove files with advanced options
279    ///
280    /// This provides full control over file removal with options for force,
281    /// recursive, cached-only removal, etc.
282    ///
283    /// # Arguments
284    /// * `paths` - Paths to remove
285    /// * `options` - Remove options
286    ///
287    /// # Example
288    /// ```rust,no_run
289    /// use rustic_git::{Repository, RemoveOptions};
290    /// # use std::env;
291    /// # let repo_path = env::temp_dir().join("test_rm_options");
292    /// # std::fs::create_dir_all(&repo_path).unwrap();
293    /// # let repo = Repository::init(&repo_path, false).unwrap();
294    ///
295    /// // Remove from index only, keep files in working tree
296    /// let options = RemoveOptions::new().with_cached();
297    /// repo.rm_with_options(&["keep_local.txt"], options)?;
298    /// # Ok::<(), rustic_git::GitError>(())
299    /// ```
300    pub fn rm_with_options<P: AsRef<Path>>(
301        &self,
302        paths: &[P],
303        options: RemoveOptions,
304    ) -> Result<()> {
305        Repository::ensure_git()?;
306
307        let mut args = vec!["rm"];
308
309        if options.force {
310            args.push("--force");
311        }
312
313        if options.recursive {
314            args.push("-r");
315        }
316
317        if options.cached {
318            args.push("--cached");
319        }
320
321        if options.ignore_unmatch {
322            args.push("--ignore-unmatch");
323        }
324
325        args.push("--");
326
327        let path_strings: Vec<String> = paths
328            .iter()
329            .map(|p| p.as_ref().to_string_lossy().to_string())
330            .collect();
331        let path_refs: Vec<&str> = path_strings.iter().map(String::as_str).collect();
332        args.extend(path_refs);
333
334        git(&args, Some(self.repo_path()))?;
335
336        Ok(())
337    }
338
339    /// Move or rename a file or directory
340    ///
341    /// This is equivalent to `git mv <source> <destination>` and will move
342    /// the file both in the working directory and in the repository.
343    ///
344    /// # Arguments
345    /// * `source` - Source path
346    /// * `destination` - Destination path
347    ///
348    /// # Example
349    /// ```rust,no_run
350    /// use rustic_git::Repository;
351    /// # use std::env;
352    /// # let repo_path = env::temp_dir().join("test_mv");
353    /// # std::fs::create_dir_all(&repo_path).unwrap();
354    /// # let repo = Repository::init(&repo_path, false).unwrap();
355    ///
356    /// // Rename a file
357    /// repo.mv("old_name.txt", "new_name.txt")?;
358    /// # Ok::<(), rustic_git::GitError>(())
359    /// ```
360    pub fn mv<P: AsRef<Path>, Q: AsRef<Path>>(&self, source: P, destination: Q) -> Result<()> {
361        self.mv_with_options(source, destination, MoveOptions::new())
362    }
363
364    /// Move files with advanced options
365    ///
366    /// This provides full control over file moving with options for force,
367    /// verbose output, and dry run mode.
368    ///
369    /// # Arguments
370    /// * `source` - Source path
371    /// * `destination` - Destination path
372    /// * `options` - Move options
373    ///
374    /// # Example
375    /// ```rust,no_run
376    /// use rustic_git::{Repository, MoveOptions};
377    /// # use std::env;
378    /// # let repo_path = env::temp_dir().join("test_mv_options");
379    /// # std::fs::create_dir_all(&repo_path).unwrap();
380    /// # let repo = Repository::init(&repo_path, false).unwrap();
381    ///
382    /// // Force move even if destination exists
383    /// let options = MoveOptions::new().with_force();
384    /// repo.mv_with_options("source.txt", "existing.txt", options)?;
385    /// # Ok::<(), rustic_git::GitError>(())
386    /// ```
387    pub fn mv_with_options<P: AsRef<Path>, Q: AsRef<Path>>(
388        &self,
389        source: P,
390        destination: Q,
391        options: MoveOptions,
392    ) -> Result<()> {
393        Repository::ensure_git()?;
394
395        let mut args = vec!["mv"];
396
397        if options.force {
398            args.push("-f");
399        }
400
401        if options.verbose {
402            args.push("-v");
403        }
404
405        if options.dry_run {
406            args.push("-n");
407        }
408
409        let source_str = source.as_ref().to_string_lossy();
410        let dest_str = destination.as_ref().to_string_lossy();
411        args.push(&source_str);
412        args.push(&dest_str);
413
414        git(&args, Some(self.repo_path()))?;
415
416        Ok(())
417    }
418
419    /// Add patterns to .gitignore file
420    ///
421    /// This adds the specified patterns to the repository's .gitignore file,
422    /// creating the file if it doesn't exist.
423    ///
424    /// # Arguments
425    /// * `patterns` - Patterns to add to .gitignore
426    ///
427    /// # Example
428    /// ```rust,no_run
429    /// use rustic_git::Repository;
430    /// # use std::env;
431    /// # let repo_path = env::temp_dir().join("test_ignore_add");
432    /// # std::fs::create_dir_all(&repo_path).unwrap();
433    /// # let repo = Repository::init(&repo_path, false).unwrap();
434    ///
435    /// // Add patterns to .gitignore
436    /// repo.ignore_add(&["*.tmp", "build/", "node_modules/"])?;
437    /// # Ok::<(), rustic_git::GitError>(())
438    /// ```
439    pub fn ignore_add(&self, patterns: &[&str]) -> Result<()> {
440        use std::fs::OpenOptions;
441        use std::io::Write;
442
443        let gitignore_path = self.repo_path().join(".gitignore");
444
445        let mut file = OpenOptions::new()
446            .create(true)
447            .append(true)
448            .open(gitignore_path)?;
449
450        for pattern in patterns {
451            writeln!(file, "{}", pattern)?;
452        }
453
454        Ok(())
455    }
456
457    /// Check if a file is ignored by .gitignore patterns
458    ///
459    /// This uses `git check-ignore` to determine if a file would be ignored
460    /// by the current .gitignore patterns.
461    ///
462    /// # Arguments
463    /// * `path` - Path to check
464    ///
465    /// # Returns
466    /// * `Ok(true)` if the file is ignored
467    /// * `Ok(false)` if the file is not ignored
468    /// * `Err(GitError)` if the command fails
469    ///
470    /// # Example
471    /// ```rust,no_run
472    /// use rustic_git::Repository;
473    /// # use std::env;
474    /// # let repo_path = env::temp_dir().join("test_ignore_check");
475    /// # std::fs::create_dir_all(&repo_path).unwrap();
476    /// # let repo = Repository::init(&repo_path, false).unwrap();
477    ///
478    /// // Check if a file is ignored
479    /// let is_ignored = repo.ignore_check("temp_file.tmp")?;
480    /// # Ok::<(), rustic_git::GitError>(())
481    /// ```
482    pub fn ignore_check<P: AsRef<Path>>(&self, path: P) -> Result<bool> {
483        Repository::ensure_git()?;
484
485        let path_str = path.as_ref().to_string_lossy();
486
487        match git(&["check-ignore", &path_str], Some(self.repo_path())) {
488            Ok(_) => Ok(true),   // File is ignored
489            Err(_) => Ok(false), // File is not ignored (check-ignore returns non-zero)
490        }
491    }
492
493    /// List current ignore patterns from .gitignore
494    ///
495    /// This reads the .gitignore file and returns all non-empty, non-comment lines.
496    ///
497    /// # Returns
498    /// * Vector of ignore patterns
499    ///
500    /// # Example
501    /// ```rust,no_run
502    /// use rustic_git::Repository;
503    /// # use std::env;
504    /// # let repo_path = env::temp_dir().join("test_ignore_list");
505    /// # std::fs::create_dir_all(&repo_path).unwrap();
506    /// # let repo = Repository::init(&repo_path, false).unwrap();
507    ///
508    /// // List all ignore patterns
509    /// let patterns = repo.ignore_list()?;
510    /// for pattern in patterns {
511    ///     println!("Ignoring: {}", pattern);
512    /// }
513    /// # Ok::<(), rustic_git::GitError>(())
514    /// ```
515    pub fn ignore_list(&self) -> Result<Vec<String>> {
516        use std::fs;
517
518        let gitignore_path = self.repo_path().join(".gitignore");
519
520        if !gitignore_path.exists() {
521            return Ok(Vec::new());
522        }
523
524        let content = fs::read_to_string(gitignore_path)?;
525        let patterns: Vec<String> = content
526            .lines()
527            .map(str::trim)
528            .filter(|line| !line.is_empty() && !line.starts_with('#'))
529            .map(String::from)
530            .collect();
531
532        Ok(patterns)
533    }
534}
535
536#[cfg(test)]
537mod tests {
538    use super::*;
539    use std::{env, fs};
540
541    fn create_test_repo() -> (Repository, std::path::PathBuf) {
542        use std::time::{SystemTime, UNIX_EPOCH};
543
544        let timestamp = SystemTime::now()
545            .duration_since(UNIX_EPOCH)
546            .unwrap()
547            .as_nanos();
548        let repo_path = env::temp_dir().join(format!(
549            "rustic_git_files_test_{}_{}",
550            std::process::id(),
551            timestamp
552        ));
553
554        // Ensure directory doesn't exist
555        if repo_path.exists() {
556            let _ = fs::remove_dir_all(&repo_path);
557        }
558        fs::create_dir_all(&repo_path).unwrap();
559
560        let repo = Repository::init(&repo_path, false).unwrap();
561
562        // Set up git user for tests
563        repo.config()
564            .set_user("Test User", "test@example.com")
565            .unwrap();
566
567        (repo, repo_path)
568    }
569
570    #[test]
571    fn test_restore_options_builder() {
572        let options = RestoreOptions::new()
573            .with_source("main")
574            .with_staged()
575            .with_worktree();
576
577        assert_eq!(options.source, Some("main".to_string()));
578        assert!(options.staged);
579        assert!(options.worktree);
580    }
581
582    #[test]
583    fn test_remove_options_builder() {
584        let options = RemoveOptions::new()
585            .with_force()
586            .with_recursive()
587            .with_cached()
588            .with_ignore_unmatch();
589
590        assert!(options.force);
591        assert!(options.recursive);
592        assert!(options.cached);
593        assert!(options.ignore_unmatch);
594    }
595
596    #[test]
597    fn test_move_options_builder() {
598        let options = MoveOptions::new()
599            .with_force()
600            .with_verbose()
601            .with_dry_run();
602
603        assert!(options.force);
604        assert!(options.verbose);
605        assert!(options.dry_run);
606    }
607
608    #[test]
609    fn test_checkout_file() {
610        let (repo, repo_path) = create_test_repo();
611
612        // Create and commit a file
613        let file_path = repo_path.join("test.txt");
614        fs::write(&file_path, "original content").unwrap();
615        repo.add(&["test.txt"]).unwrap();
616        repo.commit("Add test file").unwrap();
617
618        // Modify the file
619        fs::write(&file_path, "modified content").unwrap();
620
621        // Restore it
622        repo.checkout_file("test.txt").unwrap();
623
624        // Verify it's restored
625        let content = fs::read_to_string(&file_path).unwrap();
626        assert_eq!(content, "original content");
627
628        fs::remove_dir_all(&repo_path).unwrap();
629    }
630
631    #[test]
632    fn test_reset_file() {
633        let (repo, repo_path) = create_test_repo();
634
635        // Create and commit a file
636        let file_path = repo_path.join("test.txt");
637        fs::write(&file_path, "content").unwrap();
638        repo.add(&["test.txt"]).unwrap();
639        repo.commit("Add test file").unwrap();
640
641        // Modify and stage the file
642        fs::write(&file_path, "modified content").unwrap();
643        repo.add(&["test.txt"]).unwrap();
644
645        // Reset the file (unstage)
646        repo.reset_file("test.txt").unwrap();
647
648        // Verify it's unstaged but modified in working tree
649        let status = repo.status().unwrap();
650        assert!(status.has_changes());
651
652        fs::remove_dir_all(&repo_path).unwrap();
653    }
654
655    #[test]
656    fn test_ignore_add_and_list() {
657        let (repo, repo_path) = create_test_repo();
658
659        // Initially no patterns
660        let patterns = repo.ignore_list().unwrap();
661        assert!(patterns.is_empty());
662
663        // Add some patterns
664        repo.ignore_add(&["*.tmp", "build/", "node_modules/"])
665            .unwrap();
666
667        // Verify patterns are added
668        let patterns = repo.ignore_list().unwrap();
669        assert_eq!(patterns.len(), 3);
670        assert!(patterns.contains(&"*.tmp".to_string()));
671        assert!(patterns.contains(&"build/".to_string()));
672        assert!(patterns.contains(&"node_modules/".to_string()));
673
674        fs::remove_dir_all(&repo_path).unwrap();
675    }
676
677    #[test]
678    fn test_ignore_check() {
679        let (repo, repo_path) = create_test_repo();
680
681        // Add ignore pattern
682        repo.ignore_add(&["*.tmp"]).unwrap();
683
684        // Create files
685        fs::write(repo_path.join("test.txt"), "content").unwrap();
686        fs::write(repo_path.join("test.tmp"), "temp content").unwrap();
687
688        // Check if files are ignored
689        let txt_ignored = repo.ignore_check("test.txt").unwrap();
690        let tmp_ignored = repo.ignore_check("test.tmp").unwrap();
691
692        assert!(!txt_ignored);
693        assert!(tmp_ignored);
694
695        fs::remove_dir_all(&repo_path).unwrap();
696    }
697
698    #[test]
699    fn test_mv_basic() {
700        let (repo, repo_path) = create_test_repo();
701
702        // Create and commit a file
703        let original_path = repo_path.join("original.txt");
704        fs::write(&original_path, "content").unwrap();
705        repo.add(&["original.txt"]).unwrap();
706        repo.commit("Add original file").unwrap();
707
708        // Move the file
709        repo.mv("original.txt", "renamed.txt").unwrap();
710
711        // Verify move
712        let new_path = repo_path.join("renamed.txt");
713        assert!(!original_path.exists());
714        assert!(new_path.exists());
715
716        let content = fs::read_to_string(&new_path).unwrap();
717        assert_eq!(content, "content");
718
719        fs::remove_dir_all(&repo_path).unwrap();
720    }
721
722    #[test]
723    fn test_rm_basic() {
724        let (repo, repo_path) = create_test_repo();
725
726        // Create and commit a file
727        let file_path = repo_path.join("to_remove.txt");
728        fs::write(&file_path, "content").unwrap();
729        repo.add(&["to_remove.txt"]).unwrap();
730        repo.commit("Add file to remove").unwrap();
731
732        // Remove the file
733        repo.rm(&["to_remove.txt"]).unwrap();
734
735        // Verify removal
736        assert!(!file_path.exists());
737
738        fs::remove_dir_all(&repo_path).unwrap();
739    }
740
741    #[test]
742    fn test_rm_cached_only() {
743        let (repo, repo_path) = create_test_repo();
744
745        // Create and commit a file
746        let file_path = repo_path.join("keep_local.txt");
747        fs::write(&file_path, "content").unwrap();
748        repo.add(&["keep_local.txt"]).unwrap();
749        repo.commit("Add file").unwrap();
750
751        // Remove from index only
752        let options = RemoveOptions::new().with_cached();
753        repo.rm_with_options(&["keep_local.txt"], options).unwrap();
754
755        // Verify file still exists in working tree
756        assert!(file_path.exists());
757        let content = fs::read_to_string(&file_path).unwrap();
758        assert_eq!(content, "content");
759
760        fs::remove_dir_all(&repo_path).unwrap();
761    }
762
763    #[test]
764    fn test_restore_with_options() {
765        let (repo, repo_path) = create_test_repo();
766
767        // Create and commit a file
768        let file_path = repo_path.join("test.txt");
769        fs::write(&file_path, "original").unwrap();
770        repo.add(&["test.txt"]).unwrap();
771        repo.commit("First commit").unwrap();
772
773        // Modify and commit again
774        fs::write(&file_path, "second version").unwrap();
775        repo.add(&["test.txt"]).unwrap();
776        repo.commit("Second commit").unwrap();
777
778        // Modify current working tree
779        fs::write(&file_path, "current changes").unwrap();
780
781        // Restore from first commit
782        let options = RestoreOptions::new().with_source("HEAD~1").with_worktree();
783        repo.restore(&["test.txt"], options).unwrap();
784
785        // Verify restoration
786        let content = fs::read_to_string(&file_path).unwrap();
787        assert_eq!(content, "original");
788
789        fs::remove_dir_all(&repo_path).unwrap();
790    }
791}