Skip to content

Commit fdc0b11

Browse files
committed
feat: add wiggum clean command
Adds 'wiggum clean' to remove generated artifacts (PROGRESS.md, IMPLEMENTATION_PLAN.md, AGENTS.md, orchestrator.prompt.md, task files). - Supports --dry-run to preview what would be removed - Only removes files wiggum generated (preserves hand-written files) - Cleans up empty tasks/ and .vscode/ directories - 5 new unit tests Bumps version to 0.2.0.
1 parent 204c9f3 commit fdc0b11

File tree

7 files changed

+342
-3
lines changed

7 files changed

+342
-3
lines changed

CHANGELOG.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
66

77
## [Unreleased]
88

9+
## [0.2.0] - 2026-02-26
10+
11+
### Added
12+
13+
- `wiggum clean` command to remove generated artifacts (`--dry-run` supported)
14+
15+
## [0.1.0] - 2026-02-24
16+
917
### Added
1018

1119
- Interactive plan creation (`wiggum init`)
@@ -25,4 +33,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
2533
- VCS-aware reporting with git timeline
2634
- mdBook documentation site
2735

28-
[Unreleased]: https://github.com/greysquirr3l/wiggum/compare/main...HEAD
36+
[Unreleased]: https://github.com/greysquirr3l/wiggum/compare/v0.2.0...HEAD
37+
[0.2.0]: https://github.com/greysquirr3l/wiggum/compare/v0.1.0...v0.2.0
38+
[0.1.0]: https://github.com/greysquirr3l/wiggum/releases/tag/v0.1.0

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "wiggum"
3-
version = "0.1.0"
3+
version = "0.2.0"
44
edition = "2024"
55
description = "AI orchestration scaffold generator for the Ralph Wiggum loop"
66
license = "MIT OR Apache-2.0"

src/adapters/cli.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,4 +107,18 @@ pub enum Command {
107107
#[arg(long)]
108108
force: bool,
109109
},
110+
111+
/// Remove all wiggum-generated artifacts from a project
112+
Clean {
113+
/// Path to the plan TOML file
114+
plan: PathBuf,
115+
116+
/// Override the target directory (defaults to project.path from the plan)
117+
#[arg(short, long)]
118+
output: Option<PathBuf>,
119+
120+
/// Preview what would be removed without deleting anything
121+
#[arg(long)]
122+
dry_run: bool,
123+
},
110124
}

