Skip to content

Commit 0e35647

Browse files
committed
fix(mt-core): kill видаляє вузол без run-історії, а не архівує
Помилково створений вузол (жодного run/fact/audit-артефакту в піддереві) раніше все одно переміщувався у .history/, засмічуючи архів. Тепер kill перевіряє наявність run-артефактів по всьому піддереву й лише за їх наявності архівує; інакше видаляє директорію назавжди.
1 parent b43d946 commit 0e35647

1 file changed

Lines changed: 69 additions & 5 deletions

File tree

crates/mt-core/src/lifecycle.rs

Lines changed: 69 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,11 @@
44
//! - invalidate: архівує version chain у `history/<ts>-invalidate/`, нова
55
//! chain стартує з NNN=001; каскад вниз по нащадках; без sentinel-файлів —
66
//! стан derived з відсутності `fact_*.md`.
7-
//! - kill: архівує весь вузол у `<tasks-root>/.history/<ts>-kill-<path>/`
8-
//! і прибирає директорію; каскад повний за визначенням (піддерево).
7+
//! - kill: якщо піддерево вузла (сам вузол + нащадки) не має жодного
8+
//! run-артефакту (chain-файли, `run-summary.md`, `history/`) — вузол
9+
//! видаляється назавжди (не було що архівувати, помилково створений
10+
//! вузол); інакше архівується у `<tasks-root>/.history/<ts>-kill-<path>/`
11+
//! і прибирається директорія; каскад повний за визначенням (піддерево).
912
1013
use std::fs;
1114
use std::path::Path;
@@ -118,16 +121,45 @@ fn invalidate_rec(
118121
Ok(())
119122
}
120123

121-
/// `mt kill <path>` (файловий рівень): архівує весь вузол з нащадками у
122-
/// `<tasks-root>/.history/<ts>-kill-<path>/` і прибирає директорію.
123-
/// Повертає шлях архіву відносно tasks root.
124+
/// Чи має вузол (без рекурсії в нащадків) артефакти запуску: chain-файли,
125+
/// `run-summary.md`, або `history/` (архів попередніх invalidate).
126+
fn has_run_artifacts_here(dir: &Path) -> bool {
127+
if dir.join("run-summary.md").is_file() || dir.join("history").is_dir() {
128+
return true;
129+
}
130+
let Ok(entries) = fs::read_dir(dir) else {
131+
return false;
132+
};
133+
entries.flatten().any(|entry| {
134+
entry.file_type().map(|t| t.is_file()).unwrap_or(false)
135+
&& is_chain_file(&entry.file_name().to_string_lossy())
136+
})
137+
}
138+
139+
/// Чи має піддерево вузла (сам вузол + всі нащадки) бодай один run-артефакт.
140+
fn has_run_artifacts(dir: &Path) -> bool {
141+
has_run_artifacts_here(dir)
142+
|| child_nodes(dir)
143+
.iter()
144+
.any(|c| has_run_artifacts(&dir.join(c)))
145+
}
146+
147+
/// `mt kill <path>` (файловий рівень): якщо піддерево вузла ще не мало
148+
/// жодного запуску — видаляє його назавжди; інакше архівує весь вузол
149+
/// з нащадками у `<tasks-root>/.history/<ts>-kill-<path>/` і прибирає
150+
/// директорію. Повертає `.history/<archive>` (архівовано) або
151+
/// `deleted:<node_path>` (видалено без історії).
124152
pub fn kill(tasks_dir: &str, node_path: &str) -> Result<String, String> {
125153
validate_name(node_path)?;
126154
let root = Path::new(tasks_dir);
127155
let dir = root.join(node_path);
128156
if !dir.join("task.md").is_file() {
129157
return Err(format!("node not found: {node_path}"));
130158
}
159+
if !has_run_artifacts(&dir) {
160+
fs::remove_dir_all(&dir).map_err(|e| e.to_string())?;
161+
return Ok(format!("deleted:{node_path}"));
162+
}
131163
let archive_name = format!("{}-kill-{}", timestamp(), node_path.replace('/', "-"));
132164
let history = root.join(".history");
133165
fs::create_dir_all(&history).map_err(|e| e.to_string())?;
@@ -229,4 +261,36 @@ mod tests {
229261
assert!(kill(&root, "nope").is_err());
230262
assert!(kill(&root, "../escape").is_err());
231263
}
264+
265+
#[test]
266+
fn kill_deletes_fresh_node_without_run_history() {
267+
let tmp = tempfile::tempdir().unwrap();
268+
let node = tmp.path().join("draft");
269+
fs::create_dir_all(&node).unwrap();
270+
fs::write(node.join("task.md"), "x").unwrap();
271+
fs::write(node.join("plan_001.md"), "x").unwrap();
272+
273+
let root = tmp.path().to_string_lossy().into_owned();
274+
let result = kill(&root, "draft").unwrap();
275+
assert_eq!(result, "deleted:draft");
276+
assert!(!node.exists());
277+
assert!(!tmp.path().join(".history").exists());
278+
}
279+
280+
#[test]
281+
fn kill_archives_when_only_a_descendant_has_run_history() {
282+
let tmp = tempfile::tempdir().unwrap();
283+
let node = tmp.path().join("draft");
284+
let child = node.join("sub");
285+
fs::create_dir_all(&child).unwrap();
286+
fs::write(node.join("task.md"), "x").unwrap();
287+
fs::write(child.join("task.md"), "x").unwrap();
288+
fs::write(child.join("run_001.md"), "x").unwrap();
289+
290+
let root = tmp.path().to_string_lossy().into_owned();
291+
let archive = kill(&root, "draft").unwrap();
292+
assert!(archive.starts_with(".history/"));
293+
assert!(!node.exists());
294+
assert!(tmp.path().join(&archive).join("sub/run_001.md").is_file());
295+
}
232296
}

0 commit comments

Comments
 (0)