speccoder_runtime/new_project/
undo.rs1use 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
15fn 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
23fn 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 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 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#[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 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
126fn to_trash(path: &Path) -> Result<(), String> {
128 trash::delete(path).map_err(|e| e.to_string())
129}
130
131#[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#[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}