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
use clap::{Args, Parser, Subcommand};
use clap_complete::engine::ArgValueCandidates;
use crate::workspace;
#[derive(Parser)]
#[command(name = "workon", version, about = "Development workspace launcher with Zellij")]
#[command(args_conflicts_with_subcommands = true)]
pub struct Cli {
#[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)]
#[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)]
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>,
},
}
/// `--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())
}
#[cfg(test)]
mod tests {
use super::*;
#[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);
}
}
}