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
321
322
323
324
//! jj-fork maintains a fork as independent jj series on top of upstream, plus glues that hold the
//! resolutions between series, combined into a generated fork branch. It keeps them current as
//! upstream moves.
mod artifact;
mod checks;
mod config;
mod glue;
mod init;
mod native;
mod reconcile;
mod repair;
mod repo;
mod run;
mod sync;
mod workflow;
use std::path::PathBuf;
use std::process::ExitCode;
use anyhow::Result;
use clap::{Args, Parser, Subcommand};
use crate::config::Config;
use crate::repo::Repo;
use crate::sync::{Options, Session};
#[derive(Parser)]
#[command(name = "jj-fork", version, about)]
struct Cli {
/// Config file to use instead of the repository's .jj-fork.toml.
#[arg(long, global = true)]
config: Option<PathBuf>,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
/// Prepare this clone: remotes, full history, jj, bookmark tracking, and revset aliases.
Init {
/// Upstream repository URL. Writes a starter .jj-fork.toml when none exists.
#[arg(long)]
upstream: Option<String>,
},
/// Report whether each series is up-to-date, clean, conflicted, or broken on upstream.
/// Exits 0 when nothing is stale, 10 when every stale series is clean, 20 when an agent is needed.
Check(SyncArgs),
/// Rebase clean series onto upstream and assemble the fork branch. Changes nothing unless
/// every stale series is clean.
Sync(SyncArgs),
/// Restack glues, then build, check, and move the fork branch to a merge of upstream, every
/// series, and every glue.
Assemble {
#[command(flatten)]
args: SyncArgs,
/// Check and publish an existing merge, such as one whose conflicts were resolved by hand.
#[arg(long)]
candidate: Option<String>,
},
/// Start an empty, editable series on upstream. Never assembles or pushes.
Create {
/// Full bookmark name under a configured series prefix, e.g. patch/my-fix.
name: String,
#[arg(short, long)]
message: String,
#[arg(long)]
no_fetch: bool,
},
/// Remove a series and explicitly approved dependent glues after checking the reduced fork.
/// Remote refs and separate PR-head bookmarks are retained.
Retire {
name: String,
/// A dependent glue whose entire resolution may be removed; repeat for every dependency.
#[arg(long)]
remove_glue: Vec<String>,
#[arg(long)]
no_fetch: bool,
#[arg(long, conflicts_with = "save_plan")]
push: bool,
#[arg(long)]
save_plan: Option<PathBuf>,
#[arg(long)]
report: Option<PathBuf>,
},
/// Authenticate and apply an exact saved proposal after rerunning its required checks.
Apply {
file: PathBuf,
#[arg(long)]
push: bool,
#[arg(long)]
report: Option<PathBuf>,
},
/// Create isolated repair tasks or validate submissions into a successor saved plan.
Repair {
#[command(subcommand)]
command: repair::Command,
},
/// Add `aliases.fork` to your jj config so `jj fork` runs this tool.
Alias,
}
#[derive(Args, Default)]
struct SyncArgs {
/// Upstream revision to update to. Defaults to the upstream branch after fetching.
#[arg(long)]
target: Option<String>,
/// Do not fetch the fork and upstream remotes first.
#[arg(long)]
no_fetch: bool,
/// Skip the configured checks; detect conflicts only.
#[arg(long, conflicts_with = "save_plan")]
no_checks: bool,
/// Push the fork branch, every series and glue, and a fast-forwarded mirror branch after
/// success. Refuses if any of them changed on the remote during the run.
#[arg(long, conflicts_with = "save_plan")]
push: bool,
/// Write one typed, versioned JSON report (text output remains unchanged).
#[arg(long)]
report: Option<PathBuf>,
/// Build and check an executable saved plan without publishing maintenance or pushing.
#[arg(long)]
save_plan: Option<PathBuf>,
}
pub fn progress(message: &str) {
eprintln!("jj-fork: {message}");
}
pub fn report(line: &str) {
println!("{line}");
}
fn main() -> ExitCode {
let cli = Cli::parse();
match execute(cli) {
Ok(code) => ExitCode::from(code as u8),
Err(err) => {
eprintln!("jj-fork: error: {err:#}");
ExitCode::from(1)
}
}
}
fn execute(cli: Cli) -> Result<i32> {
let report_path = match &cli.command {
Command::Check(args) | Command::Sync(args) | Command::Assemble { args, .. } => {
args.report.clone()
}
Command::Apply { report, .. } | Command::Retire { report, .. } => report.clone(),
_ => None,
};
let mut reported = false;
let result = execute_inner(cli, &mut reported);
if let Err(error) = &result
&& !reported
&& let Some(path) = report_path
{
artifact::save_report(
&path,
&workflow::Report {
plan: None,
diagnostics: vec![workflow::Issue {
id: "command_error:prepare".into(),
code: "command_error".into(),
subject: "prepare".into(),
candidate: None,
tier: None,
message: format!("{error:#}"),
}],
exit_code: 1,
published_operation: None,
push: None,
},
)?;
}
result
}
fn execute_inner(cli: Cli, reported: &mut bool) -> Result<i32> {
let cwd = std::env::var_os("JJ_WORKSPACE_ROOT")
.map(PathBuf::from)
.unwrap_or(std::env::current_dir()?);
if let Command::Alias = cli.command {
init::install_alias(&cwd)?;
return Ok(0);
}
let repo = Repo::discover(&cwd)?;
if let Command::Apply { file, push, report } = &cli.command {
*reported = true; // Apply writes its own authenticated or initial-error report.
return workflow::apply(&repo, file, cli.config.as_deref(), *push, report.as_deref());
}
if let Command::Init {
upstream: Some(url),
} = &cli.command
&& cli.config.is_none()
&& !repo.root.join(config::FILE_NAME).exists()
{
init::write_starter_config(&repo.root, url)?;
}
let config = Config::load(&repo.root, cli.config.as_deref())?;
if let Command::Repair { command } = cli.command {
return repair::run(&repo, &config, command);
}
let config_path = std::fs::canonicalize(
cli.config
.as_deref()
.unwrap_or(&repo.root.join(config::FILE_NAME)),
)?;
let options = |args: SyncArgs, candidate: Option<String>, command: &str| Options {
context: workflow::Context {
command: command.into(),
config_path: config_path.clone(),
target_expression: args.target.clone(),
candidate_expression: candidate.clone(),
fetched: !args.no_fetch,
checks_enabled: !args.no_checks,
},
target: args.target,
candidate,
fetch: !args.no_fetch,
checks: !args.no_checks,
push: args.push,
save_plan: args.save_plan,
report_path: args.report,
};
let retirement = match &cli.command {
Command::Retire {
name, remove_glue, ..
} => Some((name.clone(), remove_glue.clone())),
_ => None,
};
let (mut session, command) = match cli.command {
Command::Init { .. } => {
init::init(&repo, &config)?;
return Ok(0);
}
Command::Alias => unreachable!(),
Command::Check(args) => {
anyhow::ensure!(
args.save_plan.is_none(),
"check supports --report, not --save-plan; use sync or assemble"
);
(
Session::new(&repo, &config, options(args, None, "check"))?,
"check",
)
}
Command::Sync(args) => (
Session::new(&repo, &config, options(args, None, "sync"))?,
"sync",
),
Command::Assemble { args, candidate } => (
Session::new(&repo, &config, options(args, candidate, "assemble"))?,
"assemble",
),
Command::Create {
name,
message,
no_fetch,
} => {
let mut session = Session::new(
&repo,
&config,
options(
SyncArgs {
no_fetch,
..Default::default()
},
None,
"create",
),
)?;
return session.create(&name, &message);
}
Command::Retire {
no_fetch,
push,
save_plan,
report,
..
} => (
Session::new(
&repo,
&config,
options(
SyncArgs {
no_fetch,
push,
save_plan,
report,
..Default::default()
},
None,
"retire",
),
)?,
"retire",
),
Command::Apply { .. } | Command::Repair { .. } => unreachable!(),
};
let result = match command {
"check" => session.check(),
"sync" => session.sync(),
"retire" => {
let (name, glues) = retirement.as_ref().unwrap();
session.retire(name, glues)
}
_ => session.assemble_command(),
};
let code = match result {
Ok(code) => code,
Err(error) => {
session.record_error(&error);
session.write_artifacts(1)?;
*reported = true;
return Err(error);
}
};
session.write_artifacts(code)?;
*reported = true;
session.finish();
Ok(code)
}