mod harness;
use harness::{PRE_PUSH_FAILING, PRE_PUSH_PASSING, show};
use jj_hooks::bookmark_updates::{BookmarkUpdate, UpdateType};
use jj_hooks::hooks::{
Cancel, RunOpts, run_for_partitioned_updates_parallel, run_for_updates_parallel,
run_for_updates_sequential,
};
use jj_hooks::jj::{JjCli, primary_git_dir};
use jj_hooks::runner::{Runner, Stage};
use harness::TestRepo;
fn build_three_stack(repo: &TestRepo) -> (String, String, String, String) {
repo.write("a.txt", "a\n");
let out = repo.jj(&["commit", "-m", "b1 commit"]);
assert!(out.status.success(), "{}", show(&out));
let out = repo.jj(&["bookmark", "create", "b1", "-r", "@-"]);
assert!(out.status.success(), "{}", show(&out));
repo.write("b.txt", "b\n");
let out = repo.jj(&["commit", "-m", "b2 commit"]);
assert!(out.status.success(), "{}", show(&out));
let out = repo.jj(&["bookmark", "create", "b2", "-r", "@-"]);
assert!(out.status.success(), "{}", show(&out));
repo.write("c.txt", "c\n");
let out = repo.jj(&["commit", "-m", "b3 commit"]);
assert!(out.status.success(), "{}", show(&out));
let out = repo.jj(&["bookmark", "create", "b3", "-r", "@-"]);
assert!(out.status.success(), "{}", show(&out));
(
repo.commit_id_of("main"),
repo.commit_id_of("b1"),
repo.commit_id_of("b2"),
repo.commit_id_of("b3"),
)
}
fn updates_for_three_stack(main: &str, b1: &str, b2: &str, b3: &str) -> Vec<BookmarkUpdate> {
vec![
BookmarkUpdate {
remote: "origin".into(),
bookmark: "b1".into(),
update_type: UpdateType::Add,
old_commit: Some(main.to_owned()),
new_commit: Some(b1.to_owned()),
},
BookmarkUpdate {
remote: "origin".into(),
bookmark: "b2".into(),
update_type: UpdateType::Add,
old_commit: Some(b1.to_owned()),
new_commit: Some(b2.to_owned()),
},
BookmarkUpdate {
remote: "origin".into(),
bookmark: "b3".into(),
update_type: UpdateType::Add,
old_commit: Some(b2.to_owned()),
new_commit: Some(b3.to_owned()),
},
]
}
#[test]
fn run_for_updates_parallel_returns_results_in_input_order() {
let repo = TestRepo::new();
repo.write_pre_commit_config(PRE_PUSH_PASSING);
let (main, b1, b2, b3) = build_three_stack(&repo);
let updates = updates_for_three_stack(&main, &b1, &b2, &b3);
let jj = JjCli::new(repo.primary().to_path_buf());
let primary_git_dir = primary_git_dir(repo.primary()).unwrap();
let opts = RunOpts {
retry_after_fixup: true,
all_files: false,
capture_output: true,
};
let outcomes = run_for_updates_parallel(
&jj,
&primary_git_dir,
repo.primary(),
Some(Runner::PreCommit),
Stage::PrePush,
&updates,
opts,
|_idx, _update| {},
|_idx, _update, _outcome| {},
)
.unwrap();
assert_eq!(outcomes.len(), 3);
for (idx, outcome) in outcomes.iter().enumerate() {
assert!(
outcome.success,
"outcome #{idx} failed: captured = {:?}",
outcome.captured_output
);
assert!(
outcome.captured_output.is_some(),
"outcome #{idx} has no captured output despite capture_output=true",
);
}
}
#[test]
fn run_for_updates_parallel_first_failure_aborts_with_per_bookmark_attribution() {
let repo = TestRepo::new();
repo.write_pre_commit_config(PRE_PUSH_FAILING);
let (main, b1, b2, b3) = build_three_stack(&repo);
let updates = updates_for_three_stack(&main, &b1, &b2, &b3);
let jj = JjCli::new(repo.primary().to_path_buf());
let primary_git_dir = primary_git_dir(repo.primary()).unwrap();
let opts = RunOpts {
retry_after_fixup: false,
all_files: false,
capture_output: true,
};
let outcomes = run_for_updates_parallel(
&jj,
&primary_git_dir,
repo.primary(),
Some(Runner::PreCommit),
Stage::PrePush,
&updates,
opts,
|_idx, _update| {},
|_idx, _update, _outcome| {},
)
.unwrap();
assert_eq!(outcomes.len(), 3);
let mut had_failure = false;
for (idx, outcome) in outcomes.iter().enumerate() {
if outcome.cancelled {
assert!(
outcome.success,
"cancelled outcome #{idx} should have success=true, got {:?}",
outcome
);
} else {
had_failure = true;
assert!(
!outcome.success,
"non-cancelled outcome #{idx} unexpectedly passed: captured = {:?}",
outcome.captured_output
);
let captured = outcome
.captured_output
.as_deref()
.unwrap_or_else(|| panic!("outcome #{idx} has no captured output"));
assert!(
!captured.is_empty(),
"outcome #{idx} captured an empty buffer",
);
}
}
assert!(
had_failure,
"expected at least one outcome to be a real hook failure, got all-cancelled",
);
}
#[test]
fn run_for_updates_parallel_progress_callback_fires_per_update() {
use std::sync::Mutex;
let repo = TestRepo::new();
repo.write_pre_commit_config(PRE_PUSH_PASSING);
let (main, b1, b2, b3) = build_three_stack(&repo);
let updates = updates_for_three_stack(&main, &b1, &b2, &b3);
let jj = JjCli::new(repo.primary().to_path_buf());
let primary_git_dir = primary_git_dir(repo.primary()).unwrap();
let opts = RunOpts {
retry_after_fixup: true,
all_files: false,
capture_output: true,
};
let progress_fired: Mutex<Vec<(usize, String, bool)>> = Mutex::new(Vec::new());
let outcomes = run_for_updates_parallel(
&jj,
&primary_git_dir,
repo.primary(),
Some(Runner::PreCommit),
Stage::PrePush,
&updates,
opts,
|_idx, _update| {},
|idx, update, outcome| {
progress_fired
.lock()
.unwrap()
.push((idx, update.bookmark.clone(), outcome.success));
},
)
.unwrap();
let mut fired = progress_fired.into_inner().unwrap();
fired.sort_by_key(|(idx, _, _)| *idx);
assert_eq!(
fired.len(),
3,
"expected 3 progress callbacks, got {fired:?}"
);
assert_eq!(fired[0], (0, "b1".into(), true));
assert_eq!(fired[1], (1, "b2".into(), true));
assert_eq!(fired[2], (2, "b3".into(), true));
assert!(outcomes.iter().all(|o| o.success));
}
#[test]
fn run_for_updates_sequential_matches_parallel_results() {
let repo = TestRepo::new();
repo.write_pre_commit_config(PRE_PUSH_PASSING);
let (main, b1, b2, b3) = build_three_stack(&repo);
let updates = updates_for_three_stack(&main, &b1, &b2, &b3);
let jj = JjCli::new(repo.primary().to_path_buf());
let primary_git_dir = primary_git_dir(repo.primary()).unwrap();
let opts = RunOpts {
retry_after_fixup: true,
all_files: false,
capture_output: true,
};
let outcomes = run_for_updates_sequential(
&jj,
&primary_git_dir,
repo.primary(),
Some(Runner::PreCommit),
Stage::PrePush,
&updates,
opts,
|_idx, _update, _outcome| {},
)
.unwrap();
assert_eq!(outcomes.len(), 3);
for (idx, outcome) in outcomes.iter().enumerate() {
assert!(
outcome.success,
"outcome #{idx} failed: captured = {:?}",
outcome.captured_output
);
assert!(
outcome.captured_output.is_some(),
"sequential with capture_output=true should still capture, got None for #{idx}",
);
}
}
#[test]
#[should_panic(expected = "run_for_updates_parallel requires capture_output=true")]
fn run_for_updates_parallel_without_capture_panics() {
let repo = TestRepo::new();
repo.write_pre_commit_config(PRE_PUSH_PASSING);
let (main, b1, b2, b3) = build_three_stack(&repo);
let updates = updates_for_three_stack(&main, &b1, &b2, &b3);
let jj = JjCli::new(repo.primary().to_path_buf());
let primary_git_dir = primary_git_dir(repo.primary()).unwrap();
let opts = RunOpts {
retry_after_fixup: true,
all_files: false,
capture_output: false,
};
let _ = run_for_updates_parallel(
&jj,
&primary_git_dir,
repo.primary(),
Some(Runner::PreCommit),
Stage::PrePush,
&updates,
opts,
|_idx, _update| {},
|_idx, _update, _outcome| {},
);
}
#[test]
fn cancel_token_round_trip() {
let c = Cancel::new();
assert!(!c.is_cancelled());
c.cancel();
assert!(c.is_cancelled());
let c2 = Cancel::new();
let c2_clone = c2.clone();
assert!(!c2.is_cancelled());
c2_clone.cancel();
assert!(c2.is_cancelled());
let n = Cancel::never();
assert!(!n.is_cancelled());
}
#[test]
fn run_for_partitioned_updates_parallel_two_independent_stacks() {
let repo = TestRepo::new();
repo.write_pre_commit_config(PRE_PUSH_PASSING);
let (main, b1, b2, b3) = build_three_stack(&repo);
let p1: Vec<BookmarkUpdate> = vec![BookmarkUpdate {
remote: "origin".into(),
bookmark: "b1".into(),
update_type: UpdateType::Add,
old_commit: Some(main.clone()),
new_commit: Some(b1.clone()),
}];
let p2: Vec<BookmarkUpdate> = vec![
BookmarkUpdate {
remote: "origin".into(),
bookmark: "b2".into(),
update_type: UpdateType::Add,
old_commit: Some(b1.clone()),
new_commit: Some(b2.clone()),
},
BookmarkUpdate {
remote: "origin".into(),
bookmark: "b3".into(),
update_type: UpdateType::Add,
old_commit: Some(b2.clone()),
new_commit: Some(b3.clone()),
},
];
let partitions = vec![p1, p2];
let jj = JjCli::new(repo.primary().to_path_buf());
let primary_git_dir = primary_git_dir(repo.primary()).unwrap();
let opts = RunOpts {
retry_after_fixup: true,
all_files: false,
capture_output: true,
};
let outcomes = run_for_partitioned_updates_parallel(
&jj,
&primary_git_dir,
repo.primary(),
Some(Runner::PreCommit),
Stage::PrePush,
&partitions,
opts,
|_p, _u, _update| {},
|_p, _u, _update, _outcome| {},
)
.unwrap();
assert_eq!(outcomes.len(), 2, "expected 2 partitions, got {outcomes:?}");
assert_eq!(outcomes[0].len(), 1);
assert_eq!(outcomes[1].len(), 2);
for partition_outcomes in &outcomes {
for o in partition_outcomes {
assert!(o.success, "expected pass, got {o:?}");
assert!(!o.cancelled, "no failures means no cancellation");
}
}
}
#[test]
fn run_for_partitioned_updates_parallel_failure_in_one_stack_does_not_cancel_the_other() {
let repo = TestRepo::new();
repo.write_pre_commit_config(PRE_PUSH_FAILING);
let (main, b1, b2, b3) = build_three_stack(&repo);
let p1: Vec<BookmarkUpdate> = vec![BookmarkUpdate {
remote: "origin".into(),
bookmark: "b1".into(),
update_type: UpdateType::Add,
old_commit: Some(main.clone()),
new_commit: Some(b1.clone()),
}];
let p2: Vec<BookmarkUpdate> = vec![BookmarkUpdate {
remote: "origin".into(),
bookmark: "b2".into(),
update_type: UpdateType::Add,
old_commit: Some(b1.clone()),
new_commit: Some(b2.clone()),
}];
let p3: Vec<BookmarkUpdate> = vec![BookmarkUpdate {
remote: "origin".into(),
bookmark: "b3".into(),
update_type: UpdateType::Add,
old_commit: Some(b2.clone()),
new_commit: Some(b3.clone()),
}];
let partitions = vec![p1, p2, p3];
let jj = JjCli::new(repo.primary().to_path_buf());
let primary_git_dir = primary_git_dir(repo.primary()).unwrap();
let opts = RunOpts {
retry_after_fixup: false,
all_files: false,
capture_output: true,
};
let outcomes = run_for_partitioned_updates_parallel(
&jj,
&primary_git_dir,
repo.primary(),
Some(Runner::PreCommit),
Stage::PrePush,
&partitions,
opts,
|_p, _u, _update| {},
|_p, _u, _update, _outcome| {},
)
.unwrap();
assert_eq!(outcomes.len(), 3);
for (p_idx, partition_outcomes) in outcomes.iter().enumerate() {
assert_eq!(partition_outcomes.len(), 1);
let o = &partition_outcomes[0];
assert!(
!o.cancelled,
"partition {p_idx} outcome was cancelled — cancellation leaked across partition boundaries: {o:?}",
);
assert!(
!o.success,
"partition {p_idx} outcome unexpectedly passed: {o:?}",
);
}
}