rustic_git/commands/
reset.rs

1use crate::utils::git;
2use crate::{Repository, Result};
3use std::path::Path;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum ResetMode {
7    Soft,
8    Mixed,
9    Hard,
10}
11
12impl ResetMode {
13    pub const fn as_str(&self) -> &'static str {
14        match self {
15            ResetMode::Soft => "--soft",
16            ResetMode::Mixed => "--mixed",
17            ResetMode::Hard => "--hard",
18        }
19    }
20}
21
22pub fn reset<P: AsRef<Path>>(repo_path: P, mode: ResetMode, commit: &str) -> Result<()> {
23    let args = vec!["reset", mode.as_str(), commit];
24    git(&args, Some(repo_path.as_ref()))?;
25    Ok(())
26}
27
28impl Repository {
29    /// Perform a soft reset to the specified commit.
30    ///
31    /// Moves HEAD to the specified commit but keeps both the index and working directory unchanged.
32    /// Previously staged changes remain staged.
33    ///
34    /// # Arguments
35    ///
36    /// * `commit` - The commit hash, reference, or "HEAD~N" to reset to
37    ///
38    /// # Returns
39    ///
40    /// A `Result` indicating success or a `GitError` if the operation fails.
41    pub fn reset_soft(&self, commit: &str) -> Result<()> {
42        Self::ensure_git()?;
43        reset(self.repo_path(), ResetMode::Soft, commit)?;
44        Ok(())
45    }
46
47    /// Perform a mixed reset to the specified commit (default reset behavior).
48    ///
49    /// Moves HEAD to the specified commit and resets the index to match, but leaves the working directory unchanged.
50    /// Previously staged changes become unstaged but remain in the working directory.
51    ///
52    /// # Arguments
53    ///
54    /// * `commit` - The commit hash, reference, or "HEAD~N" to reset to
55    ///
56    /// # Returns
57    ///
58    /// A `Result` indicating success or a `GitError` if the operation fails.
59    pub fn reset_mixed(&self, commit: &str) -> Result<()> {
60        Self::ensure_git()?;
61        reset(self.repo_path(), ResetMode::Mixed, commit)?;
62        Ok(())
63    }
64
65    /// Perform a hard reset to the specified commit.
66    ///
67    /// Moves HEAD to the specified commit and resets both the index and working directory to match.
68    /// **WARNING**: This discards all uncommitted changes permanently.
69    ///
70    /// # Arguments
71    ///
72    /// * `commit` - The commit hash, reference, or "HEAD~N" to reset to
73    ///
74    /// # Returns
75    ///
76    /// A `Result` indicating success or a `GitError` if the operation fails.
77    pub fn reset_hard(&self, commit: &str) -> Result<()> {
78        Self::ensure_git()?;
79        reset(self.repo_path(), ResetMode::Hard, commit)?;
80        Ok(())
81    }
82
83    /// Perform a reset with the specified mode.
84    ///
85    /// This is a flexible method that allows you to specify the reset mode explicitly.
86    ///
87    /// # Arguments
88    ///
89    /// * `commit` - The commit hash, reference, or "HEAD~N" to reset to
90    /// * `mode` - The reset mode (Soft, Mixed, or Hard)
91    ///
92    /// # Returns
93    ///
94    /// A `Result` indicating success or a `GitError` if the operation fails.
95    pub fn reset_with_mode(&self, commit: &str, mode: ResetMode) -> Result<()> {
96        Self::ensure_git()?;
97        reset(self.repo_path(), mode, commit)?;
98        Ok(())
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105    use crate::Repository;
106    use std::path::PathBuf;
107    use std::{env, fs};
108
109    fn create_test_repo(test_name: &str) -> (PathBuf, Repository) {
110        let temp_dir = env::temp_dir().join(format!("rustic_git_reset_test_{}", test_name));
111
112        // Clean up if exists
113        if temp_dir.exists() {
114            fs::remove_dir_all(&temp_dir).unwrap();
115        }
116
117        let repo = Repository::init(&temp_dir, false).unwrap();
118
119        // Configure git user for testing
120        repo.config()
121            .set_user("Test User", "test@example.com")
122            .unwrap();
123
124        (temp_dir, repo)
125    }
126
127    fn create_file_and_commit(
128        repo: &Repository,
129        temp_dir: &Path,
130        filename: &str,
131        content: &str,
132        message: &str,
133    ) -> String {
134        let file_path = temp_dir.join(filename);
135        fs::write(&file_path, content).unwrap();
136        repo.add(&[filename]).unwrap();
137        repo.commit(message).unwrap().to_string()
138    }
139
140    #[test]
141    fn test_reset_mode_as_str() {
142        assert_eq!(ResetMode::Soft.as_str(), "--soft");
143        assert_eq!(ResetMode::Mixed.as_str(), "--mixed");
144        assert_eq!(ResetMode::Hard.as_str(), "--hard");
145    }
146
147    #[test]
148    fn test_reset_soft() {
149        let (temp_dir, repo) = create_test_repo("reset_soft");
150
151        // Create initial commit
152        let first_commit =
153            create_file_and_commit(&repo, &temp_dir, "file1.txt", "content1", "First commit");
154
155        // Create second commit
156        let _second_commit =
157            create_file_and_commit(&repo, &temp_dir, "file2.txt", "content2", "Second commit");
158
159        // Reset soft to first commit
160        reset(&temp_dir, ResetMode::Soft, &first_commit).unwrap();
161
162        // Check that index still has file2.txt staged
163        let status = repo.status().unwrap();
164        assert_eq!(status.staged_files().count(), 1);
165        assert!(
166            status
167                .staged_files()
168                .any(|f| f.path.file_name().unwrap() == "file2.txt")
169        );
170
171        // Check that file2.txt still exists in working directory
172        assert!(temp_dir.join("file2.txt").exists());
173
174        // Clean up
175        fs::remove_dir_all(&temp_dir).unwrap();
176    }
177
178    #[test]
179    fn test_reset_mixed() {
180        let (temp_dir, repo) = create_test_repo("reset_mixed");
181
182        // Create initial commit
183        let first_commit =
184            create_file_and_commit(&repo, &temp_dir, "file1.txt", "content1", "First commit");
185
186        // Create second commit
187        let _second_commit =
188            create_file_and_commit(&repo, &temp_dir, "file2.txt", "content2", "Second commit");
189
190        // Reset mixed to first commit
191        reset(&temp_dir, ResetMode::Mixed, &first_commit).unwrap();
192
193        // Check that index is clean (no staged files)
194        let status = repo.status().unwrap();
195        assert_eq!(status.staged_files().count(), 0);
196
197        // Check that file2.txt still exists in working directory as untracked
198        assert!(temp_dir.join("file2.txt").exists());
199        assert!(
200            status
201                .untracked_entries()
202                .any(|f| f.path.file_name().unwrap() == "file2.txt")
203        );
204    }
205
206    #[test]
207    fn test_reset_hard() {
208        let (temp_dir, repo) = create_test_repo("reset_hard");
209
210        // Create initial commit
211        let first_commit =
212            create_file_and_commit(&repo, &temp_dir, "file1.txt", "content1", "First commit");
213
214        // Create second commit
215        let _second_commit =
216            create_file_and_commit(&repo, &temp_dir, "file2.txt", "content2", "Second commit");
217
218        // Reset hard to first commit
219        reset(&temp_dir, ResetMode::Hard, &first_commit).unwrap();
220
221        // Check that index is clean
222        let status = repo.status().unwrap();
223        assert_eq!(status.staged_files().count(), 0);
224
225        // Check that file2.txt no longer exists in working directory
226        assert!(!temp_dir.join("file2.txt").exists());
227        assert_eq!(status.untracked_entries().count(), 0);
228    }
229
230    #[test]
231    fn test_reset_invalid_commit() {
232        let (temp_dir, _repo) = create_test_repo("reset_invalid_commit");
233
234        let result = reset(&temp_dir, ResetMode::Mixed, "invalid_commit_hash");
235        assert!(result.is_err());
236    }
237
238    #[test]
239    fn test_reset_head() {
240        let (temp_dir, repo) = create_test_repo("reset_head");
241
242        // Create initial commit
243        create_file_and_commit(&repo, &temp_dir, "file1.txt", "content1", "Initial commit");
244
245        // Modify file and stage it
246        fs::write(temp_dir.join("file1.txt"), "modified").unwrap();
247        repo.add(&["file1.txt"]).unwrap();
248
249        // Reset to HEAD (should unstage changes)
250        reset(temp_dir, ResetMode::Mixed, "HEAD").unwrap();
251
252        // Verify file is no longer staged but working directory is modified
253        let status = repo.status().unwrap();
254        assert_eq!(status.staged_files().count(), 0);
255        assert_eq!(status.unstaged_files().count(), 1);
256    }
257
258    // Tests for Repository methods
259    #[test]
260    fn test_repository_reset_soft() {
261        let (temp_dir, repo) = create_test_repo("repository_reset_soft");
262
263        // Create initial commit
264        let first_commit =
265            create_file_and_commit(&repo, &temp_dir, "file1.txt", "content1", "First commit");
266
267        // Create second commit
268        let _second_commit =
269            create_file_and_commit(&repo, &temp_dir, "file2.txt", "content2", "Second commit");
270
271        // Reset soft to first commit using Repository method
272        repo.reset_soft(&first_commit).unwrap();
273
274        // Check that index still has file2.txt staged
275        let status = repo.status().unwrap();
276        assert_eq!(status.staged_files().count(), 1);
277        assert!(
278            status
279                .staged_files()
280                .any(|f| f.path.file_name().unwrap() == "file2.txt")
281        );
282    }
283
284    #[test]
285    fn test_repository_reset_mixed() {
286        let (temp_dir, repo) = create_test_repo("repository_reset_mixed");
287
288        // Create initial commit
289        let first_commit =
290            create_file_and_commit(&repo, &temp_dir, "file1.txt", "content1", "First commit");
291
292        // Create second commit
293        let _second_commit =
294            create_file_and_commit(&repo, &temp_dir, "file2.txt", "content2", "Second commit");
295
296        // Reset mixed to first commit using Repository method
297        repo.reset_mixed(&first_commit).unwrap();
298
299        // Check that index is clean but file exists as untracked
300        let status = repo.status().unwrap();
301        assert_eq!(status.staged_files().count(), 0);
302        assert!(temp_dir.join("file2.txt").exists());
303        assert!(
304            status
305                .untracked_entries()
306                .any(|f| f.path.file_name().unwrap() == "file2.txt")
307        );
308    }
309
310    #[test]
311    fn test_repository_reset_hard() {
312        let (temp_dir, repo) = create_test_repo("repository_reset_hard");
313
314        // Create initial commit
315        let first_commit =
316            create_file_and_commit(&repo, &temp_dir, "file1.txt", "content1", "First commit");
317
318        // Create second commit
319        let _second_commit =
320            create_file_and_commit(&repo, &temp_dir, "file2.txt", "content2", "Second commit");
321
322        // Reset hard to first commit using Repository method
323        repo.reset_hard(&first_commit).unwrap();
324
325        // Check that everything is reset
326        let status = repo.status().unwrap();
327        assert_eq!(status.staged_files().count(), 0);
328        assert!(!temp_dir.join("file2.txt").exists());
329        assert_eq!(status.untracked_entries().count(), 0);
330
331        // Clean up
332        fs::remove_dir_all(&temp_dir).unwrap();
333    }
334
335    #[test]
336    fn test_repository_reset_with_mode() {
337        let (temp_dir, repo) = create_test_repo("repository_reset_with_mode");
338
339        // Create initial commit
340        let first_commit =
341            create_file_and_commit(&repo, &temp_dir, "file1.txt", "content1", "First commit");
342
343        // Create second commit
344        let _second_commit =
345            create_file_and_commit(&repo, &temp_dir, "file2.txt", "content2", "Second commit");
346
347        // Reset using reset_with_mode
348        repo.reset_with_mode(&first_commit, ResetMode::Mixed)
349            .unwrap();
350
351        // Check same behavior as reset_mixed
352        let status = repo.status().unwrap();
353        assert_eq!(status.staged_files().count(), 0);
354        assert!(temp_dir.join("file2.txt").exists());
355
356        // Clean up
357        fs::remove_dir_all(&temp_dir).unwrap();
358    }
359}