cli: add jj bisect run command

This adds a command that automatically bisects a range of commits
using a specified command. By not having the interactive kind
(e.g. `jj bisect good/bad/skip/reset`), we avoid - for now at least -
having to decide where to store the state. The user can still achieve
interactive bisection by passing their shell as the bisection command.

Closes #2987.
This commit is contained in:
Martin von Zweigbergk 2025-07-26 23:15:24 -07:00
parent 720b8cc114
commit c15cc344de
14 changed files with 605 additions and 3 deletions

View file

@ -17,6 +17,9 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### New features
* The new command `jj bisect run` uses binary search to find a commit that
introduced a bug.
### Fixed bugs
## [0.33.0] - 2025-09-03

View file

@ -30,6 +30,11 @@ include = [
name = "jj"
path = "src/main.rs"
[[bin]]
name = "fake-bisector"
path = "testing/fake-bisector.rs"
required-features = ["test-fakes"]
[[bin]]
name = "fake-editor"
path = "testing/fake-editor.rs"

View file

@ -24,6 +24,7 @@ use itertools::Itertools as _;
use jj_lib::absorb::AbsorbError;
use jj_lib::backend::BackendError;
use jj_lib::backend::CommitId;
use jj_lib::bisect::BisectionError;
use jj_lib::config::ConfigFileSaveError;
use jj_lib::config::ConfigGetError;
use jj_lib::config::ConfigLoadError;
@ -743,6 +744,14 @@ impl From<FixError> for CommandError {
}
}
impl From<BisectionError> for CommandError {
fn from(err: BisectionError) -> Self {
match err {
BisectionError::RevsetEvaluationError(_) => user_error(err),
}
}
}
fn find_source_parse_error_hint(err: &dyn error::Error) -> Option<String> {
let source = err.source()?;
if let Some(source) = source.downcast_ref() {

View file

@ -0,0 +1,35 @@
// Copyright 2025 The Jujutsu Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
mod run;
use crate::cli_util::CommandHelper;
use crate::command_error::CommandError;
use crate::ui::Ui;
/// Find a bad revision by bisection.
#[derive(clap::Subcommand, Clone, Debug)]
pub enum BisectCommand {
Run(run::BisectRunArgs),
}
pub fn cmd_bisect(
ui: &mut Ui,
command: &CommandHelper,
subcommand: &BisectCommand,
) -> Result<(), CommandError> {
match subcommand {
BisectCommand::Run(args) => run::cmd_bisect_run(ui, command, args),
}
}

View file

@ -0,0 +1,207 @@
// Copyright 2025 The Jujutsu Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use clap_complete::ArgValueCompleter;
use jj_lib::bisect::BisectionResult;
use jj_lib::bisect::Bisector;
use jj_lib::bisect::Evaluation;
use jj_lib::commit::Commit;
use jj_lib::object_id::ObjectId as _;
use tracing::instrument;
use crate::cli_util::CommandHelper;
use crate::cli_util::RevisionArg;
use crate::cli_util::WorkspaceCommandHelper;
use crate::cli_util::short_operation_hash;
use crate::command_error::CommandError;
use crate::command_error::internal_error_with_message;
use crate::command_error::user_error;
use crate::command_error::user_error_with_message;
use crate::complete;
use crate::config::CommandNameAndArgs;
use crate::ui::Ui;
/// Run a given command to find the first bad revision.
///
/// Uses binary search to find the first bad revision. Revisions are evaluated
/// by running a given command (see the documentation for `--command` for
/// details).
///
/// It is assumed that if a given revision is bad, then all its descendants
/// in the input range are also bad.
///
/// Hint: You can pass your shell as evaluation command. You can then run
/// manual tests in the shell and make sure to exit the shell with appropriate
/// error code depending on the outcome (e.g. `exit 0` to mark the revision as
/// good in Bash or Fish).
#[derive(clap::Args, Clone, Debug)]
pub(crate) struct BisectRunArgs {
/// Range of revisions to bisect
///
/// This is typically a range like `v1.0..main`. The heads of the range are
/// assumed to be bad. Ancestors of the range that are not also in the range
/// are assumed to be good.
#[arg(
long,
short,
value_name = "REVSETS",
required = true,
add = ArgValueCompleter::new(complete::revset_expression_all),
)]
range: Vec<RevisionArg>,
/// Command to run to determine whether the bug is present
///
/// The command will be run from the workspace root. The exit status of the
/// command will be used to mark revisions as good or bad:
/// status 0 means good, 125 means to skip the revision, 127 (command not
/// found) will abort the bisection, and any other non-zero exit status
/// means the revision is bad.
///
/// The target's commit ID is available to the command in the
/// `$JJ_BISECT_TARGET` environment variable.
#[arg(long, value_name = "COMMAND", required = true)]
command: CommandNameAndArgs,
}
#[instrument(skip_all)]
pub(crate) fn cmd_bisect_run(
ui: &mut Ui,
command: &CommandHelper,
args: &BisectRunArgs,
) -> Result<(), CommandError> {
let mut workspace_command = command.workspace_helper(ui)?;
let input_range = workspace_command
.parse_union_revsets(ui, &args.range)?
.resolve()?;
let initial_repo = workspace_command.repo().clone();
let mut bisector = Bisector::new(initial_repo.as_ref(), input_range)?;
let bisection_result = loop {
match bisector.next_step()? {
jj_lib::bisect::NextStep::Evaluate(commit) => {
{
let mut formatter = ui.stdout_formatter();
// TODO: Show a graph of the current range instead?
// TODO: Say how many commits are left and estimate the number of iterations.
let commit_template = workspace_command.commit_summary_template();
write!(formatter, "Now evaluating: ")?;
commit_template.format(&commit, formatter.as_mut())?;
writeln!(formatter)?;
}
let evaluation =
evaluate_commit(ui, &mut workspace_command, &args.command, &commit)?;
{
let mut formatter = ui.stdout_formatter();
let message = match evaluation {
Evaluation::Good => "The revision is good.",
Evaluation::Bad => "The revision is bad.",
Evaluation::Skip => {
"It could not be determined if the revision is good or bad."
}
};
writeln!(formatter, "{message}")?;
writeln!(formatter)?;
}
bisector.mark(commit.id().clone(), evaluation);
// Reload the workspace because the evaluation command may run `jj` commands.
workspace_command = command.workspace_helper(ui)?;
}
jj_lib::bisect::NextStep::Done(bisection_result) => {
break bisection_result;
}
}
};
let mut formatter = ui.stdout_formatter();
writeln!(
formatter,
"Search complete. To discard any revisions created during search, run:"
)?;
writeln!(
formatter,
" jj op restore {}",
short_operation_hash(initial_repo.op_id())
)?;
match bisection_result {
BisectionResult::Indeterminate => {
return Err(user_error(
"Could not find the first bad revision. Was the input range empty?",
));
}
BisectionResult::Found(first_bad_commits) => {
let commit_template = workspace_command.commit_summary_template();
if let [first_bad_commit] = first_bad_commits.as_slice() {
write!(formatter, "The first bad revision is: ")?;
commit_template.format(first_bad_commit, formatter.as_mut())?;
writeln!(formatter)?;
} else {
writeln!(formatter, "The first bad revisions are:")?;
for first_bad_commit in first_bad_commits {
commit_template.format(&first_bad_commit, formatter.as_mut())?;
writeln!(formatter)?;
}
}
}
}
Ok(())
}
fn evaluate_commit(
ui: &mut Ui,
workspace_command: &mut WorkspaceCommandHelper,
command: &CommandNameAndArgs,
commit: &Commit,
) -> Result<Evaluation, CommandError> {
let mut tx = workspace_command.start_transaction();
let commit_id_hex = commit.id().hex();
tx.check_out(commit)?;
tx.finish(
ui,
format!("Updated to revision {commit_id_hex} for bisection"),
)?;
let mut cmd: std::process::Command = command.to_command();
let jj_executable_path = std::env::current_exe().map_err(|err| {
internal_error_with_message("Could not get path for the jj executable", err)
})?;
tracing::info!(?cmd, "running bisection evaluation command");
let status = cmd
.env("JJ_EXECUTABLE_PATH", jj_executable_path)
.env("JJ_BISECT_TARGET", &commit_id_hex)
.status()
.map_err(|err| user_error_with_message("Failed to run evaluation command", err))?;
let evaluation = if status.success() {
Evaluation::Good
} else {
match status.code() {
Some(125) => Evaluation::Skip,
Some(127) => {
return Err(user_error(
"Evaluation command returned 127 (command not found) - aborting bisection.",
));
}
_ => Evaluation::Bad,
}
};
Ok(evaluation)
}