src/generation/clean.rs

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
use std::fs;
2+
use std::path::{Path, PathBuf};
3+
4+
use tracing::info;
5+
6+
use crate::domain::plan::Plan;
7+
use crate::error::Result;
8+
9+
/// Files and directories that wiggum generates.
10+
const GENERATED_FILES: &[&str] = &[
11+
"PROGRESS.md",
12+
"IMPLEMENTATION_PLAN.md",
13+
"AGENTS.md",
14+
".vscode/orchestrator.prompt.md",
15+
];
16+
17+
/// Collect all wiggum-generated paths that exist on disk.
18+
///
19+
/// # Errors
20+
///
21+
/// Returns an error if task resolution fails.
22+
pub fn collect_targets(plan: &Plan, project_path: &Path) -> Result<Vec<PathBuf>> {
23+
let mut targets = Vec::new();
24+
25+
for file in GENERATED_FILES {
26+
let path = project_path.join(file);
27+
if path.exists() {
28+
targets.push(path);
29+
}
30+
}
31+
32+
// Collect individual task files from the plan so we only remove
33+
// files wiggum would have generated — not hand-written files that
34+
// happen to live in tasks/.
35+
let resolved = plan.resolve_tasks()?;
36+
let tasks_dir = project_path.join("tasks");
37+
if tasks_dir.is_dir() {
38+
for t in &resolved {
39+
let filename = format!("T{:02}-{}.md", t.number, t.slug);
40+
let path = tasks_dir.join(&filename);
41+
if path.exists() {
42+
targets.push(path);
43+
}
44+
}
45+
}
46+
47+
// If the tasks dir is empty after removal, mark it for cleanup too
48+
targets.push(tasks_dir);
49+
50+
targets.sort();
51+
targets.dedup();
52+
Ok(targets)
53+
}
54+
55+
/// Remove wiggum-generated artifacts from the project directory.
56+
///
57+
/// Returns the list of paths that were actually removed.
58+
///
59+
/// # Errors
60+
///
61+
/// Returns an error if task resolution or file removal fails.
62+
pub fn remove_artifacts(plan: &Plan, project_path: &Path) -> Result<Vec<PathBuf>> {
63+
let targets = collect_targets(plan, project_path)?;
64+
let mut removed = Vec::new();
65+
66+
// Remove files first, then directories (so dirs are empty when we try)
67+
for path in targets.iter().filter(|p| p.is_file()) {
68+
fs::remove_file(path)?;
69+
info!("Removed file: {}", path.display());
70+
removed.push(path.clone());
71+
}
72+
73+
for path in targets.iter().filter(|p| p.is_dir()) {
74+
if is_dir_empty(path) {
75+
fs::remove_dir(path)?;
76+
info!("Removed directory: {}", path.display());
77+
removed.push(path.clone());
78+
}
79+
}
80+
81+
// Clean up .vscode/ if empty after removing orchestrator.prompt.md
82+
let vscode_dir = project_path.join(".vscode");
83+
if vscode_dir.is_dir() && is_dir_empty(&vscode_dir) {
84+
fs::remove_dir(&vscode_dir)?;
85+
info!("Removed empty directory: {}", vscode_dir.display());
86+
removed.push(vscode_dir);
87+
}
88+
89+
Ok(removed)
90+
}
91+
92+
fn is_dir_empty(path: &Path) -> bool {
93+
path.read_dir()
94+
.map(|mut entries| entries.next().is_none())
95+
.unwrap_or(false)
96+
}
97+
98+
#[cfg(test)]
99+
#[allow(clippy::unwrap_used)]
100+
mod tests {
101+
use super::*;
102+
use crate::domain::plan::Plan;
103+
use std::fs;
104+
use tempfile::TempDir;
105+
106+
fn sample_plan(path: &str) -> Plan {
107+
let toml = format!(
108+
r#"
109+
[project]
110+
name = "test-project"
111+
description = "test"
112+
language = "rust"
113+
path = "{path}"
114+
115+
[[phases]]
116+
name = "Phase 1"
117+
order = 1
118+
119+
[[phases.tasks]]
120+
slug = "setup"
121+
title = "Project setup"
122+
goal = "Set up the project"
123+
depends_on = []
124+
125+
[[phases.tasks]]
126+
slug = "model"
127+
title = "Domain model"
128+
goal = "Define domain types"
129+
depends_on = ["setup"]
130+
"#
131+
);
132+
Plan::from_toml(&toml).unwrap()
133+
}
134+
135+
#[test]
136+
fn collect_targets_finds_existing_files() {
137+
let tmp = TempDir::new().unwrap();
138+
let root = tmp.path();
139+
140+
// Create the files wiggum would generate
141+
fs::write(root.join("PROGRESS.md"), "progress").unwrap();
142+
fs::write(root.join("IMPLEMENTATION_PLAN.md"), "plan").unwrap();
143+
fs::write(root.join("AGENTS.md"), "agents").unwrap();
144+
fs::create_dir_all(root.join(".vscode")).unwrap();
145+
fs::write(root.join(".vscode/orchestrator.prompt.md"), "orch").unwrap();
146+
fs::create_dir_all(root.join("tasks")).unwrap();
147+
fs::write(root.join("tasks/T01-setup.md"), "task1").unwrap();
148+
fs::write(root.join("tasks/T02-model.md"), "task2").unwrap();
149+
150+
let plan = sample_plan(&root.to_string_lossy());
151+
let targets = collect_targets(&plan, root).unwrap();
152+
153+
assert!(targets.iter().any(|p| p.ends_with("PROGRESS.md")));
154+
assert!(
155+
targets
156+
.iter()
157+
.any(|p| p.ends_with("IMPLEMENTATION_PLAN.md"))
158+
);
159+
assert!(targets.iter().any(|p| p.ends_with("AGENTS.md")));
160+
assert!(
161+
targets
162+
.iter()
163+
.any(|p| p.ends_with("orchestrator.prompt.md"))
164+
);
165+
assert!(targets.iter().any(|p| p.ends_with("T01-setup.md")));
166+
assert!(targets.iter().any(|p| p.ends_with("T02-model.md")));
167+
}
168+
169+
#[test]
170+
fn collect_targets_ignores_missing_files() {
171+
let tmp = TempDir::new().unwrap();
172+
let root = tmp.path();
173+
174+
let plan = sample_plan(&root.to_string_lossy());
175+
let targets = collect_targets(&plan, root).unwrap();
176+
177+
// Only the tasks/ dir entry (which doesn't exist either)
178+
// should be present — all file entries are skipped
179+
assert!(!targets.iter().any(|p| p.is_file()));
180+
}
181+
182+
#[test]
183+
fn remove_artifacts_deletes_generated_files() {
184+
let tmp = TempDir::new().unwrap();
185+
let root = tmp.path();
186+
187+
fs::write(root.join("PROGRESS.md"), "progress").unwrap();
188+
fs::write(root.join("IMPLEMENTATION_PLAN.md"), "plan").unwrap();
189+
fs::write(root.join("AGENTS.md"), "agents").unwrap();
190+
fs::create_dir_all(root.join(".vscode")).unwrap();
191+
fs::write(root.join(".vscode/orchestrator.prompt.md"), "orch").unwrap();
192+
fs::create_dir_all(root.join("tasks")).unwrap();
193+
fs::write(root.join("tasks/T01-setup.md"), "task1").unwrap();
194+
fs::write(root.join("tasks/T02-model.md"), "task2").unwrap();
195+
196+
// Also create a non-wiggum file that should survive
197+
fs::write(root.join("Cargo.toml"), "[package]").unwrap();
198+
199+
let plan = sample_plan(&root.to_string_lossy());
200+
let removed = remove_artifacts(&plan, root).unwrap();
201+
202+
assert!(!root.join("PROGRESS.md").exists());
203+
assert!(!root.join("IMPLEMENTATION_PLAN.md").exists());
204+
assert!(!root.join("AGENTS.md").exists());
205+
assert!(!root.join(".vscode/orchestrator.prompt.md").exists());
206+
assert!(!root.join(".vscode").exists()); // empty dir cleaned up
207+
assert!(!root.join("tasks/T01-setup.md").exists());
208+
assert!(!root.join("tasks/T02-model.md").exists());
209+
assert!(!root.join("tasks").exists()); // empty dir cleaned up
210+
211+
// Non-wiggum file survives
212+
assert!(root.join("Cargo.toml").exists());
213+
214+
assert!(removed.len() >= 7);
215+
}
216+
217+
#[test]
218+
fn remove_artifacts_preserves_non_wiggum_task_files() {
219+
let tmp = TempDir::new().unwrap();
220+
let root = tmp.path();
221+
222+
fs::create_dir_all(root.join("tasks")).unwrap();
223+
fs::write(root.join("tasks/T01-setup.md"), "task1").unwrap();
224+
fs::write(root.join("tasks/my-custom-notes.md"), "keep me").unwrap();
225+
226+
let plan = sample_plan(&root.to_string_lossy());
227+
remove_artifacts(&plan, root).unwrap();
228+
229+
assert!(!root.join("tasks/T01-setup.md").exists());
230+
// Custom file survives
231+
assert!(root.join("tasks/my-custom-notes.md").exists());
232+
// tasks/ dir survives because it's not empty
233+
assert!(root.join("tasks").is_dir());
234+
}
235+
236+
#[test]
237+
fn remove_artifacts_preserves_non_wiggum_vscode_files() {
238+
let tmp = TempDir::new().unwrap();
239+
let root = tmp.path();
240+
241+
fs::create_dir_all(root.join(".vscode")).unwrap();
242+
fs::write(root.join(".vscode/orchestrator.prompt.md"), "orch").unwrap();
243+
fs::write(root.join(".vscode/settings.json"), "{}").unwrap();
244+
245+
let plan = sample_plan(&root.to_string_lossy());
246+
remove_artifacts(&plan, root).unwrap();
247+
248+
assert!(!root.join(".vscode/orchestrator.prompt.md").exists());
249+
// settings.json survives
250+
assert!(root.join(".vscode/settings.json").exists());
251+
// .vscode/ dir survives because it's not empty
252+
assert!(root.join(".vscode").is_dir());
253+
}
254+
}

