Skip to main content

void_core/workspace/
remove.rs

1//! Remove operations for workspace.
2
3use std::collections::HashMap;
4use std::fs;
5
6use crate::VoidContext;
7use crate::index::{entry_matches_file, write_workspace_index, IndexEntry};
8use crate::{Result, VoidError};
9
10use super::stage::{load_head_entries, load_index_or_empty};
11
12/// Options for removing paths.
13#[derive(Clone)]
14pub struct RemoveOptions {
15    pub ctx: VoidContext,
16    pub paths: Vec<String>,
17    /// If true, only remove from index (keep files on disk).
18    pub cached_only: bool,
19    /// If true, remove even if file has uncommitted changes.
20    pub force: bool,
21    /// If true, recursively remove directories.
22    pub recursive: bool,
23}
24
25/// Result of removing paths.
26#[derive(Debug, Clone)]
27pub struct RemoveResult {
28    pub removed: Vec<String>,
29}
30
31/// Remove paths from the index and optionally from the working tree.
32///
33/// # Arguments
34/// * `opts` - Remove options including paths, cached_only, force, and recursive flags.
35///
36/// # Behavior
37/// - Without `cached_only`: deletes file from disk, removes from index
38/// - With `cached_only`: only removes from index (file stays on disk, becomes untracked)
39/// - Fails if file has uncommitted changes (unless `force` is set)
40/// - With `recursive`: removes entire directories
41pub fn remove_paths(opts: RemoveOptions) -> Result<RemoveResult> {
42    let root = &opts.ctx.paths.root;
43
44    let mut index = load_index_or_empty(&opts.ctx)?;
45    let head_entries = load_head_entries(&opts.ctx)?;
46
47    let head_map: HashMap<String, IndexEntry> = head_entries
48        .into_iter()
49        .map(|e| (e.path.clone(), e))
50        .collect();
51
52    let mut to_remove: Vec<String> = Vec::new();
53
54    // Collect paths to remove (expand directories if recursive)
55    for path in &opts.paths {
56        let path = path.replace('\\', "/").trim_start_matches('/').to_string();
57
58        // Check if path is in index
59        if let Some(_entry) = index.get(&path) {
60            to_remove.push(path);
61        } else if opts.recursive {
62            // Check if path is a directory prefix - collect all matching entries
63            let prefix = if path.ends_with('/') {
64                path.clone()
65            } else {
66                format!("{}/", path)
67            };
68
69            let matching: Vec<String> = index
70                .entries
71                .iter()
72                .filter(|e| e.path.starts_with(&prefix) || e.path == path)
73                .map(|e| e.path.clone())
74                .collect();
75
76            if matching.is_empty() {
77                return Err(VoidError::NotFound(format!(
78                    "pathspec '{}' did not match any files",
79                    path
80                )));
81            }
82
83            to_remove.extend(matching);
84        } else {
85            // Check if it's a directory and suggest -r
86            let prefix = format!("{}/", path);
87            let has_children = index.entries.iter().any(|e| e.path.starts_with(&prefix));
88            if has_children {
89                return Err(VoidError::InvalidPattern(format!(
90                    "'{}' is a directory. Use -r to remove recursively.",
91                    path
92                )));
93            }
94
95            return Err(VoidError::NotFound(format!(
96                "pathspec '{}' did not match any files",
97                path
98            )));
99        }
100    }
101
102    // Deduplicate
103    to_remove.sort();
104    to_remove.dedup();
105
106    // Check for uncommitted changes (unless force)
107    if !opts.force && !opts.cached_only {
108        for path in &to_remove {
109            if let Some(index_entry) = index.get(path) {
110                // Validate path to prevent traversal attacks
111                let file_path = crate::util::safe_join(opts.ctx.paths.root.as_std_path(), path)?;
112
113                // Check if file exists and has different content than index
114                if file_path.exists() {
115                    let matches = match entry_matches_file(index_entry, root) {
116                        Ok(m) => m,
117                        Err(VoidError::NotFound(_)) => true, // File doesn't exist, ok to remove
118                        Err(e) => return Err(e),
119                    };
120
121                    if !matches {
122                        // File has local modifications
123                        return Err(VoidError::InvalidPattern(format!(
124                            "file '{}' has local modifications (use --force to remove anyway)",
125                            path
126                        )));
127                    }
128                }
129
130                // Check if staged changes differ from HEAD
131                if let Some(head_entry) = head_map.get(path) {
132                    if index_entry.content_hash != head_entry.content_hash {
133                        return Err(VoidError::InvalidPattern(format!(
134                            "file '{}' has staged changes (use --force to remove anyway)",
135                            path
136                        )));
137                    }
138                } else {
139                    // File is new (not in HEAD), so it has staged changes
140                    return Err(VoidError::InvalidPattern(format!(
141                        "file '{}' has staged changes (use --force to remove anyway)",
142                        path
143                    )));
144                }
145            }
146        }
147    }
148
149    // Actually remove
150    let mut removed = Vec::new();
151    for path in to_remove {
152        // Remove from index
153        index.entries.retain(|e| e.path != path);
154        removed.push(path.clone());
155
156        // Delete from disk if not cached_only
157        if !opts.cached_only {
158            // Use safe_join to prevent path traversal
159            let file_path = crate::util::safe_join(opts.ctx.paths.root.as_std_path(), &path)?;
160            if file_path.exists() {
161                fs::remove_file(&file_path)?;
162            }
163        }
164    }
165
166    write_workspace_index(opts.ctx.paths.workspace_dir.as_std_path(), opts.ctx.crypto.vault.index_key()?, &index)?;
167
168    removed.sort();
169    Ok(RemoveResult { removed })
170}