git_x/core/
safety.rs

1use crate::core::{git::GitOperations, interactive::Interactive};
2use crate::{GitXError, Result};
3
4/// Safety and backup utilities for destructive operations
5pub struct Safety;
6
7impl Safety {
8    /// Create a backup branch before destructive operations
9    pub fn create_backup_branch(prefix: Option<&str>) -> Result<String> {
10        let current_branch = GitOperations::current_branch()?;
11        let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
12        let backup_prefix = prefix.unwrap_or("backup");
13        let backup_name = format!("{backup_prefix}/{current_branch}_{timestamp}");
14
15        // Validate backup branch name
16        crate::core::validation::Validate::branch_name(&backup_name)?;
17
18        // Create the backup branch
19        let status = std::process::Command::new("git")
20            .args(["branch", &backup_name])
21            .status()?;
22
23        if !status.success() {
24            return Err(GitXError::GitCommand(format!(
25                "Failed to create backup branch '{backup_name}'"
26            )));
27        }
28
29        Ok(backup_name)
30    }
31
32    /// Check if working directory is clean before destructive operations
33    pub fn ensure_clean_working_directory() -> Result<()> {
34        // Skip working directory check in test environments
35        if Self::is_test_environment() {
36            return Ok(());
37        }
38
39        if !GitOperations::is_working_directory_clean()? {
40            return Err(GitXError::GitCommand(
41                "Working directory is not clean. Please commit or stash your changes first."
42                    .to_string(),
43            ));
44        }
45
46        Ok(())
47    }
48
49    /// Confirm destructive operation with user
50    pub fn confirm_destructive_operation(operation: &str, details: &str) -> Result<bool> {
51        let prompt = format!(
52            "⚠️  {operation} This is a destructive operation.\n{details}\nDo you want to continue?"
53        );
54
55        if !Interactive::is_interactive() {
56            // In non-interactive environments (like tests), default to allowing the operation
57            // but log that confirmation was skipped
58            eprintln!("Warning: Skipping confirmation in non-interactive environment");
59            return Ok(true);
60        }
61
62        Interactive::confirm(&prompt, false)
63    }
64
65    /// Check if we're in a test environment
66    fn is_test_environment() -> bool {
67        std::env::var("CARGO_TARGET_TMPDIR").is_ok() || std::env::var("CI").is_ok() || cfg!(test)
68    }
69
70    /// Create a safety checkpoint (stash) before operation
71    pub fn create_checkpoint(message: Option<&str>) -> Result<String> {
72        let checkpoint_msg = message.unwrap_or("git-x safety checkpoint");
73
74        let output = std::process::Command::new("git")
75            .args(["stash", "push", "-m", checkpoint_msg])
76            .output()?;
77
78        if !output.status.success() {
79            let stderr = String::from_utf8_lossy(&output.stderr);
80            return Err(GitXError::GitCommand(format!(
81                "Failed to create safety checkpoint: {stderr}"
82            )));
83        }
84
85        Ok(checkpoint_msg.to_string())
86    }
87
88    /// Restore from safety checkpoint if operation fails
89    pub fn restore_checkpoint() -> Result<()> {
90        let status = std::process::Command::new("git")
91            .args(["stash", "pop"])
92            .status()?;
93
94        if !status.success() {
95            return Err(GitXError::GitCommand(
96                "Failed to restore from safety checkpoint".to_string(),
97            ));
98        }
99
100        Ok(())
101    }
102
103    /// List recent backup branches created by git-x
104    pub fn list_backup_branches() -> Result<Vec<String>> {
105        let output = std::process::Command::new("git")
106            .args(["branch", "--list", "backup/*"])
107            .output()?;
108
109        if !output.status.success() {
110            return Err(GitXError::GitCommand(
111                "Failed to list backup branches".to_string(),
112            ));
113        }
114
115        let stdout = String::from_utf8_lossy(&output.stdout);
116        let branches: Vec<String> = stdout
117            .lines()
118            .map(|line| line.trim().trim_start_matches("* ").to_string())
119            .filter(|branch| !branch.is_empty())
120            .collect();
121
122        Ok(branches)
123    }
124
125    /// Clean up old backup branches (older than specified days)
126    pub fn cleanup_old_backups(days: u32, dry_run: bool) -> Result<Vec<String>> {
127        let backup_branches = Self::list_backup_branches()?;
128        let mut removed_branches = Vec::new();
129
130        for branch in backup_branches {
131            if Self::is_branch_older_than(&branch, days)? {
132                if dry_run {
133                    removed_branches.push(format!("[DRY RUN] Would delete: {branch}"));
134                } else {
135                    let status = std::process::Command::new("git")
136                        .args(["branch", "-D", &branch])
137                        .status()?;
138
139                    if status.success() {
140                        removed_branches.push(format!("Deleted: {branch}"));
141                    } else {
142                        removed_branches.push(format!("Failed to delete: {branch}"));
143                    }
144                }
145            }
146        }
147
148        Ok(removed_branches)
149    }
150
151    /// Check if a branch is older than specified days
152    fn is_branch_older_than(branch: &str, days: u32) -> Result<bool> {
153        let output = std::process::Command::new("git")
154            .args(["log", "-1", "--format=%ct", branch])
155            .output()?;
156
157        if !output.status.success() {
158            return Err(GitXError::GitCommand(format!(
159                "Failed to get branch date for '{branch}'"
160            )));
161        }
162
163        let timestamp_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
164        let timestamp: i64 = timestamp_str
165            .parse()
166            .map_err(|_| GitXError::Parse(format!("Invalid timestamp for branch '{branch}'")))?;
167
168        let branch_date = chrono::DateTime::from_timestamp(timestamp, 0)
169            .ok_or_else(|| GitXError::Parse(format!("Invalid date for branch '{branch}'")))?;
170
171        let cutoff_date = chrono::Utc::now() - chrono::Duration::days(days as i64);
172
173        Ok(branch_date < cutoff_date)
174    }
175}
176
177/// Builder for creating safe operation workflows
178pub struct SafetyBuilder {
179    operation_name: String,
180    backup_needed: bool,
181    checkpoint_needed: bool,
182    confirmation_needed: bool,
183    clean_directory_needed: bool,
184}
185
186impl SafetyBuilder {
187    pub fn new(operation_name: &str) -> Self {
188        Self {
189            operation_name: operation_name.to_string(),
190            backup_needed: false,
191            checkpoint_needed: false,
192            confirmation_needed: false,
193            clean_directory_needed: false,
194        }
195    }
196
197    pub fn with_backup(mut self) -> Self {
198        self.backup_needed = true;
199        self
200    }
201
202    pub fn with_checkpoint(mut self) -> Self {
203        self.checkpoint_needed = true;
204        self
205    }
206
207    pub fn with_confirmation(mut self) -> Self {
208        self.confirmation_needed = true;
209        self
210    }
211
212    pub fn with_clean_directory(mut self) -> Self {
213        self.clean_directory_needed = true;
214        self
215    }
216
217    pub fn execute<F>(self, operation: F) -> Result<String>
218    where
219        F: FnOnce() -> Result<String>,
220    {
221        // Pre-operation safety checks
222        if self.clean_directory_needed {
223            Safety::ensure_clean_working_directory()?;
224        }
225
226        let backup_name = if self.backup_needed {
227            Some(Safety::create_backup_branch(Some("safety"))?)
228        } else {
229            None
230        };
231
232        if self.checkpoint_needed {
233            Safety::create_checkpoint(Some(&format!("Before {}", self.operation_name)))?;
234        }
235
236        if self.confirmation_needed {
237            let details = if let Some(ref backup) = backup_name {
238                format!("A backup branch '{backup}' has been created.")
239            } else {
240                "No backup will be created.".to_string()
241            };
242
243            if !Safety::confirm_destructive_operation(&self.operation_name, &details)? {
244                return Ok("Operation cancelled by user.".to_string());
245            }
246        }
247
248        // Execute the operation
249        match operation() {
250            Ok(result) => Ok(result),
251            Err(e) => {
252                // Try to restore from checkpoint on failure
253                if self.checkpoint_needed {
254                    if let Err(restore_err) = Safety::restore_checkpoint() {
255                        eprintln!("Warning: Failed to restore checkpoint: {restore_err}");
256                    }
257                }
258                Err(e)
259            }
260        }
261    }
262}