heddle_cli_args/cli/cli_args/commands_thread.rs
1// SPDX-License-Identifier: Apache-2.0
2//! Thread command definitions.
3
4use clap::{Args, Subcommand};
5
6use super::{
7 CollapseArgs, ExpandArgs, ThreadAbsorbArgs, ThreadCapturesArgs, ThreadCheckoutArgs,
8 ThreadDropArgs, ThreadMoveArgs, ThreadNameArgs, ThreadRenameArgs, ThreadResolveArgs,
9 ThreadShowArgs,
10};
11
12#[derive(Subcommand, Clone)]
13pub enum ThreadCommands {
14 /// Create a thread ref at the current state.
15 #[command(after_help = "\
16Advanced split form:
17 heddle start <name> --path <dir> is the normal one-step isolated-checkout path.
18 heddle thread create <name> only creates the thread ref. Pair it later with
19 heddle thread checkout <name> --path <dir> when you intentionally need to
20 create the ref now and materialize the checkout later.
21")]
22 Create {
23 /// Thread identifier.
24 name: String,
25 /// Mark the thread ephemeral. Auto-collapses after `--ttl` if not
26 /// promoted. The collapse is recorded as
27 /// `OpRecord::EphemeralThreadCollapse`; underlying states stay
28 /// addressable. (W1/A13.)
29 #[arg(long)]
30 ephemeral: bool,
31 /// TTL in seconds. Defaults to 24h when `--ephemeral` is set
32 /// without `--ttl`.
33 #[arg(long, requires = "ephemeral")]
34 ttl_secs: Option<u32>,
35 },
36
37 /// Print the name of the current thread (the thread the working
38 /// checkout is attached to). Read-only — no state change.
39 /// Useful in shell pipelines: `cd "$(heddle thread cd "$(heddle thread current)")"`.
40 Current,
41
42 /// Switch the current checkout to an existing thread ref.
43 Switch {
44 /// Thread identifier.
45 name: String,
46 /// Print only the target thread's checkout path on stdout and
47 /// exit. Used by the shell hook (`heddle shell init`) to auto-cd
48 /// into the new thread:
49 /// dir=$(heddle thread switch X --print-cd-path) && cd "$dir"
50 /// Auto-capture still runs; rich output is suppressed.
51 #[arg(long, hide_short_help = true)]
52 print_cd_path: bool,
53 /// Discard uncommitted changes in the current checkout before switching.
54 #[arg(short, long)]
55 force: bool,
56 },
57
58 /// Print the on-disk path for a thread. Read-only — no state change,
59 /// no auto-capture. Without a shell hook:
60 /// cd "$(heddle thread cd X)"
61 /// With `heddle shell init`, `heddle thread cd X` becomes `cd <path>`.
62 Cd {
63 /// Thread identifier.
64 name: String,
65 },
66
67 /// List threads.
68 List(ThreadListArgs),
69
70 /// Show one thread with actor and workflow context.
71 Show(ThreadShowArgs),
72
73 /// Show granular captures on a thread.
74 Captures(ThreadCapturesArgs),
75
76 /// Rename a thread ref.
77 Rename(ThreadRenameArgs),
78
79 /// Refresh a thread onto its target thread.
80 Refresh(ThreadNameArgs),
81
82 /// Move selected captured paths from one thread into another.
83 Move(ThreadMoveArgs),
84
85 /// Absorb a child thread into its parent or another thread.
86 Absorb(ThreadAbsorbArgs),
87
88 /// Guide a blocked or stale thread toward its next clean state.
89 Resolve(ThreadResolveArgs),
90
91 /// Inspect or explicitly transfer a Thread's native ownership.
92 Ownership {
93 #[command(subcommand)]
94 command: ThreadOwnershipCommands,
95 },
96
97 /// Create a working checkout for this thread.
98 #[command(after_help = "\
99`heddle start <name>` is the one-step default: it creates the thread ref and
100isolated checkout together. `thread checkout <name> --path <dir>` is the
101second step after `heddle thread create <name>` when you intentionally
102created the ref first and want to materialize it later.
103")]
104 Checkout(ThreadCheckoutArgs),
105
106 /// Drop a thread and mark it abandoned.
107 #[command(visible_alias = "delete")]
108 Drop(ThreadDropArgs),
109
110 /// Sweep merged, stale auto-created, or abandoned threads.
111 #[command(
112 long_about = "\
113Sweep threads that have outlived their usefulness. Cleanup removes recorded checkouts, marks matching thread records abandoned, and prunes live thread refs so everyday thread lists stay focused.
114
115Modes:
116 - --merged: clean up threads recorded as merged.
117 - --auto --older-than <duration>: clean up harness-created threads that have not been touched in the given duration.
118 - --abandoned: remove live refs and checkout residue left by abandoned threads; retain their records, states, and audit history.
119
120The three modes can be combined. Pair with --dry-run to preview the work without changing anything on disk.",
121 after_help = "\
122Examples:
123 heddle thread cleanup --merged --dry-run
124 heddle thread cleanup --merged
125 heddle thread cleanup --auto --older-than 7d --dry-run
126 heddle thread cleanup --abandoned --dry-run
127"
128 )]
129 Cleanup(ThreadCleanupArgs),
130
131 /// Collapse (squash) multiple states into one.
132 Collapse(CollapseArgs),
133
134 /// Expand a squashed land into the captures it collapsed.
135 Expand(ExpandArgs),
136
137 /// Manage named state markers under the thread namespace.
138 Marker {
139 #[command(subcommand)]
140 command: ThreadMarkerCommands,
141 },
142}
143
144#[derive(Subcommand, Clone)]
145pub enum ThreadOwnershipCommands {
146 /// Show the local owner or the complete signed conflict.
147 Status { thread: Option<String> },
148 /// Explicitly transfer a local-key Thread to the currently authorized account.
149 Claim { thread: Option<String> },
150 /// Original local owner chooses one accepted claim; the current account accepts.
151 Resolve {
152 thread: Option<String>,
153 /// Full claim ID shown by `heddle thread ownership status`.
154 #[arg(long)]
155 claim: String,
156 },
157}
158
159#[cfg(test)]
160mod ownership_tests {
161 use clap::Parser;
162
163 use super::*;
164 use crate::cli::cli_args::{Cli, Commands};
165
166 #[test]
167 fn ownership_commands_select_named_or_current_thread_and_require_explicit_winner() {
168 let status = Cli::try_parse_from(["heddle", "thread", "ownership", "status"])
169 .expect("current Thread status");
170 assert!(matches!(
171 status.command,
172 Commands::Thread {
173 command: ThreadCommands::Ownership {
174 command: ThreadOwnershipCommands::Status { thread: None }
175 }
176 }
177 ));
178 let resolve = Cli::try_parse_from([
179 "heddle",
180 "thread",
181 "ownership",
182 "resolve",
183 "feature",
184 "--claim",
185 "ab12",
186 ])
187 .expect("named Thread and explicit claim");
188 assert!(
189 matches!(resolve.command, Commands::Thread { command: ThreadCommands::Ownership {
190 command: ThreadOwnershipCommands::Resolve { thread: Some(_), claim }
191 } } if claim == "ab12")
192 );
193 assert!(
194 Cli::try_parse_from(["heddle", "thread", "ownership", "resolve", "feature"]).is_err()
195 );
196 }
197}
198
199#[derive(Subcommand, Clone)]
200pub enum ThreadMarkerCommands {
201 /// List markers, optionally filtered by name prefix.
202 ///
203 /// Pass `--filter <PREFIX>` to return only markers whose name
204 /// starts with the given prefix. The match is a literal
205 /// `starts_with` check, not a glob.
206 List {
207 /// Return only markers whose name starts with this prefix.
208 #[arg(long, value_name = "PREFIX")]
209 filter: Option<String>,
210 },
211
212 /// Create marker at current state.
213 Create {
214 /// Marker name.
215 name: String,
216 },
217
218 /// Delete marker(s).
219 ///
220 /// Pass an exact marker name, or `--prefix <PFX>` to delete every marker
221 /// whose name starts with the given prefix. Exactly one of `<NAME>` or
222 /// `--prefix` must be supplied.
223 Delete {
224 /// Marker name (exact match). Mutually exclusive with `--prefix`.
225 #[arg(required_unless_present = "prefix", conflicts_with = "prefix")]
226 name: Option<String>,
227
228 /// Delete every marker whose name starts with this prefix.
229 #[arg(long)]
230 prefix: Option<String>,
231 },
232
233 /// Show marker details.
234 Show {
235 /// Marker name.
236 name: String,
237 },
238}
239
240/// Arguments for `heddle thread list`.
241///
242/// The default view hides harness-auto-created and abandoned threads.
243/// Pass the corresponding include flags to surface them.
244#[derive(Args, Clone, Debug, Default)]
245pub struct ThreadListArgs {
246 /// Include threads created automatically by harness integrations
247 /// (e.g. Claude Code segment-rotation). Hidden by default to keep
248 /// the view focused on threads the user explicitly created.
249 #[arg(long)]
250 pub include_auto: bool,
251
252 /// Include threads whose lifecycle state is abandoned. Hidden by
253 /// default because they are no longer actionable.
254 #[arg(long)]
255 pub include_abandoned: bool,
256}
257
258/// Arguments for `heddle thread cleanup`.
259///
260/// At least one cleanup mode must be set; otherwise the command refuses
261/// with a clear message. `--older-than` is required when `--auto` is set.
262#[derive(Args, Clone, Debug)]
263pub struct ThreadCleanupArgs {
264 /// Clean up threads whose recorded state is `merged`.
265 #[arg(long)]
266 pub merged: bool,
267
268 /// Drop harness-auto-created threads (those tagged `auto: true`).
269 /// Combine with `--older-than` to gate the sweep on staleness.
270 #[arg(long)]
271 pub auto: bool,
272
273 /// Clean abandoned threads that still have a live ref or checkout
274 /// residue. States and audit history are retained.
275 #[arg(long)]
276 pub abandoned: bool,
277
278 /// Maximum age (since `updated_at`) for an auto-thread to be
279 /// considered live. Threads older than this are eligible for
280 /// sweep when `--auto` is set. Accepts a Go-style duration like
281 /// `7d`, `24h`, `30m`, `15s` (or a raw integer interpreted as
282 /// seconds).
283 #[arg(long, value_name = "DURATION")]
284 pub older_than: Option<String>,
285
286 /// Print what would be dropped without actually dropping it.
287 #[arg(long)]
288 pub dry_run: bool,
289}