I'm here all day until we have auto-PRs set up for tangled
ERROR
packetmix/npins/sources.json
ERROR
packetmix/npins/sources.json
Failed to calculate interdiff for this file.
NEW
packetmix/homes/development/jujutsu.nix
NEW
packetmix/homes/development/jujutsu.nix
NEW
packetmix/packages/jujutsu/7245-jj-gerrit-upload.patch
NEW
packetmix/packages/jujutsu/7245-jj-gerrit-upload.patch
···
1
-
From a85628df2f2bc1b46374de546052f624b09f172b Mon Sep 17 00:00:00 2001
2
-
From: Austin Seipp <aseipp@pobox.com>
3
-
Date: Thu, 18 Jan 2024 00:35:09 -0600
4
-
Subject: [PATCH] cli: basic `jj gerrit upload` implementation
5
-
6
-
This implements the most basic workflow for submitting changes to Gerrit,
7
-
through a verb called 'upload'. This verb is intended to be distinct from the word
8
-
'submit', which for Gerrit means 'merge a change into the repository.'
9
-
10
-
Given a list of revsets (specified by multiple `-r` options), this will parse
11
-
the footers of every commit, collect them, insert a `Change-Id` (if one doesn't
12
-
already exist), and then push them into the given remote.
13
-
14
-
Because the argument is a revset, you may submit entire trees of changes at
15
-
once, including multiple trees of independent changes, e.g.
16
-
17
-
jj gerrit upload -r foo:: -r baz::
18
-
19
-
There are many other improvements that can be applied on top of this, including
20
-
a ton of consistency and "does this make sense?" checks. However, it is flexible
21
-
and a good starting point, and you can in fact both submit and cycle reviews
22
-
with this interface.
23
-
24
-
Signed-off-by: Austin Seipp <aseipp@pobox.com>
25
-
---
26
-
CHANGELOG.md | 2 +
27
-
cli/src/commands/gerrit/mod.rs | 57 +++++
28
-
cli/src/commands/gerrit/upload.rs | 384 +++++++++++++++++++++++++++++++
29
-
cli/src/commands/mod.rs | 7 +
30
-
cli/src/config-schema.json | 14 ++
31
-
cli/tests/cli-reference@.md.snap | 38 +++
32
-
cli/tests/runner.rs | 1 +
33
-
cli/tests/test_gerrit_upload.rs | 89 +++++++
34
-
8 files changed, 592 insertions(+)
35
-
create mode 100644 cli/src/commands/gerrit/mod.rs
36
-
create mode 100644 cli/src/commands/gerrit/upload.rs
37
-
create mode 100644 cli/tests/test_gerrit_upload.rs
38
-
39
-
diff --git a/CHANGELOG.md b/CHANGELOG.md
40
-
index 267b5ed303..9bc1029fcf 100644
41
-
--- a/CHANGELOG.md
42
-
+++ b/CHANGELOG.md
43
-
@@ -107,6 +107,8 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
44
-
* The new command `jj redo` can progressively redo operations that were
45
-
previously undone by multiple calls to `jj undo`.
46
-
47
-
+* Gerrit support implemented with the new command `jj gerrit upload`
48
-
+
49
-
### Fixed bugs
50
-
51
-
* `jj git clone` now correctly fetches all tags, unless `--fetch-tags` is
52
-
diff --git a/cli/src/commands/gerrit/mod.rs b/cli/src/commands/gerrit/mod.rs
53
-
new file mode 100644
54
-
index 0000000000..60abdb6702
55
-
--- /dev/null
56
-
+++ b/cli/src/commands/gerrit/mod.rs
57
-
@@ -0,0 +1,57 @@
58
-
+// Copyright 2024 The Jujutsu Authors
59
-
+//
60
-
+// Licensed under the Apache License, Version 2.0 (the "License");
61
-
+// you may not use this file except in compliance with the License.
62
-
+// You may obtain a copy of the License at
63
-
+//
64
-
+// https://www.apache.org/licenses/LICENSE-2.0
65
-
+//
66
-
+// Unless required by applicable law or agreed to in writing, software
67
-
+// distributed under the License is distributed on an "AS IS" BASIS,
68
-
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
69
-
+// See the License for the specific language governing permissions and
70
-
+// limitations under the License.
71
-
+
72
-
+use std::fmt::Debug;
73
-
+
74
-
+use clap::Subcommand;
75
-
+
76
-
+use crate::cli_util::CommandHelper;
77
-
+use crate::command_error::CommandError;
78
-
+use crate::commands::gerrit;
79
-
+use crate::ui::Ui;
80
-
+
81
-
+/// Interact with Gerrit Code Review.
82
-
+#[derive(Subcommand, Clone, Debug)]
83
-
+pub enum GerritCommand {
84
-
+ /// Upload changes to Gerrit for code review, or update existing changes.
85
-
+ ///
86
-
+ /// Uploading in a set of revisions to Gerrit creates a single "change" for
87
-
+ /// each revision included in the revset. This change is then available for
88
-
+ /// review on your Gerrit instance.
89
-
+ ///
90
-
+ /// This command modifies each commit in the revset to include a `Change-Id`
91
-
+ /// footer in its commit message if one does not already exist. Note that
92
-
+ /// this ID is NOT compatible with jj IDs, and is Gerrit-specific.
93
-
+ ///
94
-
+ /// If a change already exists for a given revision (i.e. it contains the
95
-
+ /// same `Change-Id`), this command will update the contents of the existing
96
-
+ /// change to match.
97
-
+ ///
98
-
+ /// Note: this command takes 1-or-more revsets arguments, each of which can
99
-
+ /// resolve to multiple revisions; so you may post trees or ranges of
100
-
+ /// commits to Gerrit for review all at once.
101
-
+ Upload(gerrit::upload::UploadArgs),
102
-
+}
103
-
+
104
-
+pub fn cmd_gerrit(
105
-
+ ui: &mut Ui,
106
-
+ command: &CommandHelper,
107
-
+ subcommand: &GerritCommand,
108
-
+) -> Result<(), CommandError> {
109
-
+ match subcommand {
110
-
+ GerritCommand::Upload(review) => gerrit::upload::cmd_upload(ui, command, review),
111
-
+ }
112
-
+}
113
-
+
114
-
+mod upload;
115
-
diff --git a/cli/src/commands/gerrit/upload.rs b/cli/src/commands/gerrit/upload.rs
116
-
new file mode 100644
117
-
index 0000000000..88c3ca5e97
118
-
--- /dev/null
119
-
+++ b/cli/src/commands/gerrit/upload.rs
120
-
@@ -0,0 +1,384 @@
121
-
+// Copyright 2024 The Jujutsu Authors
122
-
+//
123
-
+// Licensed under the Apache License, Version 2.0 (the "License");
124
-
+// you may not use this file except in compliance with the License.
125
-
+// You may obtain a copy of the License at
126
-
+//
127
-
+// https://www.apache.org/licenses/LICENSE-2.0
128
-
+//
129
-
+// Unless required by applicable law or agreed to in writing, software
130
-
+// distributed under the License is distributed on an "AS IS" BASIS,
131
-
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
132
-
+// See the License for the specific language governing permissions and
133
-
+// limitations under the License.
134
-
+
135
-
+use std::fmt::Debug;
136
-
+use std::io::Write as _;
137
-
+use std::rc::Rc;
138
-
+use std::sync::Arc;
139
-
+
140
-
+use bstr::BStr;
141
-
+use indexmap::IndexMap;
142
-
+use itertools::Itertools as _;
143
-
+use jj_lib::backend::CommitId;
144
-
+use jj_lib::commit::Commit;
145
-
+use jj_lib::commit::CommitIteratorExt as _;
146
-
+use jj_lib::git::GitRefUpdate;
147
-
+use jj_lib::git::{self};
148
-
+use jj_lib::object_id::ObjectId as _;
149
-
+use jj_lib::repo::Repo as _;
150
-
+use jj_lib::revset::RevsetExpression;
151
-
+use jj_lib::settings::UserSettings;
152
-
+use jj_lib::store::Store;
153
-
+use jj_lib::trailer::Trailer;
154
-
+use jj_lib::trailer::parse_description_trailers;
155
-
+
156
-
+use crate::cli_util::CommandHelper;
157
-
+use crate::cli_util::RevisionArg;
158
-
+use crate::cli_util::short_commit_hash;
159
-
+use crate::command_error::CommandError;
160
-
+use crate::command_error::internal_error;
161
-
+use crate::command_error::user_error;
162
-
+use crate::command_error::user_error_with_hint;
163
-
+use crate::command_error::user_error_with_message;
164
-
+use crate::git_util::with_remote_git_callbacks;
165
-
+use crate::ui::Ui;
166
-
+
167
-
+#[derive(clap::Args, Clone, Debug)]
168
-
+pub struct UploadArgs {
169
-
+ /// The revset, selecting which commits are sent in to Gerrit. This can be
170
-
+ /// any arbitrary set of commits; they will be modified to include a
171
-
+ /// `Change-Id` footer if one does not already exist, and then sent off to
172
-
+ /// Gerrit for review.
173
-
+ #[arg(long, short = 'r')]
174
-
+ revisions: Vec<RevisionArg>,
175
-
+
176
-
+ /// The location where your changes are intended to land. This should be
177
-
+ /// an upstream branch.
178
-
+ #[arg(long = "remote-branch", short = 'b')]
179
-
+ remote_branch: Option<String>,
180
-
+
181
-
+ /// The Gerrit remote to push to. Can be configured with the `gerrit.remote`
182
-
+ /// repository option as well. This is typically a full SSH URL for your
183
-
+ /// Gerrit instance.
184
-
+ #[arg(long)]
185
-
+ remote: Option<String>,
186
-
+
187
-
+ /// If true, do not actually add `Change-Id`s to commits, and do not push
188
-
+ /// the changes to Gerrit.
189
-
+ #[arg(long = "dry-run", short = 'n')]
190
-
+ dry_run: bool,
191
-
+}
192
-
+
193
-
+/// calculate push remote. The logic is:
194
-
+/// 1. If the user specifies `--remote`, use that
195
-
+/// 2. If the user has 'gerrit.remote' configured, use that
196
-
+/// 3. If there is a default push remote, use that
197
-
+/// 4. If the user has a remote named 'gerrit', use that
198
-
+/// 5. otherwise, bail out
199
-
+fn calculate_push_remote(
200
-
+ store: &Arc<Store>,
201
-
+ config: &UserSettings,
202
-
+ remote: Option<String>,
203
-
+) -> Result<String, CommandError> {
204
-
+ let git_repo = git::get_git_repo(store)?; // will fail if not a git repo
205
-
+ let remotes = git_repo.remote_names();
206
-
+
207
-
+ // case 1
208
-
+ if let Some(remote) = remote {
209
-
+ if remotes.contains(BStr::new(&remote)) {
210
-
+ return Ok(remote);
211
-
+ }
212
-
+ return Err(user_error(format!(
213
-
+ "The remote '{remote}' (specified via `--remote`) does not exist",
214
-
+ )));
215
-
+ }
216
-
+
217
-
+ // case 2
218
-
+ if let Ok(remote) = config.get_string("gerrit.default-remote") {
219
-
+ if remotes.contains(BStr::new(&remote)) {
220
-
+ return Ok(remote);
221
-
+ }
222
-
+ return Err(user_error(format!(
223
-
+ "The remote '{remote}' (configured via `gerrit.default-remote`) does not exist",
224
-
+ )));
225
-
+ }
226
-
+
227
-
+ // case 3
228
-
+ if let Some(remote) = git_repo.remote_default_name(gix::remote::Direction::Push) {
229
-
+ return Ok(remote.to_string());
230
-
+ }
231
-
+
232
-
+ // case 4
233
-
+ if remotes.iter().any(|r| **r == "gerrit") {
234
-
+ return Ok("gerrit".to_owned());
235
-
+ }
236
-
+
237
-
+ // case 5
238
-
+ Err(user_error(
239
-
+ "No remote specified, and no 'gerrit' remote was found",
240
-
+ ))
241
-
+}
242
-
+
243
-
+/// Determine what Gerrit ref and remote to use. The logic is:
244
-
+///
245
-
+/// 1. If the user specifies `--remote-branch branch`, use that
246
-
+/// 2. If the user has 'gerrit.default-remote-branch' configured, use that
247
-
+/// 3. Otherwise, bail out
248
-
+fn calculate_push_ref(
249
-
+ config: &UserSettings,
250
-
+ remote_branch: Option<String>,
251
-
+) -> Result<String, CommandError> {
252
-
+ // case 1
253
-
+ if let Some(remote_branch) = remote_branch {
254
-
+ return Ok(remote_branch);
255
-
+ }
256
-
+
257
-
+ // case 2
258
-
+ if let Ok(branch) = config.get_string("gerrit.default-remote-branch") {
259
-
+ return Ok(branch);
260
-
+ }
261
-
+
262
-
+ // case 3
263
-
+ Err(user_error(
264
-
+ "No target branch specified via --remote-branch, and no 'gerrit.default-remote-branch' \
265
-
+ was found",
266
-
+ ))
267
-
+}
268
-
+
269
-
+pub fn cmd_upload(ui: &mut Ui, command: &CommandHelper, upload: &UploadArgs) -> Result<(), CommandError> {
270
-
+ let mut workspace_command = command.workspace_helper(ui)?;
271
-
+
272
-
+ let revisions: Vec<_> = workspace_command
273
-
+ .parse_union_revsets(ui, &upload.revisions)?
274
-
+ .evaluate_to_commits()?
275
-
+ .try_collect()?;
276
-
+ if revisions.is_empty() {
277
-
+ writeln!(ui.status(), "No revisions to upload.")?;
278
-
+ return Ok(());
279
-
+ }
280
-
+
281
-
+ if revisions
282
-
+ .iter()
283
-
+ .any(|commit| commit.id() == workspace_command.repo().store().root_commit_id())
284
-
+ {
285
-
+ return Err(user_error("Cannot upload the virtual 'root()' commit"));
286
-
+ }
287
-
+
288
-
+ workspace_command.check_rewritable(revisions.iter().ids())?;
289
-
+
290
-
+ // If you have the changes main -> A -> B, and then run `jj gerrit upload B`,
291
-
+ // then that uploads both A and B. Thus, we need to ensure that A also
292
-
+ // has a Change-ID.
293
-
+ // We make an assumption here that all immutable commits already have a
294
-
+ // Change-ID.
295
-
+ let to_upload: Vec<Commit> = workspace_command
296
-
+ .attach_revset_evaluator(
297
-
+ // I'm unsure, but this *might* have significant performance
298
-
+ // implications. If so, we can change it to a maximum depth.
299
-
+ Rc::new(RevsetExpression::Difference(
300
-
+ // Unfortunately, DagRange{root: immutable_heads, heads: commits}
301
-
+ // doesn't work if you're, for example, working on top of an
302
-
+ // immutable commit that isn't in immutable_heads().
303
-
+ Rc::new(RevsetExpression::Ancestors {
304
-
+ heads: RevsetExpression::commits(
305
-
+ revisions.iter().ids().cloned().collect::<Vec<_>>(),
306
-
+ ),
307
-
+ generation: jj_lib::revset::GENERATION_RANGE_FULL,
308
-
+ parents_range: jj_lib::revset::PARENTS_RANGE_FULL,
309
-
+ }),
310
-
+ workspace_command.env().immutable_expression().clone(),
311
-
+ )),
312
-
+ )
313
-
+ .evaluate_to_commits()?
314
-
+ .try_collect()?;
315
-
+
316
-
+ let mut tx = workspace_command.start_transaction();
317
-
+ let base_repo = tx.base_repo().clone();
318
-
+ let store = base_repo.store();
319
-
+
320
-
+ let old_heads = base_repo
321
-
+ .index()
322
-
+ .heads(&mut revisions.iter().ids())
323
-
+ .map_err(internal_error)?;
324
-
+
325
-
+ let git_settings = command.settings().git_settings()?;
326
-
+ let remote = calculate_push_remote(store, command.settings(), upload.remote.clone())?;
327
-
+ let remote_branch = calculate_push_ref(command.settings(), upload.remote_branch.clone())?;
328
-
+
329
-
+ // immediately error and reject any discardable commits, i.e. the
330
-
+ // the empty wcc
331
-
+ for commit in &to_upload {
332
-
+ if commit.is_discardable(tx.repo_mut())? {
333
-
+ return Err(user_error_with_hint(
334
-
+ format!(
335
-
+ "Refusing to upload commit {} because it is an empty commit with no description",
336
-
+ short_commit_hash(commit.id())
337
-
+ ),
338
-
+ "Perhaps you squashed then ran upload? Maybe you meant to upload the parent commit \
339
-
+ instead (eg. @-)",
340
-
+ ));
341
-
+ }
342
-
+ }
343
-
+
344
-
+ let mut old_to_new: IndexMap<CommitId, Commit> = IndexMap::new();
345
-
+ for commit_id in to_upload.iter().map(|c| c.id()).rev() {
346
-
+ let original_commit = store.get_commit(commit_id).unwrap();
347
-
+ let description = original_commit.description().to_owned();
348
-
+ let trailers = parse_description_trailers(&description);
349
-
+
350
-
+ let change_id_trailers: Vec<&Trailer> = trailers
351
-
+ .iter()
352
-
+ .filter(|trailer| trailer.key == "Change-Id")
353
-
+ .collect();
354
-
+
355
-
+ // There shouldn't be multiple change-ID fields. So just error out if
356
-
+ // there is.
357
-
+ if change_id_trailers.len() > 1 {
358
-
+ return Err(user_error(format!(
359
-
+ "multiple Change-Id footers in commit {}",
360
-
+ short_commit_hash(commit_id)
361
-
+ )));
362
-
+ }
363
-
+
364
-
+ // The user can choose to explicitly set their own change-ID to
365
-
+ // override the default change-ID based on the jj change-ID.
366
-
+ if let Some(trailer) = change_id_trailers.first() {
367
-
+ // Check the change-id format is correct.
368
-
+ if trailer.value.len() != 41 || !trailer.value.starts_with('I') {
369
-
+ // Intentionally leave the invalid change IDs as-is.
370
-
+ writeln!(
371
-
+ ui.warning_default(),
372
-
+ "warning: invalid Change-Id footer in commit {}",
373
-
+ short_commit_hash(original_commit.id()),
374
-
+ )?;
375
-
+ }
376
-
+
377
-
+ // map the old commit to itself
378
-
+ old_to_new.insert(original_commit.id().clone(), original_commit.clone());
379
-
+ continue;
380
-
+ }
381
-
+
382
-
+ // Gerrit change id is 40 chars, jj change id is 32, so we need padding.
383
-
+ // To be consistent with `format_gerrit_change_id_trailer``, we pad with
384
-
+ // 6a6a6964 (hex of "jjid").
385
-
+ let gerrit_change_id = format!("I6a6a6964{}", original_commit.change_id().hex());
386
-
+
387
-
+ let new_description = format!(
388
-
+ "{}{}Change-Id: {}\n",
389
-
+ description.trim(),
390
-
+ if trailers.is_empty() { "\n\n" } else { "\n" },
391
-
+ gerrit_change_id
392
-
+ );
393
-
+
394
-
+ let new_parents = original_commit
395
-
+ .parents()
396
-
+ .map(|parent| {
397
-
+ let p = parent.unwrap();
398
-
+ if let Some(rewritten_parent) = old_to_new.get(p.id()) {
399
-
+ rewritten_parent
400
-
+ } else {
401
-
+ &p
402
-
+ }
403
-
+ .id()
404
-
+ .clone()
405
-
+ })
406
-
+ .collect();
407
-
+
408
-
+ // rewrite the set of parents to point to the commits that were
409
-
+ // previously rewritten in toposort order
410
-
+ //
411
-
+ // TODO FIXME (aseipp): this whole dance with toposorting, calculating
412
-
+ // new_parents, and then doing rewrite_commit is roughly equivalent to
413
-
+ // what we do in duplicate.rs as well. we should probably refactor this?
414
-
+ let new_commit = tx
415
-
+ .repo_mut()
416
-
+ .rewrite_commit(&original_commit)
417
-
+ .set_description(new_description)
418
-
+ .set_parents(new_parents)
419
-
+ // Set the timestamp back to the timestamp of the original commit.
420
-
+ // Otherwise, `jj gerrit upload @ && jj gerrit upload @` will upload
421
-
+ // two patchsets with the only difference being the timestamp.
422
-
+ .set_committer(original_commit.committer().clone())
423
-
+ .set_author(original_commit.author().clone())
424
-
+ .write()?;
425
-
+
426
-
+ old_to_new.insert(original_commit.id().clone(), new_commit.clone());
427
-
+ }
428
-
+ writeln!(ui.stderr())?;
429
-
+
430
-
+ let remote_ref = format!("refs/for/{remote_branch}");
431
-
+ writeln!(
432
-
+ ui.stderr(),
433
-
+ "Found {} heads to push to Gerrit (remote '{}'), target branch '{}'",
434
-
+ old_heads.len(),
435
-
+ remote,
436
-
+ remote_branch,
437
-
+ )?;
438
-
+
439
-
+ writeln!(ui.stderr())?;
440
-
+
441
-
+ // NOTE (aseipp): because we are pushing everything to the same remote ref,
442
-
+ // we have to loop and push each commit one at a time, even though
443
-
+ // push_updates in theory supports multiple GitRefUpdates at once, because
444
-
+ // we obviously can't push multiple heads to the same ref.
445
-
+ for head in &old_heads {
446
-
+ write!(
447
-
+ ui.stderr(),
448
-
+ "{}",
449
-
+ if upload.dry_run {
450
-
+ "Dry-run: Would push "
451
-
+ } else {
452
-
+ "Pushing "
453
-
+ }
454
-
+ )?;
455
-
+ // We have to write the old commit here, because the until we finish
456
-
+ // the transaction (which we don't), the new commit is labelled as
457
-
+ // "hidden".
458
-
+ tx.base_workspace_helper().write_commit_summary(
459
-
+ ui.stderr_formatter().as_mut(),
460
-
+ &store.get_commit(head).unwrap(),
461
-
+ )?;
462
-
+ writeln!(ui.stderr())?;
463
-
+
464
-
+ if upload.dry_run {
465
-
+ continue;
466
-
+ }
467
-
+
468
-
+ let new_commit = store
469
-
+ .get_commit(old_to_new.get(head).unwrap().id())
470
-
+ .unwrap();
471
-
+
472
-
+ // how do we get better errors from the remote? 'git push' tells us
473
-
+ // about rejected refs AND ALSO '(nothing changed)' when there are no
474
-
+ // changes to push, but we don't get that here.
475
-
+ with_remote_git_callbacks(ui, |cb| {
476
-
+ git::push_updates(
477
-
+ tx.repo_mut(),
478
-
+ &git_settings,
479
-
+ remote.as_ref(),
480
-
+ &[GitRefUpdate {
481
-
+ qualified_name: remote_ref.clone().into(),
482
-
+ expected_current_target: None,
483
-
+ new_target: Some(new_commit.id().clone()),
484
-
+ }],
485
-
+ cb,
486
-
+ )
487
-
+ })
488
-
+ // Despite the fact that a manual git push will error out with 'no new
489
-
+ // changes' if you're up to date, this git backend appears to silently
490
-
+ // succeed - no idea why.
491
-
+ // It'd be nice if we could distinguish this. We should ideally succeed,
492
-
+ // but give the user a warning.
493
-
+ .map_err(|err| match err {
494
-
+ git::GitPushError::NoSuchRemote(_)
495
-
+ | git::GitPushError::RemoteName(_)
496
-
+ | git::GitPushError::UnexpectedBackend(_) => user_error(err),
497
-
+ git::GitPushError::Subprocess(_) => {
498
-
+ user_error_with_message("Internal git error while pushing to gerrit", err)
499
-
+ }
500
-
+ })?;
501
-
+ }
502
-
+
503
-
+ Ok(())
504
-
+}
505
-
diff --git a/cli/src/commands/mod.rs b/cli/src/commands/mod.rs
506
-
index cdf3c9c3ad..cb7b4ca185 100644
507
-
--- a/cli/src/commands/mod.rs
508
-
+++ b/cli/src/commands/mod.rs
509
-
@@ -30,6 +30,8 @@ mod evolog;
510
-
mod file;
511
-
mod fix;
512
-
#[cfg(feature = "git")]
513
-
+mod gerrit;
514
-
+#[cfg(feature = "git")]
515
-
mod git;
516
-
mod help;
517
-
mod interdiff;
518
-
@@ -115,6 +117,9 @@ enum Command {
519
-
Fix(fix::FixArgs),
520
-
#[cfg(feature = "git")]
521
-
#[command(subcommand)]
522
-
+ Gerrit(gerrit::GerritCommand),
523
-
+ #[cfg(feature = "git")]
524
-
+ #[command(subcommand)]
525
-
Git(git::GitCommand),
526
-
Help(help::HelpArgs),
527
-
Interdiff(interdiff::InterdiffArgs),
528
-
@@ -180,6 +185,8 @@ pub fn run_command(ui: &mut Ui, command_helper: &CommandHelper) -> Result<(), Co
529
-
Command::File(args) => file::cmd_file(ui, command_helper, args),
530
-
Command::Fix(args) => fix::cmd_fix(ui, command_helper, args),
531
-
#[cfg(feature = "git")]
532
-
+ Command::Gerrit(sub_args) => gerrit::cmd_gerrit(ui, command_helper, sub_args),
533
-
+ #[cfg(feature = "git")]
534
-
Command::Git(args) => git::cmd_git(ui, command_helper, args),
535
-
Command::Help(args) => help::cmd_help(ui, command_helper, args),
536
-
Command::Interdiff(args) => interdiff::cmd_interdiff(ui, command_helper, args),
537
-
diff --git a/cli/src/config-schema.json b/cli/src/config-schema.json
538
-
index 887c34e2ba..d15b334ecf 100644
539
-
--- a/cli/src/config-schema.json
540
-
+++ b/cli/src/config-schema.json
541
-
@@ -490,6 +490,20 @@
542
-
}
543
-
}
544
-
},
545
-
+ "gerrit": {
546
-
+ "type": "object",
547
-
+ "description": "Settings for interacting with Gerrit",
548
-
+ "properties": {
549
-
+ "default-remote": {
550
-
+ "type": "string",
551
-
+ "description": "The Gerrit remote to interact with"
552
-
+ },
553
-
+ "default-remote-branch": {
554
-
+ "type": "string",
555
-
+ "description": "The default branch to propose changes for"
556
-
+ }
557
-
+ }
558
-
+ },
559
-
"merge-tools": {
560
-
"type": "object",
561
-
"description": "Tables of custom options to pass to the given merge tool (selected in ui.merge-editor)",
562
-
diff --git a/cli/tests/cli-reference@.md.snap b/cli/tests/cli-reference@.md.snap
563
-
index a97a0ffc55..dff8bcbe37 100644
564
-
--- a/cli/tests/cli-reference@.md.snap
565
-
+++ b/cli/tests/cli-reference@.md.snap
566
-
@@ -45,6 +45,8 @@ This document contains the help content for the `jj` command-line program.
567
-
* [`jj file track`↴](#jj-file-track)
568
-
* [`jj file untrack`↴](#jj-file-untrack)
569
-
* [`jj fix`↴](#jj-fix)
570
-
+* [`jj gerrit`↴](#jj-gerrit)
571
-
+* [`jj gerrit upload`↴](#jj-gerrit-upload)
572
-
* [`jj git`↴](#jj-git)
573
-
* [`jj git clone`↴](#jj-git-clone)
574
-
* [`jj git export`↴](#jj-git-export)
575
-
@@ -139,6 +141,7 @@ To get started, see the tutorial [`jj help -k tutorial`].
576
-
* `evolog` — Show how a change has evolved over time
577
-
* `file` — File operations
578
-
* `fix` — Update files with formatting fixes or other changes
579
-
+* `gerrit` — Interact with Gerrit Code Review
580
-
* `git` — Commands for working with Git remotes and the underlying Git repo
581
-
* `help` — Print this message or the help of the given subcommand(s)
582
-
* `interdiff` — Compare the changes of two commits
583
-
@@ -1177,6 +1180,41 @@ output of the first tool.
584
-
585
-
586
-
587
-
+## `jj gerrit`
588
-
+
589
-
+Interact with Gerrit Code Review
590
-
+
591
-
+**Usage:** `jj gerrit <COMMAND>`
592
-
+
593
-
+###### **Subcommands:**
594
-
+
595
-
+* `upload` — Upload changes to Gerrit for code review, or update existing changes
596
-
+
597
-
+
598
-
+
599
-
+## `jj gerrit upload`
600
-
+
601
-
+Upload changes to Gerrit for code review, or update existing changes.
602
-
+
603
-
+Uploading in a set of revisions to Gerrit creates a single "change" for each revision included in the revset. This change is then available for review on your Gerrit instance.
604
-
+
605
-
+This command modifies each commit in the revset to include a `Change-Id` footer in its commit message if one does not already exist. Note that this ID is NOT compatible with jj IDs, and is Gerrit-specific.
606
-
+
607
-
+If a change already exists for a given revision (i.e. it contains the same `Change-Id`), this command will update the contents of the existing change to match.
608
-
+
609
-
+Note: this command takes 1-or-more revsets arguments, each of which can resolve to multiple revisions; so you may post trees or ranges of commits to Gerrit for review all at once.
610
-
+
611
-
+**Usage:** `jj gerrit upload [OPTIONS]`
612
-
+
613
-
+###### **Options:**
614
-
+
615
-
+* `-r`, `--revisions <REVISIONS>` — The revset, selecting which commits are sent in to Gerrit. This can be any arbitrary set of commits; they will be modified to include a `Change-Id` footer if one does not already exist, and then sent off to Gerrit for review
616
-
+* `-b`, `--remote-branch <REMOTE_BRANCH>` — The location where your changes are intended to land. This should be an upstream branch
617
-
+* `--remote <REMOTE>` — The Gerrit remote to push to. Can be configured with the `gerrit.remote` repository option as well. This is typically a full SSH URL for your Gerrit instance
618
-
+* `-n`, `--dry-run` — If true, do not actually add `Change-Id`s to commits, and do not push the changes to Gerrit
619
-
+
620
-
+
621
-
+
622
-
## `jj git`
623
-
624
-
Commands for working with Git remotes and the underlying Git repo
625
-
diff --git a/cli/tests/runner.rs b/cli/tests/runner.rs
626
-
index 88c1ca2319..f228da5e70 100644
627
-
--- a/cli/tests/runner.rs
628
-
+++ b/cli/tests/runner.rs
629
-
@@ -37,6 +37,7 @@ mod test_file_show_command;
630
-
mod test_file_track_untrack_commands;
631
-
mod test_fix_command;
632
-
mod test_generate_md_cli_help;
633
-
+mod test_gerrit_upload;
634
-
mod test_git_clone;
635
-
mod test_git_colocated;
636
-
mod test_git_fetch;
637
-
diff --git a/cli/tests/test_gerrit_upload.rs b/cli/tests/test_gerrit_upload.rs
638
-
new file mode 100644
639
-
index 0000000000..71543cedd8
640
-
--- /dev/null
641
-
+++ b/cli/tests/test_gerrit_upload.rs
642
-
@@ -0,0 +1,89 @@
643
-
+// Copyright 2025 The Jujutsu Authors
644
-
+//
645
-
+// Licensed under the Apache License, Version 2.0 (the "License");
646
-
+// you may not use this file except in compliance with the License.
647
-
+// You may obtain a copy of the License at
648
-
+//
649
-
+// https://www.apache.org/licenses/LICENSE-2.0
650
-
+//
651
-
+// Unless required by applicable law or agreed to in writing, software
652
-
+// distributed under the License is distributed on an "AS IS" BASIS,
653
-
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
654
-
+// See the License for the specific language governing permissions and
655
-
+// limitations under the License.
656
-
+
657
-
+use crate::common::TestEnvironment;
658
-
+use crate::common::create_commit;
659
-
+
660
-
+#[test]
661
-
+fn test_gerrit_upload_dryrun() {
662
-
+ let test_env = TestEnvironment::default();
663
-
+ test_env.run_jj_in(".", ["git", "init", "repo"]).success();
664
-
+ let work_dir = test_env.work_dir("repo");
665
-
+
666
-
+ create_commit(&work_dir, "a", &[]);
667
-
+ create_commit(&work_dir, "b", &["a"]);
668
-
+ create_commit(&work_dir, "c", &["a"]);
669
-
+ let output = work_dir.run_jj(["gerrit", "upload", "-r", "b"]);
670
-
+ insta::assert_snapshot!(output, @r###"
671
-
+ ------- stderr -------
672
-
+ Error: No remote specified, and no 'gerrit' remote was found
673
-
+ [EOF]
674
-
+ [exit status: 1]
675
-
+ "###);
676
-
+
677
-
+ // With remote specified but.
678
-
+ test_env.add_config(r#"gerrit.default-remote="origin""#);
679
-
+ let output = work_dir.run_jj(["gerrit", "upload", "-r", "b"]);
680
-
+ insta::assert_snapshot!(output, @r###"
681
-
+ ------- stderr -------
682
-
+ Error: The remote 'origin' (configured via `gerrit.default-remote`) does not exist
683
-
+ [EOF]
684
-
+ [exit status: 1]
685
-
+ "###);
686
-
+
687
-
+ let output = work_dir.run_jj(["gerrit", "upload", "-r", "b", "--remote=origin"]);
688
-
+ insta::assert_snapshot!(output, @r###"
689
-
+ ------- stderr -------
690
-
+ Error: The remote 'origin' (specified via `--remote`) does not exist
691
-
+ [EOF]
692
-
+ [exit status: 1]
693
-
+ "###);
694
-
+
695
-
+ let output = work_dir.run_jj([
696
-
+ "git",
697
-
+ "remote",
698
-
+ "add",
699
-
+ "origin",
700
-
+ "http://example.com/repo/foo",
701
-
+ ]);
702
-
+ insta::assert_snapshot!(output, @"");
703
-
+ let output = work_dir.run_jj(["gerrit", "upload", "-r", "b", "--remote=origin"]);
704
-
+ insta::assert_snapshot!(output, @r###"
705
-
+ ------- stderr -------
706
-
+ Error: No target branch specified via --remote-branch, and no 'gerrit.default-remote-branch' was found
707
-
+ [EOF]
708
-
+ [exit status: 1]
709
-
+ "###);
710
-
+
711
-
+ test_env.add_config(r#"gerrit.default-remote-branch="main""#);
712
-
+ let output = work_dir.run_jj(["gerrit", "upload", "-r", "b", "--dry-run"]);
713
-
+ insta::assert_snapshot!(output, @r###"
714
-
+ ------- stderr -------
715
-
+
716
-
+ Found 1 heads to push to Gerrit (remote 'origin'), target branch 'main'
717
-
+
718
-
+ Dry-run: Would push zsuskuln 123b4d91 b | b
719
-
+ [EOF]
720
-
+ "###);
721
-
+
722
-
+ let output = work_dir.run_jj(["gerrit", "upload", "-r", "b", "--dry-run", "-b", "other"]);
723
-
+ insta::assert_snapshot!(output, @r###"
724
-
+ ------- stderr -------
725
-
+
726
-
+ Found 1 heads to push to Gerrit (remote 'origin'), target branch 'other'
727
-
+
728
-
+ Dry-run: Would push zsuskuln 123b4d91 b | b
729
-
+ [EOF]
730
-
+ "###);
731
-
+}
732
-
···
NEW
packetmix/packages/jujutsu/7245-jj-gerrit-upload.patch.license
NEW
packetmix/packages/jujutsu/7245-jj-gerrit-upload.patch.license
NEW
packetmix/packages/jujutsu/default.nix
NEW
packetmix/packages/jujutsu/default.nix
···
1
-
# SPDX-FileCopyrightText: 2025 FreshlyBakedCake
2
-
#
3
-
# SPDX-License-Identifier: MIT
4
-
5
-
{ config, ... }:
6
-
{
7
-
config.packages.jujutsu = {
8
-
systems = [ "x86_64-linux" ];
9
-
package =
10
-
{
11
-
system,
12
-
...
13
-
}:
14
-
config.inputs.nixos-unstable.result.${system}.jujutsu.overrideAttrs (prevAttrs: {
15
-
patches = (prevAttrs.patches or [ ]) ++ [ ./7245-jj-gerrit-upload.patch ];
16
-
});
17
-
};
18
-
}
···