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
use clap::Args;
use clap::ValueEnum;
use crate::cli::graph::GraphArgs;
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())
}
}
/// Arguments for the submit subcommand.
#[derive(Debug, Args)]
pub struct SubmitArgs {
/// The bookmark to submit as a pull request. If omitted, shows an
/// interactive selection.
pub bookmark: Option<String>,
/// Show what would be done without actually doing it.
#[arg(long)]
pub dry_run: bool,
#[command(flatten)]
pub graph: GraphArgs,
/// 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. Overridden by --draft.
#[arg(
long = "pr-mode",
env = "STAKK_PR_MODE",
default_value = "regular",
value_enum,
verbatim_doc_comment
)]
pub pr_mode: PrMode,
/// Shortcut for --pr-mode=draft. Overrides --pr-mode if both are given.
#[arg(long, env = "STAKK_DRAFT")]
draft: bool,
/// 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.
///
/// The template is rendered with minijinja and receives the following
/// 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 entry in stack has:
///
/// 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
/// is_current — true for the PR being submitted
///
/// 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", verbatim_doc_comment)]
pub template: Option<String>,
/// Where to place the stack comment on each pull request.
///
/// In body mode the stack is written inside a fenced section
/// (STAKK_BODY_START / STAKK_BODY_END) that is appended to the PR
/// description. Content you write outside the fences is preserved.
/// Do not edit the fenced section by hand — it is overwritten on
/// every run.
///
/// Switching modes migrates automatically: moving to body mode
/// deletes the old stack comment, and moving to comment mode strips
/// the fenced section from the PR body.
#[arg(
long = "stack-placement",
env = "STAKK_STACK_PLACEMENT",
default_value = "comment",
value_enum,
verbatim_doc_comment
)]
pub stack_placement: StackPlacement,
/// Prefix for auto-generated bookmark names.
///
/// When set, the prefix is prepended to names produced by the [~]auto
/// bookmark name generator (TF-IDF, term frequency-inverse document
/// frequency). For example, --auto-prefix gb- turns
/// "caching-database" into "gb-caching-database".
///
/// Only applies to auto-generated names -- not to the default
/// stakk-<change_id> names or names from
/// --bookmark-command.
///
/// 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.
///
/// The command is 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, timestamp
/// .name -- author name (string)
/// .email -- author email (string)
/// .timestamp -- commit timestamp (string, ISO 8601)
/// files -- array of file paths changed by this commit
/// (array of strings, 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 (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>,
}
impl SubmitArgs {
/// Effective PR mode. `--draft` forces `PrMode::Draft`.
pub fn pr_mode(&self) -> PrMode {
if self.draft {
PrMode::Draft
} else {
self.pr_mode
}
}
}