Skip to main content

git_same/commands/
reset.rs

1//! Reset command handler.
2//!
3//! Removes gisa configuration, workspace configs, and caches.
4//! Supports interactive scope selection or `--force` for scripting.
5
6use crate::cli::ResetArgs;
7use crate::config::{Config, WorkspaceConfig, WorkspaceManager};
8use crate::errors::{AppError, Result};
9use crate::output::Output;
10use chrono::{DateTime, Utc};
11use std::io::{self, BufRead, Write};
12use std::path::PathBuf;
13
14/// What scope of reset to perform.
15enum ResetScope {
16    Everything,
17    ConfigOnly,
18    AllWorkspaces,
19    Workspace(PathBuf),
20}
21
22/// Rich detail about a single workspace for display.
23struct WorkspaceDetail {
24    root_path: PathBuf,
25    orgs: Vec<String>,
26    last_synced: Option<String>,
27    dot_dir: PathBuf,
28    cache_size: Option<u64>,
29}
30
31/// Everything that could be removed.
32struct ResetTarget {
33    config_dir: PathBuf,
34    config_file: Option<PathBuf>,
35    workspaces: Vec<WorkspaceDetail>,
36}
37
38impl ResetTarget {
39    fn is_empty(&self) -> bool {
40        self.config_file.is_none() && self.workspaces.is_empty()
41    }
42
43    fn has_workspaces(&self) -> bool {
44        !self.workspaces.is_empty()
45    }
46}
47
48/// Run the reset command.
49pub async fn run(args: &ResetArgs, output: &Output) -> Result<()> {
50    let target = discover_targets()?;
51
52    if target.is_empty() {
53        output.info("Nothing to reset — gisa is not configured.");
54        return Ok(());
55    }
56
57    // --force: delete everything, no prompts
58    if args.force {
59        display_detailed_targets(&ResetScope::Everything, &target, output);
60        execute_reset(&ResetScope::Everything, &target, output)?;
61        return Ok(());
62    }
63
64    // Interactive: ask what to reset
65    let scope = prompt_scope(&target)?;
66    display_detailed_targets(&scope, &target, output);
67
68    if !confirm("\nAre you sure? [y/N] ")? {
69        output.info("Reset cancelled.");
70        return Ok(());
71    }
72
73    execute_reset(&scope, &target, output)?;
74    Ok(())
75}
76
77/// Discover what files and directories exist that could be removed.
78fn discover_targets() -> Result<ResetTarget> {
79    let config_path = Config::default_path()?;
80    let config_dir = config_path
81        .parent()
82        .ok_or_else(|| AppError::config("Cannot determine config directory"))?
83        .to_path_buf();
84
85    let config_file = if config_path.exists() {
86        Some(config_path)
87    } else {
88        None
89    };
90
91    let workspaces = WorkspaceManager::list()?
92        .iter()
93        .map(build_workspace_detail)
94        .collect::<Result<Vec<_>>>()?;
95
96    Ok(ResetTarget {
97        config_dir,
98        config_file,
99        workspaces,
100    })
101}
102
103/// Build rich detail for a workspace.
104fn build_workspace_detail(ws: &WorkspaceConfig) -> Result<WorkspaceDetail> {
105    let dot_dir = WorkspaceManager::dot_dir(&ws.root_path);
106    let cache_file = WorkspaceManager::cache_path(&ws.root_path);
107
108    let cache_size = if cache_file.exists() {
109        std::fs::metadata(&cache_file).map(|m| m.len()).ok()
110    } else {
111        None
112    };
113
114    Ok(WorkspaceDetail {
115        root_path: ws.root_path.clone(),
116        orgs: ws.orgs.clone(),
117        last_synced: ws.last_synced.clone(),
118        dot_dir,
119        cache_size,
120    })
121}
122
123/// Display detailed information about what will be deleted.
124fn display_detailed_targets(scope: &ResetScope, target: &ResetTarget, output: &Output) {
125    output.warn("The following will be permanently deleted:");
126
127    match scope {
128        ResetScope::Everything => {
129            if let Some(ref path) = target.config_file {
130                output.info(&format!("  Global config: {}", path.display()));
131            }
132            for ws in &target.workspaces {
133                display_workspace_detail(ws, output);
134            }
135        }
136        ResetScope::ConfigOnly => {
137            if let Some(ref path) = target.config_file {
138                output.info(&format!("  Global config: {}", path.display()));
139            }
140        }
141        ResetScope::AllWorkspaces => {
142            for ws in &target.workspaces {
143                display_workspace_detail(ws, output);
144            }
145        }
146        ResetScope::Workspace(path) => {
147            if let Some(ws) = target.workspaces.iter().find(|w| w.root_path == *path) {
148                display_workspace_detail(ws, output);
149            }
150        }
151    }
152}
153
154/// Display detail for a single workspace.
155fn display_workspace_detail(ws: &WorkspaceDetail, output: &Output) {
156    let path_display = crate::config::workspace::tilde_collapse_path(&ws.root_path);
157    output.info(&format!("  Workspace at {}:", path_display));
158
159    if ws.orgs.is_empty() {
160        output.info("    Orgs:        (all)");
161    } else {
162        output.info(&format!(
163            "    Orgs:        {} ({})",
164            ws.orgs.join(", "),
165            ws.orgs.len()
166        ));
167    }
168
169    let synced = ws
170        .last_synced
171        .as_deref()
172        .map(humanize_timestamp)
173        .unwrap_or_else(|| "never".to_string());
174    output.info(&format!("    Last synced: {}", synced));
175
176    if let Some(size) = ws.cache_size {
177        output.info(&format!("    Cache:       {}", format_bytes(size)));
178    }
179
180    output.info(&format!("    Config dir:  {}", ws.dot_dir.display()));
181}
182
183/// Execute the reset based on scope.
184fn execute_reset(scope: &ResetScope, target: &ResetTarget, output: &Output) -> Result<()> {
185    let mut had_errors = false;
186
187    match scope {
188        ResetScope::Everything => {
189            for ws in &target.workspaces {
190                had_errors |= !remove_workspace_dir(ws, output);
191            }
192            if let Some(ref path) = target.config_file {
193                had_errors |= !remove_file(path, "config", output);
194            }
195            try_remove_empty_dir(&target.config_dir, output);
196        }
197        ResetScope::ConfigOnly => {
198            if let Some(ref path) = target.config_file {
199                had_errors |= !remove_file(path, "config", output);
200            }
201        }
202        ResetScope::AllWorkspaces => {
203            for ws in &target.workspaces {
204                had_errors |= !remove_workspace_dir(ws, output);
205            }
206        }
207        ResetScope::Workspace(path) => {
208            if let Some(ws) = target.workspaces.iter().find(|w| w.root_path == *path) {
209                had_errors |= !remove_workspace_dir(ws, output);
210            } else {
211                output.warn(&format!("Workspace '{}' not found.", path.display()));
212                had_errors = true;
213            }
214        }
215    }
216
217    if had_errors {
218        Err(AppError::config(
219            "Reset completed with one or more removal errors.",
220        ))
221    } else {
222        match scope {
223            ResetScope::Everything => {
224                output.success("Reset complete. Run 'gisa init' to start fresh.");
225            }
226            ResetScope::ConfigOnly => {
227                output.success("Global config removed.");
228            }
229            ResetScope::AllWorkspaces => {
230                output.success("All workspaces removed.");
231            }
232            ResetScope::Workspace(path) => {
233                output.success(&format!("Workspace '{}' removed.", path.display()));
234            }
235        }
236        Ok(())
237    }
238}
239
240fn remove_workspace_dir(ws: &WorkspaceDetail, output: &Output) -> bool {
241    let path_display = crate::config::workspace::tilde_collapse_path(&ws.root_path);
242    match std::fs::remove_dir_all(&ws.dot_dir) {
243        Ok(()) => {
244            // Also unregister from global config
245            let _ = Config::remove_from_registry(&path_display);
246            output.success(&format!("Removed workspace config at {}", path_display));
247            true
248        }
249        Err(e) => {
250            output.warn(&format!(
251                "Failed to remove workspace config at {}: {}",
252                path_display, e
253            ));
254            false
255        }
256    }
257}
258
259fn remove_file(path: &PathBuf, label: &str, output: &Output) -> bool {
260    match std::fs::remove_file(path) {
261        Ok(()) => {
262            output.success(&format!("Removed {}: {}", label, path.display()));
263            true
264        }
265        Err(e) => {
266            output.warn(&format!("Failed to remove {}: {}", label, e));
267            false
268        }
269    }
270}
271
272fn try_remove_empty_dir(dir: &PathBuf, output: &Output) {
273    if dir.exists() {
274        match std::fs::remove_dir(dir) {
275            Ok(()) => output.verbose(&format!("Removed directory: {}", dir.display())),
276            Err(_) => output.verbose(&format!(
277                "Config directory not empty, leaving: {}",
278                dir.display()
279            )),
280        }
281    }
282}
283
284// --- Interactive prompts (all write to stderr) ---
285
286/// Prompt user to select what to reset.
287fn prompt_scope(target: &ResetTarget) -> Result<ResetScope> {
288    eprintln!("What would you like to reset?");
289
290    let mut options: Vec<(&str, ResetScope)> = Vec::new();
291
292    if target.config_file.is_some() && target.has_workspaces() {
293        options.push((
294            "Everything (global config + all workspaces)",
295            ResetScope::Everything,
296        ));
297    }
298
299    if target.config_file.is_some() {
300        options.push(("Global config only", ResetScope::ConfigOnly));
301    }
302
303    if target.workspaces.len() > 1 {
304        options.push(("All workspaces", ResetScope::AllWorkspaces));
305    }
306
307    if target.has_workspaces() {
308        options.push((
309            "A specific workspace",
310            ResetScope::Workspace(PathBuf::new()),
311        ));
312    }
313
314    // If only one option, skip the menu
315    if options.len() == 1 {
316        let (_, scope) = options.remove(0);
317        return match scope {
318            ResetScope::Workspace(_) => prompt_workspace(&target.workspaces),
319            other => Ok(other),
320        };
321    }
322
323    for (i, (label, _)) in options.iter().enumerate() {
324        eprintln!("  {}. {}", i + 1, label);
325    }
326
327    let choice = prompt_number("> ", options.len())?;
328    let (_, scope) = options.remove(choice - 1);
329
330    match scope {
331        ResetScope::Workspace(_) => prompt_workspace(&target.workspaces),
332        other => Ok(other),
333    }
334}
335
336/// Prompt user to pick a specific workspace.
337fn prompt_workspace(workspaces: &[WorkspaceDetail]) -> Result<ResetScope> {
338    eprintln!("\nSelect a workspace to delete:");
339    for (i, ws) in workspaces.iter().enumerate() {
340        let path_display = crate::config::workspace::tilde_collapse_path(&ws.root_path);
341        let orgs = if ws.orgs.is_empty() {
342            "all orgs".to_string()
343        } else {
344            format!("{} org(s)", ws.orgs.len())
345        };
346        let synced = ws
347            .last_synced
348            .as_deref()
349            .map(humanize_timestamp)
350            .unwrap_or_else(|| "never synced".to_string());
351        eprintln!("  {}. {}  ({}, {})", i + 1, path_display, orgs, synced);
352    }
353
354    let choice = prompt_number("> ", workspaces.len())?;
355    Ok(ResetScope::Workspace(
356        workspaces[choice - 1].root_path.clone(),
357    ))
358}
359
360/// Read a number from stdin (1-based, within max).
361fn prompt_number(prompt: &str, max: usize) -> Result<usize> {
362    loop {
363        eprint!("{}", prompt);
364        io::stderr().flush()?;
365
366        let stdin = io::stdin();
367        let mut line = String::new();
368        let bytes_read = stdin.lock().read_line(&mut line)?;
369        if bytes_read == 0 {
370            return Err(AppError::Interrupted);
371        }
372
373        match line.trim().parse::<usize>() {
374            Ok(n) if n >= 1 && n <= max => return Ok(n),
375            _ => eprintln!("Please enter a number between 1 and {}.", max),
376        }
377    }
378}
379
380/// Prompt the user for y/N confirmation.
381fn confirm(prompt: &str) -> Result<bool> {
382    eprint!("{}", prompt);
383    io::stderr().flush()?;
384
385    let stdin = io::stdin();
386    let mut line = String::new();
387    stdin.lock().read_line(&mut line)?;
388
389    let answer = line.trim().to_lowercase();
390    Ok(answer == "y" || answer == "yes")
391}
392
393// --- Formatting helpers ---
394
395/// Humanize an ISO 8601 timestamp to a relative string like "2h ago".
396fn humanize_timestamp(ts: &str) -> String {
397    let parsed = ts
398        .parse::<DateTime<Utc>>()
399        .or_else(|_| DateTime::parse_from_rfc3339(ts).map(|dt| dt.with_timezone(&Utc)));
400
401    let Ok(dt) = parsed else {
402        return ts.to_string();
403    };
404
405    let duration = Utc::now().signed_duration_since(dt);
406
407    if duration.num_days() > 30 {
408        format!("{}mo ago", duration.num_days() / 30)
409    } else if duration.num_days() > 0 {
410        format!("{}d ago", duration.num_days())
411    } else if duration.num_hours() > 0 {
412        format!("{}h ago", duration.num_hours())
413    } else if duration.num_minutes() > 0 {
414        format!("{}m ago", duration.num_minutes())
415    } else {
416        "just now".to_string()
417    }
418}
419
420/// Format bytes to human-readable string.
421fn format_bytes(bytes: u64) -> String {
422    if bytes >= 1_048_576 {
423        format!("{:.1} MB", bytes as f64 / 1_048_576.0)
424    } else if bytes >= 1024 {
425        format!("{:.1} KB", bytes as f64 / 1024.0)
426    } else {
427        format!("{} B", bytes)
428    }
429}
430
431#[cfg(test)]
432#[path = "reset_tests.rs"]
433mod tests;