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
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
use std::ffi::{OsStr, OsString};
use clap::{Args, Parser, Subcommand};
use clap_complete::engine::ArgValueCandidates;
use crate::workspace;
#[derive(Parser, Debug)]
#[command(name = "workon", version, about = "Development workspace launcher with Zellij")]
#[command(args_conflicts_with_subcommands = true)]
pub struct Cli {
/// Multiplexer for the session (zellij, tmux, or zmx)
#[arg(long, global = true, value_parser = crate::mux::parse_mux, value_name = "MUX")]
pub mux: Option<crate::mux::MuxKind>,
#[command(subcommand)]
pub command: Option<Command>,
#[command(flatten)]
pub session: SessionArgs,
}
/// Flags for the default session / ephemeral `-w` flow. Flattened onto the root
/// so `workon`, `workon -w`, and `workon -c foo` keep parsing as before; they
/// conflict with the subcommands via `args_conflicts_with_subcommands`.
#[derive(Args, Debug)]
#[allow(clippy::struct_excessive_bools)]
pub struct SessionArgs {
/// Name the session — and, with -w, the worktree. Defaults to the directory name.
#[arg(long)]
pub name: Option<String>,
/// Ephemeral jj workspace mode
#[arg(short = 'w')]
pub workspace: bool,
/// Force new session (delete existing, recover from hung server)
#[arg(long, conflicts_with = "workspace")]
pub new_session: bool,
/// Skip copying gitignored files into the workspace
#[arg(long, requires = "workspace")]
pub skip_copy_ignored: bool,
/// Resume an agent session by ID (printed when a workspace exits)
#[arg(short = 'r', long, requires = "workspace")]
pub resume: Option<String>,
/// Named config to load from `~/.config/workon/configs/<name>.kdl`
#[arg(short = 'c', long = "config")]
pub config: Option<String>,
/// Reserved. Was a `-n` alias for forcing a new session; now an inert
/// no-op so the old reflex can't silently recreate a session. The
/// deliberate spelling is `--new-session`.
#[arg(short = 'n', hide = true)]
pub reserved_n: bool,
}
/// Headless workspace lifecycle: `create -> (attach <-> quit)* -> destroy`.
/// Quitting an `attach` session detaches; the workspace survives until
/// `destroy`.
#[derive(Subcommand, Debug)]
pub enum Command {
/// Provision a persistent workspace and print its path (no session)
#[command(
after_help = "Exits 3 when a setup step failed: the workspace is kept, not ready, and `workon attach` opens it."
)]
Create {
/// Nickname for the worktree
#[arg(long)]
name: Option<String>,
/// Named config to record for later `attach`
#[arg(short = 'c', long = "config")]
config: Option<String>,
/// Skip copying gitignored files into the workspace
#[arg(long)]
skip_copy_ignored: bool,
/// Branch from this jj revision instead of the detected trunk, e.g. `default@-`
#[arg(long, value_name = "REV")]
base: Option<String>,
/// Print `{ ws_id, path, dbs, failed_steps }` as JSON on stdout
#[arg(long)]
json: bool,
},
/// Open an existing workspace in a session (workspace survives on quit)
Attach {
/// Workspace id, --name nickname, or path; omit for the cwd's workspace
#[arg(add = ArgValueCandidates::new(workspace::ref_candidates))]
reference: Option<String>,
/// Override the recorded config
#[arg(short = 'c', long = "config")]
config: Option<String>,
},
/// Tear down a workspace, saving rescued work unless --no-save
Destroy {
/// Workspace id, --name nickname, or path; omit for the cwd's workspace
#[arg(add = ArgValueCandidates::new(workspace::ref_candidates))]
reference: Option<String>,
/// Discard unsaved work instead of bookmarking it
#[arg(long)]
no_save: bool,
/// Print `{ ws_id, saved, dropped }` as JSON on stdout
#[arg(long)]
json: bool,
},
/// List workspaces at or under the current directory
List {
/// Print an array of workspace objects as JSON on stdout
#[arg(long)]
json: bool,
},
/// Print a workspace's directory, for `cd "$(workon path REF)"` (no session)
Path {
/// Workspace id, --name nickname, or path; omit for the cwd's workspace
#[arg(add = ArgValueCandidates::new(workspace::ref_candidates))]
reference: Option<String>,
},
/// Start the session in the background
Run {
/// Name the session. Defaults to the directory name.
#[arg(long)]
name: Option<String>,
/// Named config to load from `~/.config/workon/configs/<name>.kdl`
#[arg(short = 'c', long = "config")]
config: Option<String>,
/// Run this in the session once it is up
#[arg(long)]
command: Option<String>,
},
/// Type text into the running session
Send {
/// Text to type, followed by Enter
text: String,
/// Name the session. Defaults to the directory name.
#[arg(long)]
name: Option<String>,
},
/// Print recent output from the session
History {
/// Name the session. Defaults to the directory name.
#[arg(long)]
name: Option<String>,
/// How many lines to print
#[arg(long, default_value_t = 200)]
lines: usize,
},
/// Wait until the session output contains a line of text
Wait {
/// Text to wait for
#[arg(long = "for")]
text: String,
/// Name the session. Defaults to the directory name.
#[arg(long)]
name: Option<String>,
/// Give up after this many seconds
#[arg(long, default_value_t = 30)]
timeout: u64,
},
}
/// `--name ""` counts as no name, so the caller falls back to its default
/// (the directory name, or an unlabelled ws_id).
pub fn given_name(name: Option<&str>) -> Option<&str> {
name.filter(|s| !s.is_empty())
}
/// Move a leading `--mux` to the end.
///
/// Session flags conflict with subcommands (`args_conflicts_with_subcommands`),
/// and clap applies that to every root flag, including this global one. Putting
/// `--mux` last lets `workon --mux tmux run` parse as `workon run --mux tmux`.
pub fn normalize_argv<I, T>(args: I) -> Vec<OsString>
where
I: IntoIterator<Item = T>,
T: Into<OsString>,
{
let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
let Some((program, rest)) = args.split_first() else {
return args;
};
let mut out = Vec::with_capacity(args.len());
out.push(program.clone());
let mut mux = Vec::new();
let mut literal = false;
let mut rest = rest.iter();
while let Some(arg) = rest.next() {
if !literal && arg == "--" {
literal = true;
out.push(arg.clone());
continue;
}
let equals = arg.to_string_lossy().starts_with("--mux=");
if !literal && mux.is_empty() && (arg == OsStr::new("--mux") || equals) {
mux.push(arg.clone());
if arg == OsStr::new("--mux")
&& let Some(value) = rest.next()
{
mux.push(value.clone());
}
continue;
}
out.push(arg.clone());
}
out.extend(mux);
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mux_on_a_subcommand_reaches_the_root() {
let cli = Cli::try_parse_from(normalize_argv(["workon", "run", "--mux", "tmux"])).unwrap();
assert_eq!(cli.mux, Some(crate::mux::MuxKind::Tmux));
let cli = Cli::try_parse_from(normalize_argv(["workon", "--mux", "zmx", "history"])).unwrap();
assert_eq!(cli.mux, Some(crate::mux::MuxKind::Zmx));
}
#[test]
fn an_unknown_mux_names_the_three() {
let err = Cli::try_parse_from(["workon", "--mux", "screen"]).unwrap_err();
assert!(err.to_string().contains("unknown multiplexer 'screen'. workon supports zellij, tmux, and zmx."));
}
#[test]
fn empty_name_is_no_name() {
assert_eq!(given_name(Some("fix-bug")), Some("fix-bug"));
assert_eq!(given_name(Some("")), None);
assert_eq!(given_name(None), None);
}
proptest::proptest! {
/// A name survives exactly when it is non-empty, and then comes back unchanged.
#[test]
fn given_name_drops_only_the_empty_name(name in "\\PC{0,12}") {
let expected = if name.is_empty() { None } else { Some(name.as_str()) };
proptest::prop_assert_eq!(given_name(Some(&name)), expected);
}
}
}