1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
//! The one-time fold of `.status` sidecars into committed frontmatter
//! `state:` keys (design: ticket-state-frontmatter, rule 4) — `kranz ticket
//! migrate-state`.
//!
//! [`fold_sidecar_states`] walks `.kranz/tickets/` and, for every ticket whose
//! gitignored `.status` sidecar records the terminal pipeline state `done`,
//! folds that verdict into the committed .md as `state: done` (carrying the
//! sidecar note into `state-note:`) via [`Ticket::write_lifecycle`] — the one
//! write path that also refreshes the sidecar cache. Dry-run by default: the
//! same function with `apply: false` reports what it WOULD do without writing
//! a byte, so the operator reviews the fold before applying it.
//!
//! Three classes of ticket are never rewritten:
//! - a ticket whose .md is DIRTY in git (uncommitted modification, or
//! untracked): an in-flight editor or agent may have the file open, and
//! rewriting it is the house-rule violation this command exists once to
//! perform — the skip is reported by name so the operator can fold the
//! ticket after that work lands. The dirty check is fail-closed: no git,
//! no fold.
//! - a ticket that already carries a `state:` key — this is what makes a
//! re-run idempotent (the frontmatter is the source of truth; the fold
//! only fills ABSENT keys, it never overwrites an operator's verdict);
//! - a ticket with no sidecar, or whose sidecar holds a non-terminal
//! PIPELINE state (drafting/review/queued/failed/…): the frontmatter
//! domain has no value for pipeline states — they stay sidecar-owned by
//! design, and a fresh-clone re-read of an in-flight ticket as NEW is the
//! same behavior the pipeline always had.
use crate::error::{EngineError, Result};
use crate::git_ops::GitRepo;
use crate::ticket::{Ticket, TicketLifecycle, TicketState};
use std::collections::HashSet;
use std::path::Path;
/// One ticket's fold decision.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FoldAction {
/// Sidecar `done` → frontmatter `state: done`, carrying the sidecar note
/// into `state-note:` when present (the note is carried, never parsed —
/// "Superseded by …" prose stays prose).
Fold { slug: String, note: Option<String> },
/// Already carries a frontmatter `state:` key — untouched (this is what
/// makes a re-run idempotent).
AlreadyMigrated { slug: String },
/// The .md is dirty in git: never rewrite a file an in-flight editor or
/// agent has open. Named loudly in the report; the operator folds it by
/// re-running once the in-flight work lands.
SkipDirty { slug: String },
/// Nothing terminal to fold: no sidecar at all (`None`), or a
/// non-terminal pipeline sidecar — pipeline states are not operator
/// lifecycle and remain sidecar-owned by design.
NoTerminalSidecar {
slug: String,
sidecar: Option<TicketState>,
},
}
impl FoldAction {
fn slug(&self) -> &str {
match self {
FoldAction::Fold { slug, .. }
| FoldAction::AlreadyMigrated { slug }
| FoldAction::SkipDirty { slug }
| FoldAction::NoTerminalSidecar { slug, .. } => slug,
}
}
}
/// The fold plan (dry-run) or record (applied) over one repo's tickets.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MigrationReport {
/// `false` = dry-run: the actions are what WOULD happen; no byte was
/// written. `true` = the [`FoldAction::Fold`] entries were applied.
pub applied: bool,
/// Per-ticket decisions, sorted by slug for a deterministic report.
pub actions: Vec<FoldAction>,
}
impl MigrationReport {
/// Tickets the fold rewrote (or would rewrite), counted.
pub fn folds(&self) -> usize {
self.actions
.iter()
.filter(|a| matches!(a, FoldAction::Fold { .. }))
.count()
}
/// Tickets skipped because their .md has uncommitted changes.
pub fn dirty_skips(&self) -> usize {
self.actions
.iter()
.filter(|a| matches!(a, FoldAction::SkipDirty { .. }))
.count()
}
/// Tickets that already carried a frontmatter `state:` key.
pub fn already_migrated(&self) -> usize {
self.actions
.iter()
.filter(|a| matches!(a, FoldAction::AlreadyMigrated { .. }))
.count()
}
/// Tickets with nothing terminal to fold (no sidecar / pipeline sidecar).
pub fn left_alone(&self) -> usize {
self.actions
.iter()
.filter(|a| matches!(a, FoldAction::NoTerminalSidecar { .. }))
.count()
}
}
/// Plan (and with `apply: true`, perform) the fold of terminal `.status`
/// sidecars into frontmatter `state:` keys. See the module docs for the skip
/// rules. Fail-closed on the git dirty-check: when uncommitted edits cannot
/// be detected, NOTHING is planned or written — the check is the only thing
/// standing between the fold and an in-flight edit.
pub fn fold_sidecar_states(repo_root: &Path, apply: bool) -> Result<MigrationReport> {
let git = GitRepo::open(repo_root).map_err(|e| {
EngineError::Git(format!(
"migrate-state needs git to detect uncommitted ticket edits before \
rewriting them (fail-closed): {e}"
))
})?;
let dirty: HashSet<String> = git
.dirty_paths()?
.iter()
// Porcelain paths are repo-relative with forward slashes on every
// platform; normalize anyway so a Windows `\` never misses a match.
.map(|p| p.to_string_lossy().replace('\\', "/"))
.collect();
let mut actions = Vec::new();
for ticket in Ticket::list(repo_root) {
let slug = ticket.slug;
if dirty.contains(&format!(".kranz/tickets/{slug}.md")) {
actions.push(FoldAction::SkipDirty { slug });
continue;
}
if ticket.lifecycle.is_some() {
actions.push(FoldAction::AlreadyMigrated { slug });
continue;
}
match Ticket::sidecar_record(repo_root, &slug) {
Some((TicketState::Done, note)) => {
if apply {
Ticket::write_lifecycle(repo_root, &slug, TicketLifecycle::Done, note.clone())?;
}
actions.push(FoldAction::Fold { slug, note });
}
other => actions.push(FoldAction::NoTerminalSidecar {
slug,
sidecar: other.map(|(state, _)| state),
}),
}
}
actions.sort_by(|a, b| a.slug().cmp(b.slug()));
Ok(MigrationReport {
applied: apply,
actions,
})
}