Skip to main content

speccoder_runtime/new_project/
undo.rs

1//! Undo a project speccoder created — only while nothing but speccoder's
2//! commit exists there. A new folder goes to the system Trash (never
3//! deleted); in an existing repository speccoder's commit is reverted as
4//! the person, history kept.
5
6use std::path::Path;
7
8use speccoder_core::actions::Via;
9use speccoder_core::new_project::{ProjectMode, UndoOffer};
10use speccoder_core::{CommandResult, CommandStatus};
11
12use super::{COMMAND, Creation, creation, now, path_digest, shown, write_creation};
13use crate::git::Git;
14
15/// Paths a check's run leaves that do not count as a change.
16fn build_output(path: &str) -> bool {
17    speccoder_core::collection::is_build_or_dependency(path)
18        || path
19            .split('/')
20            .any(|c| matches!(c, ".pytest_cache" | ".ruff_cache" | ".mypy_cache"))
21}
22
23/// What changed since speccoder's commit, at most 10 paths and a count;
24/// empty when nothing did.
25fn changes(root: &Path, c: &Creation) -> Result<Vec<String>, String> {
26    let g = Git::at(root);
27    let mut out = Vec::new();
28    let head = g.head().map_err(|e| e.0)?;
29    if head.as_deref() != c.commit.as_deref() {
30        out.push(format!(
31            "HEAD is {} now, not speccoder's commit",
32            head.map_or_else(|| "unborn".to_owned(), |h| h.chars().take(7).collect())
33        ));
34    }
35    // Untrimmed: a status line starts with a space (` M path`).
36    let porcelain = String::from_utf8_lossy(
37        &g.run(&[
38            "status",
39            "--porcelain",
40            "--untracked-files=all",
41            "--ignored",
42        ])
43        .map_err(|e| e.0)?,
44    )
45    .into_owned();
46    for line in porcelain.lines() {
47        let path = line.get(3..).unwrap_or_default();
48        // Build output, and what git ignored before speccoder ran, is no change.
49        if line.starts_with("!!")
50            && (build_output(path)
51                || c.before
52                    .ignored
53                    .iter()
54                    .any(|i| i == path || path.starts_with(i.as_str())))
55        {
56            continue;
57        }
58        out.push(path.to_owned());
59    }
60    if c.mode == ProjectMode::NewFolder {
61        let refs = g
62            .line(&["for-each-ref", "--format=%(refname)"])
63            .map_err(|e| e.0)?;
64        let refs: Vec<&str> = refs.lines().filter(|r| !r.is_empty()).collect();
65        let heads = refs.iter().filter(|r| r.starts_with("refs/heads/")).count();
66        if heads > 1 {
67            out.push(format!("{heads} branches"));
68        }
69        for r in refs.iter().filter(|r| !r.starts_with("refs/heads/")) {
70            out.push((*r).to_owned());
71        }
72    }
73    Ok(out)
74}
75
76/// What Undo would do for the project at `root`, now.
77#[must_use]
78pub fn undo_offer(state: &Path, root: &Path) -> UndoOffer {
79    let none = |reason: &str| UndoOffer {
80        available: false,
81        kind: "none".to_owned(),
82        reason: Some(reason.to_owned()),
83    };
84    let Some(c) = creation(state, root) else {
85        return none("speccoder did not create this project");
86    };
87    if c.undone_at.is_some() {
88        return none("it was undone already");
89    }
90    if c.commit.is_none() {
91        return none("speccoder has not committed it yet");
92    }
93    let kind = match c.mode {
94        // Only a folder speccoder made goes to the Trash.
95        ProjectMode::NewFolder if !c.before.made => {
96            return none("speccoder did not make this folder: remove what it added yourself");
97        }
98        ProjectMode::NewFolder => "trash",
99        ProjectMode::ExistingRepository => "revert",
100    };
101    match changes(root, &c) {
102        Ok(ch) if ch.is_empty() => UndoOffer {
103            available: true,
104            kind: kind.to_owned(),
105            reason: None,
106        },
107        Ok(ch) => UndoOffer {
108            available: false,
109            kind: kind.to_owned(),
110            reason: Some(format!("changed since: {}", listed(&ch))),
111        },
112        Err(e) => none(&e),
113    }
114}
115
116fn listed(paths: &[String]) -> String {
117    let shown: Vec<&str> = paths.iter().take(10).map(String::as_str).collect();
118    let more = paths.len().saturating_sub(10);
119    if more > 0 {
120        format!("{} and {more} more", shown.join(", "))
121    } else {
122        shown.join(", ")
123    }
124}
125
126/// Moves `path` to the system Trash.
127fn to_trash(path: &Path) -> Result<(), String> {
128    trash::delete(path).map_err(|e| e.to_string())
129}
130
131/// `speccoder new --undo`: the project at `root`, undone — its folder to
132/// the Trash, or speccoder's commit reverted — when `commit` is still the
133/// only thing that happened there.
134#[must_use]
135pub fn undo(
136    root: &Path,
137    commit: &str,
138    via: Via,
139    state: &Path,
140    options: &crate::RunOptions,
141) -> CommandResult {
142    undo_with(root, commit, via, state, options, &to_trash)
143}
144
145/// [`undo`] with the Trash given (tests: a folder standing in for it).
146#[must_use]
147pub fn undo_with(
148    root: &Path,
149    commit: &str,
150    via: Via,
151    state: &Path,
152    options: &crate::RunOptions,
153    trash: &dyn Fn(&Path) -> Result<(), String>,
154) -> CommandResult {
155    let refused = |m: &str| {
156        CommandResult::new(COMMAND, CommandStatus::GitPrecondition, m).with_code("NEW-UNDO-REFUSED")
157    };
158    let root = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
159    let Some(mut c) = creation(state, &root) else {
160        return refused(&format!(
161            "speccoder did not create {}; nothing was moved",
162            shown(&root)
163        ));
164    };
165    let Some(made) = c.commit.clone() else {
166        return refused("speccoder has not committed this project; nothing was moved");
167    };
168    if c.undone_at.is_some() {
169        return refused("this project was undone already");
170    }
171    if !commit.is_empty() && !made.starts_with(commit) && !commit.starts_with(&made) {
172        return refused(&format!(
173            "{commit} is not speccoder's commit ({}); nothing was moved",
174            made.chars().take(7).collect::<String>()
175        ));
176    }
177    match changes(&root, &c) {
178        Ok(ch) if ch.is_empty() => {}
179        Ok(ch) => {
180            return refused(&format!(
181                "{} changed since speccoder's commit: {}; nothing was moved",
182                shown(&root),
183                listed(&ch)
184            ));
185        }
186        Err(e) => return refused(&e),
187    }
188    let (how, message) = match c.mode {
189        ProjectMode::NewFolder if !c.before.made => {
190            return refused(&format!(
191                "speccoder did not make {}: it moves to the Trash only a folder it made; nothing was moved",
192                shown(&root)
193            ));
194        }
195        ProjectMode::NewFolder => match trash(&root) {
196            Ok(()) => ("trash", format!("moved {} to the Trash", shown(&root))),
197            Err(e) => {
198                return refused(&format!(
199                    "{} cannot go to the Trash on this system ({e}); remove it yourself — speccoder never deletes it",
200                    shown(&root)
201                ));
202            }
203        },
204        ProjectMode::ExistingRepository => {
205            match Git::at(&root).run(&["revert", "--no-edit", &made]) {
206                Ok(_) => (
207                    "revert",
208                    format!(
209                        "reverted {} \"new project: {}\"",
210                        made.chars().take(7).collect::<String>(),
211                        c.name
212                    ),
213                ),
214                Err(e) => return CommandResult::new(COMMAND, CommandStatus::GitPrecondition, &e.0),
215            }
216        }
217    };
218    c.undone_at = Some(now());
219    let _ = write_creation(state, &c);
220    crate::welcome::forget(state, &root);
221    if let Some(p) = &options.progress {
222        p.event(&crate::progress::Event::Project {
223            step: crate::progress::ProjectEvent::Undone {
224                path_digest: path_digest(&root),
225                how: how.to_owned(),
226                via: via.as_str().to_owned(),
227            },
228        });
229    }
230    CommandResult::new(COMMAND, CommandStatus::Ok, &message)
231}