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
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
use clap::Args;
use clap::ValueEnum;
use crate::cli::revset::RevsetArgs;
use crate::forge::comment::StackPlacement;
/// Whether new pull requests are created as regular or draft PRs.
///
/// This only affects newly created PRs. Existing PRs keep their
/// current draft/ready state.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Deserialize, clap::ValueEnum)]
#[serde(rename_all = "kebab-case")]
pub enum PrMode {
/// Create pull requests as regular (non-draft) PRs.
#[default]
Regular,
/// Create pull requests as drafts.
Draft,
}
impl std::fmt::Display for PrMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let pv = self
.to_possible_value()
.expect("all variants have possible values");
f.write_str(pv.get_name())
}
}
/// Controls whether existing PR titles and/or bodies are updated from
/// commit descriptions on every submit.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Deserialize, clap::ValueEnum)]
#[serde(rename_all = "kebab-case")]
pub enum SyncPrContent {
/// Do not sync. Title and body are only set on PR creation.
#[default]
None,
/// Sync only the PR title from the first line of the commit description.
Title,
/// Sync only the PR body from the commit description.
Body,
/// Sync both the PR title and body.
All,
}
impl std::fmt::Display for SyncPrContent {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let pv = self
.to_possible_value()
.expect("all variants have possible values");
f.write_str(pv.get_name())
}
}
/// Controls whether git commit trailers are stripped from PR bodies.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Deserialize, clap::ValueEnum)]
#[serde(rename_all = "kebab-case")]
pub enum TrailerHandling {
/// Leave trailers in the PR body verbatim.
#[default]
Keep,
/// Strip the trailer block (Signed-off-by, Co-authored-by, Refs, etc.)
/// from the PR body.
Strip,
}
impl std::fmt::Display for TrailerHandling {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let pv = self
.to_possible_value()
.expect("all variants have possible values");
f.write_str(pv.get_name())
}
}
/// Arguments for the submit subcommand.
#[derive(Debug, Args)]
pub struct SubmitArgs {
/// Print the submission plan and stop.
///
/// No bookmark is created, nothing is pushed, and no pull request is
/// touched — but a configured --bookmark-command still runs during
/// selection.
#[arg(long, verbatim_doc_comment)]
pub dry_run: bool,
/// Keep an existing bookmark as a PR boundary (repeatable).
///
/// Non-interactive selection: the --keep/--new/--new-auto/--new-command
/// marks fully determine the PR set — nothing is implicit. All marks
/// must lie on one trunk-to-tip path; the topmost is the tip of the
/// submission.
///
/// Unmarked commits *below* the topmost mark fold into the PR above
/// them, bookmarked or not. Commits *above* it are not submitted at
/// all — an unbookmarked work-in-progress head is left out unless it
/// is marked.
///
/// `stakk graph [--format=json]` lists stacks, bookmarks and change
/// ids.
#[arg(long, value_name = "BOOKMARK", verbatim_doc_comment)]
pub keep: Vec<String>,
/// Create a new bookmark at REV as a PR boundary (repeatable).
///
/// REV is a change id or commit id prefix (as shown by `stakk graph`).
/// The bookmark is named stakk-<change_id> unless =NAME is given.
#[arg(long, value_name = "REV[=NAME]", verbatim_doc_comment)]
pub new: Vec<String>,
/// Create a new auto-named bookmark at REV (repeatable).
///
/// The name is derived with TF-IDF from the descriptions and files of
/// the commits folded into the boundary, honoring --auto-prefix. Falls
/// back to stakk-<change_id> when nothing can be derived or the
/// derived name is already taken.
#[arg(long, value_name = "REV", verbatim_doc_comment)]
pub new_auto: Vec<String>,
/// Create a new bookmark at REV named by --bookmark-command (repeatable).
///
/// Errors if no bookmark command is configured. The command receives
/// the same JSON segment description as in the TUI.
#[arg(long, value_name = "REV", verbatim_doc_comment)]
pub new_command: Vec<String>,
#[command(flatten)]
pub revset: RevsetArgs,
/// Whether new pull requests are created as regular or draft PRs.
///
/// Existing PRs keep their current draft/ready state.
#[arg(
long,
env = "STAKK_PR_MODE",
default_value = "regular",
value_enum,
verbatim_doc_comment
)]
pub pr_mode: PrMode,
/// Git remote to push to.
#[arg(long, default_value = "origin", env = "STAKK_REMOTE")]
pub remote: String,
/// Path to a custom minijinja template for stack comments.
///
/// Render context:
///
/// stack — list of entries (see below)
/// stack_size — total number of entries
/// default_branch — name of the trunk branch (e.g. "main")
/// current_bookmark — the bookmark being submitted
/// stakk_url — URL to the stakk project
///
/// Each stack entry:
///
/// bookmark_name — bookmark name
/// pr_url — full URL to the pull request
/// pr_number — PR number
/// title — PR title
/// base — base branch name
/// is_draft — whether the PR is a draft
/// position — 1-based position in the stack, 1 nearest the trunk
/// is_current — true for the PR being submitted
///
/// Entries come trunk-first; the built-in template pipes them through
/// `reverse` to draw the stack leaf-first, like `stakk graph`.
///
/// Example template:
///
/// Stack ({{ stack_size }} PRs, merges into `{{ default_branch }}`):
/// {% for entry in stack %}
/// - {{ entry.pr_url }}{% if entry.is_current %} 👈{% endif %}
/// {%- endfor %}
#[expect(
clippy::doc_lazy_continuation,
reason = "endfor must align with the for-loop, not the list item"
)]
#[arg(long, env = "STAKK_TEMPLATE_PATH", verbatim_doc_comment)]
pub template_path: Option<String>,
/// Where to place the stack overview on each pull request.
///
/// body mode appends a fenced section (STAKK_BODY_START /
/// STAKK_BODY_END) to the PR description. Content outside the fences
/// is preserved; the fenced section itself is overwritten on every
/// run, so do not edit it by hand.
///
/// none retires the feature cleanly by removing existing stack
/// comments and body fences on submit. ignore leaves them exactly as
/// they are — use it when something else owns that part of the PR.
///
/// Switching between comment and body migrates automatically: moving to
/// body deletes the old stack comment, moving to comment strips the
/// fenced section from the PR body.
///
/// A submission that produces a single pull request is not a stack:
/// no stack info is written, and stale artifacts are cleaned up
/// unless the mode is ignore.
#[arg(
long,
env = "STAKK_STACK_PLACEMENT",
default_value = "comment",
value_enum,
verbatim_doc_comment
)]
pub stack_placement: StackPlacement,
/// Whether existing PR titles and/or bodies are updated from jj commit
/// descriptions on every submit.
///
/// Syncing overwrites manual edits to the synced fields on GitHub.
///
/// See --trailers for whether commit trailers are kept or stripped.
#[arg(
long,
env = "STAKK_SYNC_PR_CONTENT",
default_value = "none",
value_enum,
verbatim_doc_comment
)]
pub sync_pr_content: SyncPrContent,
/// Whether to keep or strip git commit trailers in PR bodies.
///
/// Trailers are key/value lines at the end of a commit message such as
/// Signed-off-by, Co-authored-by, or Refs.
#[arg(
long,
env = "STAKK_TRAILERS",
default_value = "keep",
value_enum,
verbatim_doc_comment
)]
pub trailers: TrailerHandling,
/// Prefix for auto-generated bookmark names.
///
/// Prepended to names from the [~]auto generator (TF-IDF, term
/// frequency-inverse document frequency): --auto-prefix gb- turns
/// "caching-database" into "gb-caching-database". Does not apply to
/// the default stakk-<change_id> names or to --bookmark-command names.
///
/// The prefix is applied before length/character validation, so it
/// counts toward the 255-byte limit.
#[arg(long, env = "STAKK_AUTO_PREFIX", verbatim_doc_comment)]
pub auto_prefix: Option<String>,
/// Shell command for generating custom bookmark names.
///
/// Invoked via sh -c <command> (Unix) or cmd /C <command> (Windows).
/// It receives a JSON object on stdin describing a single segment of
/// commits and must print exactly one bookmark name to stdout (plain
/// text, leading/trailing whitespace is trimmed).
///
/// The custom name appears as an additional [*] toggle option in the
/// TUI, after the existing bookmarks [x] and generated name [+].
///
/// JSON input schema:
///
/// schema_version -- integer, currently 1; bumped on
/// breaking schema changes
/// rules -- object with validation constraints
/// .max_length -- integer, max name length in bytes (255)
/// .disallowed_chars -- string of forbidden characters
/// commits -- array of commit objects, ordered
/// trunk-to-tip (oldest first); the last
/// element is the tip being bookmarked
///
/// Each commit object:
///
/// commit_id -- full hex commit hash (string)
/// change_id -- full jj change ID (string)
/// short_change_id -- shortest unique change ID prefix (string)
/// description -- full commit message incl. body (string)
/// author -- object with .name, .email and .timestamp
/// (ISO 8601), all strings
/// files -- array of file paths changed by this commit
/// (e.g. ["src/main.rs"])
///
/// Minimal example (two commits):
///
/// {
/// "schema_version": 1,
/// "rules": { "max_length": 255, "disallowed_chars": " ~^:?*[\\" },
/// "commits": [
/// {
/// "commit_id": "aaa111",
/// "change_id": "abc123",
/// "short_change_id": "abc",
/// "description": "add login page",
/// "author": { "name": "Jo", "email": "jo@example.com",
/// "timestamp": "2026-03-01T12:00:00+01:00" },
/// "files": ["src/login.rs"]
/// },
/// {
/// "commit_id": "bbb222",
/// "change_id": "def456",
/// "short_change_id": "def",
/// "description": "style login form",
/// "author": { "name": "Jo", "email": "jo@example.com",
/// "timestamp": "2026-03-01T13:00:00+01:00" },
/// "files": ["src/login.rs", "styles/login.css"]
/// }
/// ]
/// }
///
/// Expected stdout (one line, trimmed): login-page
///
/// Example command (lowercase the tip commit description, replace
/// non-alphanumeric runs with hyphens, trim to 50 chars):
///
/// jq -r '.commits[-1].description' \
/// | tr '[:upper:]' '[:lower:]' \
/// | sed 's/[^a-z0-9]\{1,\}/-/g; s/^-//; s/-$//' \
/// | head -c 50
#[arg(long, env = "STAKK_BOOKMARK_COMMAND", verbatim_doc_comment)]
pub bookmark_command: Option<String>,
}