View file

@ -17,6 +17,7 @@ mod absorb;
mod backout;
#[cfg(feature = "bench")]
mod bench;
mod bisect;
mod bookmark;
mod commit;
mod config;
@ -97,6 +98,8 @@ enum Command {
#[command(subcommand)]
Bench(bench::BenchCommand),
#[command(subcommand)]
Bisect(bisect::BisectCommand),
#[command(subcommand)]
Bookmark(bookmark::BookmarkCommand),
Commit(commit::CommitArgs),
#[command(subcommand)]
@ -168,6 +171,7 @@ pub fn run_command(ui: &mut Ui, command_helper: &CommandHelper) -> Result<(), Co
Command::Backout(args) => backout::cmd_backout(ui, command_helper, args),
#[cfg(feature = "bench")]
Command::Bench(args) => bench::cmd_bench(ui, command_helper, args),
Command::Bisect(args) => bisect::cmd_bisect(ui, command_helper, args),
Command::Bookmark(args) => bookmark::cmd_bookmark(ui, command_helper, args),
Command::Commit(args) => commit::cmd_commit(ui, command_helper, args),
Command::Config(args) => config::cmd_config(ui, command_helper, args),

View file

@ -0,0 +1,70 @@
// Copyright 2025 The Jujutsu Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use std::env;
use std::fs;
use std::path::PathBuf;
use std::process::exit;
use itertools::Itertools as _;
fn main() {
let edit_script_path = PathBuf::from(env::var_os("BISECTION_SCRIPT").unwrap());
let commit_to_test = env::var_os("JJ_BISECT_TARGET")
.unwrap()
.to_str()
.unwrap()
.to_owned();
println!("fake-bisector testing commit {commit_to_test}");
let edit_script = fs::read_to_string(&edit_script_path).unwrap();
let mut instructions = edit_script.split('\0').collect_vec();
if let Some(pos) = instructions.iter().position(|&i| i == "next invocation\n") {
// Overwrite the edit script. The next time `fake-bisector` is called, it will
// only see the part after the `next invocation` command.
fs::write(&edit_script_path, instructions[pos + 1..].join("\0")).unwrap();
instructions.truncate(pos);
}
for instruction in instructions {
let (command, payload) = instruction.split_once('\n').unwrap_or((instruction, ""));
let parts = command.split(' ').collect_vec();
match parts.as_slice() {
[""] => {}
["fail"] => exit(1),
["fail-if-target-is", bad_target_commit] => {
if commit_to_test == *bad_target_commit {
exit(1)
}
}
["write", path] => {
fs::write(path, payload).unwrap_or_else(|_| panic!("Failed to write file {path}"));
}
["jj", args @ ..] => {
let jj_executable_path = PathBuf::from(env::var_os("JJ_EXECUTABLE_PATH").unwrap());
let status = std::process::Command::new(&jj_executable_path)
.args(args)
.status()
.unwrap();
if !status.success() {
eprintln!("fake-bisector: failed to run jj: {status:?}");
exit(1)
}
}
_ => {
eprintln!("fake-bisector: unexpected command: {command}");
exit(1)
}
}
}
}

View file

@ -13,6 +13,8 @@ This document contains the help content for the `jj` command-line program.
* [`jj`↴](#jj)
* [`jj abandon`↴](#jj-abandon)
* [`jj absorb`↴](#jj-absorb)
* [`jj bisect`↴](#jj-bisect)
* [`jj bisect run`↴](#jj-bisect-run)
* [`jj bookmark`↴](#jj-bookmark)
* [`jj bookmark create`↴](#jj-bookmark-create)
* [`jj bookmark delete`↴](#jj-bookmark-delete)
@ -128,6 +130,7 @@ To get started, see the tutorial [`jj help -k tutorial`].
* `abandon` — Abandon a revision
* `absorb` — Move changes from a revision into the stack of mutable revisions
* `bisect` — Find a bad revision by bisection
* `bookmark` — Manage bookmarks [default alias: b]
* `commit` — Update the description and create a new change on top [default alias: ci]
* `config` — Manage config options
@ -265,6 +268,43 @@ The modification made by `jj absorb` can be reviewed by `jj op show -p`.
## `jj bisect`
Find a bad revision by bisection
**Usage:** `jj bisect <COMMAND>`
###### **Subcommands:**
* `run` — Run a given command to find the first bad revision
## `jj bisect run`
Run a given command to find the first bad revision.
Uses binary search to find the first bad revision. Revisions are evaluated by running a given command (see the documentation for `--command` for details).
It is assumed that if a given revision is bad, then all its descendants in the input range are also bad.
Hint: You can pass your shell as evaluation command. You can then run manual tests in the shell and make sure to exit the shell with appropriate error code depending on the outcome (e.g. `exit 0` to mark the revision as good in Bash or Fish).
**Usage:** `jj bisect run --range <REVSETS> --command <COMMAND>`
###### **Options:**
* `-r`, `--range <REVSETS>` — Range of revisions to bisect
This is typically a range like `v1.0..main`. The heads of the range are assumed to be bad. Ancestors of the range that are not also in the range are assumed to be good.
* `--command <COMMAND>` — Command to run to determine whether the bug is present
The command will be run from the workspace root. The exit status of the command will be used to mark revisions as good or bad: status 0 means good, 125 means to skip the revision, 127 (command not found) will abort the bisection, and any other non-zero exit status means the revision is bad.
The target's commit ID is available to the command in the `$JJ_BISECT_TARGET` environment variable.
## `jj bookmark`
Manage bookmarks [default alias: b]

View file

@ -21,6 +21,12 @@ pub use self::config_schema_defaults::default_config_from_schema;
pub use self::test_environment::TestEnvironment;
pub use self::test_environment::TestWorkDir;
pub fn fake_bisector_path() -> String {
let path = assert_cmd::cargo::cargo_bin("fake-bisector");
assert!(path.is_file());
path.into_os_string().into_string().unwrap()
}
pub fn fake_editor_path() -> String {
let path = assert_cmd::cargo::cargo_bin("fake-editor");
assert!(path.is_file());

View file

@ -202,6 +202,15 @@ impl TestEnvironment {
self.env_vars.insert(key.into(), val.into());
}
/// Sets up the fake bisection test command to read a script from the
/// returned path
pub fn set_up_fake_bisector(&mut self) -> PathBuf {
let bisection_script = self.env_root().join("bisection_script");
std::fs::write(&bisection_script, "").unwrap();
self.add_env_var("BISECTION_SCRIPT", bisection_script.to_str().unwrap());
bisection_script
}
/// Sets up the fake editor to read an edit script from the returned path
/// Also sets up the fake editor as a merge tool named "fake-editor"
pub fn set_up_fake_editor(&mut self) -> PathBuf {

View file

@ -14,6 +14,7 @@ mod test_acls;
mod test_advance_bookmarks;
mod test_alias;
mod test_backout_command;
mod test_bisect_command;
mod test_bookmark_command;
mod test_builtin_aliases;
mod test_commit_command;

View file

@ -0,0 +1,213 @@
// Copyright 2022 The Jujutsu Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use crate::common::CommandOutput;
use crate::common::TestEnvironment;
use crate::common::TestWorkDir;
use crate::common::create_commit;
use crate::common::fake_bisector_path;
#[test]
fn test_bisect_run() {
let test_env = TestEnvironment::default();
test_env.run_jj_in(".", ["git", "init", "repo"]).success();
let work_dir = test_env.work_dir("repo");
create_commit(&work_dir, "a", &[]);
create_commit(&work_dir, "b", &["a"]);
create_commit(&work_dir, "c", &["b"]);
create_commit(&work_dir, "d", &["c"]);
create_commit(&work_dir, "e", &["d"]);
create_commit(&work_dir, "f", &["e"]);
insta::assert_snapshot!(work_dir.run_jj(["bisect", "run", "--range=..", "--command=false"]), @r"
Now evaluating: royxmykx dffaa0d4 c | c
The revision is bad.
Now evaluating: rlvkpnrz 7d980be7 a | a
The revision is bad.
Search complete. To discard any revisions created during search, run:
jj op restore 9152b6b19cce
The first bad revision is: rlvkpnrz 7d980be7 a | a
[EOF]
------- stderr -------
Working copy (@) now at: lylxulpl 68b3a16f (empty) (no description set)
Parent commit (@-) : royxmykx dffaa0d4 c | c
Added 0 files, modified 0 files, removed 3 files
Working copy (@) now at: rsllmpnm 5f328bc5 (empty) (no description set)
Parent commit (@-) : rlvkpnrz 7d980be7 a | a
Added 0 files, modified 0 files, removed 2 files
[EOF]
");
insta::assert_snapshot!(get_log_output(&work_dir), @r"
@ rsllmpnmslon 5f328bc5fde0 '' files:
kmkuslswpqwq 8b67af288466 'f' files: f
znkkpsqqskkl 62d30ded0e8f 'e' files: e
vruxwmqvtpmx 86be7a223919 'd' files: d
royxmykxtrkr dffaa0d4dacc 'c' files: c
zsuskulnrvyr 123b4d91f6e5 'b' files: b
rlvkpnrzqnoo 7d980be7a1d4 'a' files: a
zzzzzzzzzzzz 000000000000 '' files:
[EOF]
");
}
#[test]
fn test_bisect_run_write_file() {
let mut test_env = TestEnvironment::default();
let bisector_path = fake_bisector_path();
let bisection_script = test_env.set_up_fake_bisector();
test_env.run_jj_in(".", ["git", "init", "repo"]).success();
let work_dir = test_env.work_dir("repo");
create_commit(&work_dir, "a", &[]);
create_commit(&work_dir, "b", &["a"]);
create_commit(&work_dir, "c", &["b"]);
create_commit(&work_dir, "d", &["c"]);
create_commit(&work_dir, "e", &["d"]);
std::fs::write(
&bisection_script,
["write new-file\nsome contents", "fail"].join("\0"),
)
.unwrap();
insta::assert_snapshot!(work_dir.run_jj(["bisect", "run", "--range=..", "--command", &bisector_path]), @r"
Now evaluating: zsuskuln 123b4d91 b | b
fake-bisector testing commit 123b4d91f6e5e39bfed39bae3bacf9380dc79078
The revision is bad.
Now evaluating: rlvkpnrz 7d980be7 a | a
fake-bisector testing commit 7d980be7a1d499e4d316ab4c01242885032f7eaf
The revision is bad.
Search complete. To discard any revisions created during search, run:
jj op restore 156d8a1abcb8
The first bad revision is: rlvkpnrz 7d980be7 a | a
[EOF]
------- stderr -------
Working copy (@) now at: kmkuslsw 17e2a972 (empty) (no description set)
Parent commit (@-) : zsuskuln 123b4d91 b | b
Added 0 files, modified 0 files, removed 3 files
Working copy (@) now at: msksykpx 2f6e298d (empty) (no description set)
Parent commit (@-) : rlvkpnrz 7d980be7 a | a
Added 0 files, modified 0 files, removed 2 files
[EOF]
");
insta::assert_snapshot!(get_log_output(&work_dir), @r"
@ msksykpxotkr 891aeb03b623 '' files: new-file
kmkuslswpqwq 2bae881dc1bc '' files: new-file
znkkpsqqskkl 62d30ded0e8f 'e' files: e
vruxwmqvtpmx 86be7a223919 'd' files: d
royxmykxtrkr dffaa0d4dacc 'c' files: c
zsuskulnrvyr 123b4d91f6e5 'b' files: b
rlvkpnrzqnoo 7d980be7a1d4 'a' files: a
zzzzzzzzzzzz 000000000000 '' files:
[EOF]
");
// No concurrent operations
let output = work_dir.run_jj(["op", "log", "-n=5", "-T=description"]);
insta::assert_snapshot!(output, @r"
@ snapshot working copy
Updated to revision 7d980be7a1d499e4d316ab4c01242885032f7eaf for bisection
snapshot working copy
Updated to revision 123b4d91f6e5e39bfed39bae3bacf9380dc79078 for bisection
create bookmark e pointing to commit 62d30ded0e8fdf8cf87012e6223898b97977fc8e
[EOF]
");
}
#[test]
fn test_bisect_run_jj_command() {
let mut test_env = TestEnvironment::default();
let bisector_path = fake_bisector_path();
let bisection_script = test_env.set_up_fake_bisector();
test_env.run_jj_in(".", ["git", "init", "repo"]).success();
let work_dir = test_env.work_dir("repo");
create_commit(&work_dir, "a", &[]);
create_commit(&work_dir, "b", &["a"]);
create_commit(&work_dir, "c", &["b"]);
create_commit(&work_dir, "d", &["c"]);
create_commit(&work_dir, "e", &["d"]);
std::fs::write(&bisection_script, ["jj new -mtesting", "fail"].join("\0")).unwrap();
insta::assert_snapshot!(work_dir.run_jj(["bisect", "run", "--range=..", "--command", &bisector_path]), @r"
Now evaluating: zsuskuln 123b4d91 b | b
fake-bisector testing commit 123b4d91f6e5e39bfed39bae3bacf9380dc79078
The revision is bad.
Now evaluating: rlvkpnrz 7d980be7 a | a
fake-bisector testing commit 7d980be7a1d499e4d316ab4c01242885032f7eaf
The revision is bad.
Search complete. To discard any revisions created during search, run:
jj op restore 156d8a1abcb8
The first bad revision is: rlvkpnrz 7d980be7 a | a
[EOF]
------- stderr -------
Working copy (@) now at: kmkuslsw 17e2a972 (empty) (no description set)
Parent commit (@-) : zsuskuln 123b4d91 b | b
Added 0 files, modified 0 files, removed 3 files
Working copy (@) now at: kmkuslsw?? 55b3b4a8 (empty) testing
Parent commit (@-) : kmkuslsw?? 17e2a972 (empty) (no description set)
Working copy (@) now at: msksykpx 2f6e298d (empty) (no description set)
Parent commit (@-) : rlvkpnrz 7d980be7 a | a
Added 0 files, modified 0 files, removed 1 files
Working copy (@) now at: kmkuslsw?? 2f80658c (empty) testing
Parent commit (@-) : msksykpx 2f6e298d (empty) (no description set)
[EOF]
");
insta::assert_snapshot!(get_log_output(&work_dir), @r"
@ kmkuslswpqwq 2f80658c4d26 'testing' files:
msksykpxotkr 2f6e298d59bd '' files:
kmkuslswpqwq 55b3b4a8b253 'testing' files:
kmkuslswpqwq 17e2a9721f61 '' files:
znkkpsqqskkl 62d30ded0e8f 'e' files: e
vruxwmqvtpmx 86be7a223919 'd' files: d
royxmykxtrkr dffaa0d4dacc 'c' files: c
zsuskulnrvyr 123b4d91f6e5 'b' files: b
rlvkpnrzqnoo 7d980be7a1d4 'a' files: a
zzzzzzzzzzzz 000000000000 '' files:
[EOF]
");
// No concurrent operations
let output = work_dir.run_jj(["op", "log", "-n=5", "-T=description"]);
insta::assert_snapshot!(output, @r"
@ new empty commit
Updated to revision 7d980be7a1d499e4d316ab4c01242885032f7eaf for bisection
new empty commit
Updated to revision 123b4d91f6e5e39bfed39bae3bacf9380dc79078 for bisection
create bookmark e pointing to commit 62d30ded0e8fdf8cf87012e6223898b97977fc8e
[EOF]
");
}
#[must_use]
fn get_log_output(work_dir: &TestWorkDir) -> CommandOutput {
let template = r#"separate(" ",
change_id.short(),
commit_id.short(),
"'" ++ description.first_line() ++ "'",
"files: " ++ diff.files().map(|e| e.path())
)"#;
work_dir.run_jj(["log", "-T", template])
}

View file

@ -99,7 +99,7 @@ Co-locating a Jujutsu repository allows you to use both Jujutsu and Git in the
same working copy. The benefits of doing so are:
- You can use Git commands when you're not sure how to do something with
Jujutsu, Jujutsu hasn't yet implemented a feature (e.g., bisection), or you
Jujutsu, Jujutsu hasn't yet implemented a feature (e.g., tagging), or you
simply prefer Git in some situations.
- Tooling that expects a Git repository still works (IDEs, build tooling, etc.)

View file

@ -60,8 +60,8 @@ Here is a list of some differences between jj and Sapling.
* **Git interop:** Sapling supports cloning, pushing, and pulling from a remote
Git repo. jj also does, and it also supports sharing a working copy with a Git
repo, so you can use `jj` and `git` interchangeably in the same repo.
* **Polish:** Sapling is more polished and feature-complete. For example, jj
has no `bisect` command. Sapling also has very nice built-in web UI called
* **Polish:** Sapling is more polished and feature-complete. Sapling has very
nice built-in web UI called
[Interactive Smartlog](https://sapling-scm.com/docs/addons/isl), which lets
you drag and drop commits to rebase them, among other things.
* **Forge workflow:** Sapling has `sl pr submit --stack`, which lets you