I'm here all day until we have auto-PRs set up for tangled
+1
-1
packetmix/homes/development/jujutsu.nix
+1
-1
packetmix/homes/development/jujutsu.nix
+35
-35
packetmix/npins/sources.json
+35
-35
packetmix/npins/sources.json
···
61
},
62
"branch": "main",
63
"submodules": false,
64
-
"revision": "d239f0bcf5e7ed391c8ced32e13aaa4c1324a757",
65
-
"url": "https://github.com/bluesky-social/atproto/archive/d239f0bcf5e7ed391c8ced32e13aaa4c1324a757.tar.gz",
66
-
"hash": "sha256-PzZhBAGwrjItNGgUTXsP3fceEXlxKWj5+OsaSnK3UbI="
67
},
68
"catppuccin": {
69
"type": "Git",
···
74
},
75
"branch": "main",
76
"submodules": false,
77
-
"revision": "5f7dc8bab8af6ba612ef8dc7cd44e38ba6cfd51a",
78
-
"url": "https://github.com/catppuccin/nix/archive/5f7dc8bab8af6ba612ef8dc7cd44e38ba6cfd51a.tar.gz",
79
-
"hash": "sha256-aHN6dAD72IsNvNlzU3nbV4DJRb1qPvURgWIzHeYsBbc="
80
},
81
"collabora-gtimelog": {
82
"type": "Git",
···
102
"version_upper_bound": null,
103
"release_prefix": null,
104
"submodules": false,
105
-
"version": "v1.19.15",
106
-
"revision": "daba1ab7bdf57377fb04e174a7527406c422578d",
107
-
"url": "https://api.github.com/repos/9001/copyparty/tarball/refs/tags/v1.19.15",
108
-
"hash": "sha256-FjS5UA4jWko/2+lkyzGOVhhZOEA8oTIoQ7GIcRj3ebM="
109
},
110
"core": {
111
"type": "Git",
···
154
},
155
"branch": "master",
156
"submodules": false,
157
-
"revision": "004753ae6b04c4b18aa07192c1106800aaacf6c3",
158
-
"url": "https://github.com/nix-community/home-manager/archive/004753ae6b04c4b18aa07192c1106800aaacf6c3.tar.gz",
159
-
"hash": "sha256-CcT3QvZ74NGfM+lSOILcCEeU+SnqXRvl1XCRHenZ0Us="
160
},
161
"impermanence": {
162
"type": "Git",
···
197
},
198
"branch": "main",
199
"submodules": false,
200
-
"revision": "1c4e77387ad42686517850da5fb98d8cac11adb7",
201
-
"url": "https://git.lix.systems/lix-project/lix/archive/1c4e77387ad42686517850da5fb98d8cac11adb7.tar.gz",
202
-
"hash": "sha256-bF0zdh6b56OBlYQ3Lsu+nFk+L5F28Mw7xzeYQU9V964="
203
},
204
"lix-module": {
205
"type": "Git",
···
298
},
299
"branch": "main",
300
"submodules": false,
301
-
"revision": "d425163158a96a26924597574316a627d2e982aa",
302
-
"url": "https://github.com/sodiboo/niri-flake/archive/d425163158a96a26924597574316a627d2e982aa.tar.gz",
303
-
"hash": "sha256-xhUr1oMQwL/8h8xnPi5QxUHRFDHoCofhw8Jy7qTD4BY="
304
},
305
"nix-index-database": {
306
"type": "Git",
···
311
},
312
"branch": "main",
313
"submodules": false,
314
-
"revision": "ec7a78cb0e098832d8acac091a4df393259c4839",
315
-
"url": "https://github.com/nix-community/nix-index-database/archive/ec7a78cb0e098832d8acac091a4df393259c4839.tar.gz",
316
-
"hash": "sha256-WZf+FhebP2/1pK2np5xj/NuDjD6fXK2BHnq/tPUN18o="
317
},
318
"nix-monitored": {
319
"type": "Git",
···
331
"nixos-unstable": {
332
"type": "Channel",
333
"name": "nixos-unstable",
334
-
"url": "https://releases.nixos.org/nixos/unstable/nixos-25.11pre868392.e9f00bd89398/nixexprs.tar.xz",
335
-
"hash": "sha256-Lz9jvhswQu/niKVttNvOds0w+OS+2x63NivPVJng5G4="
336
},
337
"nixpkgs": {
338
"type": "Channel",
339
"name": "nixos-25.05",
340
-
"url": "https://releases.nixos.org/nixos/25.05/nixos-25.05.810656.5b5be50345d4/nixexprs.tar.xz",
341
-
"hash": "sha256-1t/IP8E71w1r6aXjX/GBU/ZbbJo3GrSuUjQNYH11gJU="
342
},
343
"npins": {
344
"type": "Git",
···
349
},
350
"branch": "master",
351
"submodules": false,
352
-
"revision": "e49bcf58d66fa7f586aff687feae72c23e429672",
353
-
"url": "https://github.com/andir/npins/archive/e49bcf58d66fa7f586aff687feae72c23e429672.tar.gz",
354
-
"hash": "sha256-ZLrVWa7WU+IUXPAd6mR7B9c6FDZSiGgW8ClfW0SxbMs="
355
},
356
"scriptfs": {
357
"type": "Git",
···
374
},
375
"branch": "master",
376
"submodules": false,
377
-
"revision": "32b7c17e266e0da0f0e1e6e6a3821cd8dd3b8362",
378
"url": null,
379
-
"hash": "sha256-Q/UnCYR8NgO5xoevJJxyCa3ZQnB/5c62F1IBxFKbF3Q="
380
},
381
"treefmt-nix": {
382
"type": "Git",
···
402
"version_upper_bound": null,
403
"release_prefix": null,
404
"submodules": false,
405
-
"version": "v1.1.0",
406
-
"revision": "226f7f5c2d66be2acc5617cf37943075cb2ba693",
407
-
"url": "https://api.github.com/repos/abenz1267/walker/tarball/refs/tags/v1.1.0",
408
-
"hash": "sha256-fjd8N5lnZ1ee3xRCUQ2eHXiIncwe5DGj8N3MDfrYMeE="
409
}
410
},
411
"version": 6
···
61
},
62
"branch": "main",
63
"submodules": false,
64
+
"revision": "4c15fb47cec26060bff2e710e95869a90c9d7fdd",
65
+
"url": "https://github.com/bluesky-social/atproto/archive/4c15fb47cec26060bff2e710e95869a90c9d7fdd.tar.gz",
66
+
"hash": "sha256-FhWxdePEeBX2At70YdmjiYT8oXIvJ+ry3VdtfcuBUBU="
67
},
68
"catppuccin": {
69
"type": "Git",
···
74
},
75
"branch": "main",
76
"submodules": false,
77
+
"revision": "eeada12912d80d04733383d231a9d66172858718",
78
+
"url": "https://github.com/catppuccin/nix/archive/eeada12912d80d04733383d231a9d66172858718.tar.gz",
79
+
"hash": "sha256-2fzYq/m2PXie5WZO5LhyiZrTIUdUFp1SCLZAwvPL5xo="
80
},
81
"collabora-gtimelog": {
82
"type": "Git",
···
102
"version_upper_bound": null,
103
"release_prefix": null,
104
"submodules": false,
105
+
"version": "v1.19.16",
106
+
"revision": "cd3feaac86123ecf8c73b3201b27da40faf9110c",
107
+
"url": "https://api.github.com/repos/9001/copyparty/tarball/refs/tags/v1.19.16",
108
+
"hash": "sha256-ZpQInliAuJIT4DJ/X3U0Tjvg+hweqU9FlRfR/UF9YFo="
109
},
110
"core": {
111
"type": "Git",
···
154
},
155
"branch": "master",
156
"submodules": false,
157
+
"revision": "1a09eb84fa9e33748432a5253102d01251f72d6d",
158
+
"url": "https://github.com/nix-community/home-manager/archive/1a09eb84fa9e33748432a5253102d01251f72d6d.tar.gz",
159
+
"hash": "sha256-uqbhyXtqMbYIiMqVqUhNdSuh9AEEkiasoK3mIPIVRhk="
160
},
161
"impermanence": {
162
"type": "Git",
···
197
},
198
"branch": "main",
199
"submodules": false,
200
+
"revision": "42691f0d943b43842e69b76608cbe4416e35e94e",
201
+
"url": "https://git.lix.systems/lix-project/lix/archive/42691f0d943b43842e69b76608cbe4416e35e94e.tar.gz",
202
+
"hash": "sha256-PthY1vlykNZmuKHQS01Qunut1fdM0imk5Myg3hcr8Oc="
203
},
204
"lix-module": {
205
"type": "Git",
···
298
},
299
"branch": "main",
300
"submodules": false,
301
+
"revision": "0d12957ebc8e272e3fc3830549edbb1ad63c34d4",
302
+
"url": "https://github.com/sodiboo/niri-flake/archive/0d12957ebc8e272e3fc3830549edbb1ad63c34d4.tar.gz",
303
+
"hash": "sha256-ZGEBkK8ZQ370ifJO+1TOQ87m9Gmj52uzqcqysd/lolI="
304
},
305
"nix-index-database": {
306
"type": "Git",
···
311
},
312
"branch": "main",
313
"submodules": false,
314
+
"revision": "0ca69684091aa3a6b1fe994c4afeff305b15e915",
315
+
"url": "https://github.com/nix-community/nix-index-database/archive/0ca69684091aa3a6b1fe994c4afeff305b15e915.tar.gz",
316
+
"hash": "sha256-8NI1SqntLfKl6Q0Luemc3aIboezSJElofUrqipF5g78="
317
},
318
"nix-monitored": {
319
"type": "Git",
···
331
"nixos-unstable": {
332
"type": "Channel",
333
"name": "nixos-unstable",
334
+
"url": "https://releases.nixos.org/nixos/unstable/nixos-25.11pre873798.c9b6fb798541/nixexprs.tar.xz",
335
+
"hash": "sha256-Dzw+6wNlteBG6go9KYZyVjpl6CkzjKzmXrneAF5mqI0="
336
},
337
"nixpkgs": {
338
"type": "Channel",
339
"name": "nixos-25.05",
340
+
"url": "https://releases.nixos.org/nixos/25.05/nixos-25.05.810859.20c4598c84a6/nixexprs.tar.xz",
341
+
"hash": "sha256-1WvIF4N7Jj1ppU9ocZZBpsQdiVsHVzVxNt9L2+7FwPM="
342
},
343
"npins": {
344
"type": "Git",
···
349
},
350
"branch": "master",
351
"submodules": false,
352
+
"revision": "1c1ebf233c30e14ff34472c609ded8eb988753d8",
353
+
"url": "https://github.com/andir/npins/archive/1c1ebf233c30e14ff34472c609ded8eb988753d8.tar.gz",
354
+
"hash": "sha256-yCaqGejBcWoibCRj2vRrlj5uPI7lyCGS6ivCiGeKNSo="
355
},
356
"scriptfs": {
357
"type": "Git",
···
374
},
375
"branch": "master",
376
"submodules": false,
377
+
"revision": "eaa11ecb2112c90113a8ee49a8aa05be327b13ad",
378
"url": null,
379
+
"hash": "sha256-lLyX1f4fSG1IYsjcsUKKCAOHpa3ZTFexJMRc/0K2g3c="
380
},
381
"treefmt-nix": {
382
"type": "Git",
···
402
"version_upper_bound": null,
403
"release_prefix": null,
404
"submodules": false,
405
+
"version": "v2.2.0",
406
+
"revision": "cbacfea97aaf2a3408db45e499411df19f29a0c1",
407
+
"url": "https://api.github.com/repos/abenz1267/walker/tarball/refs/tags/v2.2.0",
408
+
"hash": "sha256-cSRd4ncUWjB59nRqY0X0eXioOIL7q7PwgOQggE54lTI="
409
}
410
},
411
"version": 6
-732
packetmix/packages/jujutsu/7245-jj-gerrit-upload.patch
-732
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
-
···
-4
packetmix/packages/jujutsu/7245-jj-gerrit-upload.patch.license
-4
packetmix/packages/jujutsu/7245-jj-gerrit-upload.patch.license
-18
packetmix/packages/jujutsu/default.nix
-18
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
-
}
···