src/generation/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
pub mod agents_md;
2+
pub mod clean;
23
pub mod orchestrator;
34
pub mod plan_doc;
45
pub mod progress;

src/main.rs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,11 @@ fn main() {
6262
output,
6363
force,
6464
} => cmd_bootstrap(&path, output.as_deref(), force),
65+
Command::Clean {
66+
plan,
67+
output,
68+
dry_run,
69+
} => cmd_clean(&plan, output.as_deref(), dry_run),
6570
};
6671

6772
if let Err(e) = result {
@@ -264,3 +269,58 @@ fn cmd_bootstrap(
264269
bootstrap::run_bootstrap(project_path, output, force)?;
265270
Ok(())
266271
}
272+
273+
fn cmd_clean(
274+
plan_path: &Path,
275+
output_override: Option<&Path>,
276+
dry_run: bool,
277+
) -> wiggum::error::Result<()> {
278+
let fs = FsAdapter;
279+
let toml_content = fs.read_plan(plan_path)?;
280+
let plan = Plan::from_toml(&toml_content)?;
281+
282+
let project_path =
283+
output_override.map_or_else(|| PathBuf::from(&plan.project.path), Path::to_path_buf);
284+
285+
if dry_run {
286+
let targets = generation::clean::collect_targets(&plan, &project_path)?;
287+
let existing: Vec<_> = targets.iter().filter(|p| p.exists()).collect();
288+
if existing.is_empty() {
289+
println!("Nothing to clean in {}", project_path.display());
290+
} else {
291+
println!("Dry run — would remove:\n");
292+
for path in &existing {
293+
let relative = path.strip_prefix(&project_path).unwrap_or(path);
294+
if path.is_dir() {
295+
println!(" 📁 {}/", relative.display());
296+
} else {
297+
println!(" 🗑 {}", relative.display());
298+
}
299+
}
300+
println!(
301+
"\n Total: {} item(s) in {}",
302+
existing.len(),
303+
project_path.display()
304+
);
305+
}
306+
return Ok(());
307+
}
308+
309+
let removed = generation::clean::remove_artifacts(&plan, &project_path)?;
310+
311+
if removed.is_empty() {
312+
println!("Nothing to clean in {}", project_path.display());
313+
} else {
314+
println!(
315+
"🧹 Cleaned {} item(s) from {}",
316+
removed.len(),
317+
project_path.display()
318+
);
319+
for path in &removed {
320+
let relative = path.strip_prefix(&project_path).unwrap_or(path);
321+
println!(" ✕ {}", relative.display());
322+
}
323+
}
324+
325+
Ok(())
326+
}

0 commit comments

Comments
 (0)