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}