mod common;
use common::{MockPlatformService, TempJjRepo, github_config, make_pr, make_pr_draft};
use jj_ryu::graph::build_change_graph;
use jj_ryu::submit::{ExecutionStep, analyze_submission, create_submission_plan};
fn find_step_index(
steps: &[ExecutionStep],
predicate: impl Fn(&ExecutionStep) -> bool,
) -> Option<usize> {
steps.iter().position(predicate)
}
fn assert_step_order(steps: &[ExecutionStep], description: &str, idx_a: usize, idx_b: usize) {
assert!(
idx_a < idx_b,
"{description}: expected step at index {idx_a} before step at index {idx_b}, \
but got order {:?}",
steps.iter().map(ToString::to_string).collect::<Vec<_>>()
);
}
#[tokio::test]
async fn test_swap_scenario_retarget_before_push() {
let repo = TempJjRepo::new();
repo.build_stack(&[("feat-a", "Add A"), ("feat-b", "Add B")]);
repo.rebase_before("feat-b", "feat-a");
repo.edit("feat-a");
let workspace = repo.workspace();
let graph = build_change_graph(&workspace).expect("build graph");
let analysis = analyze_submission(&graph, Some("feat-a")).expect("analyze");
assert_eq!(analysis.segments.len(), 2);
assert_eq!(analysis.segments[0].bookmark.name, "feat-b"); assert_eq!(analysis.segments[1].bookmark.name, "feat-a");
let mock = MockPlatformService::with_config(github_config());
mock.set_find_pr_response("feat-a", Some(make_pr(1, "feat-a", "main"))); mock.set_find_pr_response("feat-b", Some(make_pr(2, "feat-b", "feat-a")));
let plan = create_submission_plan(&analysis, &mock, "origin", "main")
.await
.expect("create plan");
assert!(
plan.count_updates() >= 1,
"should have base updates for swapped PRs"
);
assert!(
plan.count_pushes() >= 1,
"should have pushes for changed branches"
);
let steps = &plan.execution_steps;
let update_b_idx = find_step_index(
steps,
|s| matches!(s, ExecutionStep::UpdateBase(u) if u.bookmark.name == "feat-b"),
);
let push_a_idx = find_step_index(
steps,
|s| matches!(s, ExecutionStep::Push(b) if b.name == "feat-a"),
);
if let (Some(update_idx), Some(push_idx)) = (update_b_idx, push_a_idx) {
assert_step_order(
steps,
"UpdateBase(B) must come before Push(A) in swap scenario",
update_idx,
push_idx,
);
}
}
#[tokio::test]
async fn test_three_level_swap_middle_to_root() {
let repo = TempJjRepo::new();
repo.build_stack(&[
("feat-a", "Add A"),
("feat-b", "Add B"),
("feat-c", "Add C"),
]);
repo.rebase_before("feat-b", "feat-a");
let workspace = repo.workspace();
let graph = build_change_graph(&workspace).expect("build graph");
let analysis = analyze_submission(&graph, Some("feat-c")).expect("analyze");
let mock = MockPlatformService::with_config(github_config());
mock.set_find_pr_response("feat-a", Some(make_pr(1, "feat-a", "main")));
mock.set_find_pr_response("feat-b", Some(make_pr(2, "feat-b", "feat-a")));
mock.set_find_pr_response("feat-c", Some(make_pr(3, "feat-c", "feat-b")));
let plan = create_submission_plan(&analysis, &mock, "origin", "main")
.await
.expect("create plan");
assert!(!plan.is_empty(), "plan should have operations after swap");
}
#[tokio::test]
async fn test_push_order_follows_stack_structure() {
let repo = TempJjRepo::new();
repo.build_stack(&[
("feat-a", "Add A"),
("feat-b", "Add B"),
("feat-c", "Add C"),
("feat-d", "Add D"),
]);
let workspace = repo.workspace();
let graph = build_change_graph(&workspace).expect("build graph");
let analysis = analyze_submission(&graph, Some("feat-d")).expect("analyze");
let mock = MockPlatformService::with_config(github_config());
let plan = create_submission_plan(&analysis, &mock, "origin", "main")
.await
.expect("create plan");
assert_eq!(plan.count_pushes(), 4, "all 4 bookmarks need push");
let steps = &plan.execution_steps;
let push_a = find_step_index(
steps,
|s| matches!(s, ExecutionStep::Push(b) if b.name == "feat-a"),
)
.unwrap();
let push_b = find_step_index(
steps,
|s| matches!(s, ExecutionStep::Push(b) if b.name == "feat-b"),
)
.unwrap();
let push_c = find_step_index(
steps,
|s| matches!(s, ExecutionStep::Push(b) if b.name == "feat-c"),
)
.unwrap();
let push_d = find_step_index(
steps,
|s| matches!(s, ExecutionStep::Push(b) if b.name == "feat-d"),
)
.unwrap();
assert_step_order(steps, "Push(A) before Push(B)", push_a, push_b);
assert_step_order(steps, "Push(B) before Push(C)", push_b, push_c);
assert_step_order(steps, "Push(C) before Push(D)", push_c, push_d);
}
#[tokio::test]
async fn test_create_order_respects_stack_for_comment_linking() {
let repo = TempJjRepo::new();
repo.build_stack(&[
("feat-a", "Add A"),
("feat-b", "Add B"),
("feat-c", "Add C"),
]);
let workspace = repo.workspace();
let graph = build_change_graph(&workspace).expect("build graph");
let analysis = analyze_submission(&graph, Some("feat-c")).expect("analyze");
let mock = MockPlatformService::with_config(github_config());
let plan = create_submission_plan(&analysis, &mock, "origin", "main")
.await
.expect("create plan");
assert_eq!(plan.count_creates(), 3);
let steps = &plan.execution_steps;
let create_a = find_step_index(
steps,
|s| matches!(s, ExecutionStep::CreatePr(c) if c.bookmark.name == "feat-a"),
)
.unwrap();
let create_b = find_step_index(
steps,
|s| matches!(s, ExecutionStep::CreatePr(c) if c.bookmark.name == "feat-b"),
)
.unwrap();
let create_c = find_step_index(
steps,
|s| matches!(s, ExecutionStep::CreatePr(c) if c.bookmark.name == "feat-c"),
)
.unwrap();
assert_step_order(steps, "CreatePr(A) before CreatePr(B)", create_a, create_b);
assert_step_order(steps, "CreatePr(B) before CreatePr(C)", create_b, create_c);
}
#[tokio::test]
async fn test_push_before_create_constraint() {
let repo = TempJjRepo::new();
repo.build_stack(&[("feat-a", "Add A")]);
let workspace = repo.workspace();
let graph = build_change_graph(&workspace).expect("build graph");
let analysis = analyze_submission(&graph, Some("feat-a")).expect("analyze");
let mock = MockPlatformService::with_config(github_config());
let plan = create_submission_plan(&analysis, &mock, "origin", "main")
.await
.expect("create plan");
assert_eq!(plan.count_pushes(), 1);
assert_eq!(plan.count_creates(), 1);
let steps = &plan.execution_steps;
let push_a = find_step_index(
steps,
|s| matches!(s, ExecutionStep::Push(b) if b.name == "feat-a"),
)
.unwrap();
let create_a = find_step_index(
steps,
|s| matches!(s, ExecutionStep::CreatePr(c) if c.bookmark.name == "feat-a"),
)
.unwrap();
assert_step_order(steps, "Push(A) before CreatePr(A)", push_a, create_a);
}
#[tokio::test]
async fn test_push_before_retarget_constraint() {
let repo = TempJjRepo::new();
repo.build_stack(&[("feat-a", "Add A"), ("feat-b", "Add B")]);
let workspace = repo.workspace();
let graph = build_change_graph(&workspace).expect("build graph");
let analysis = analyze_submission(&graph, Some("feat-b")).expect("analyze");
let mock = MockPlatformService::with_config(github_config());
mock.set_find_pr_response("feat-b", Some(make_pr(2, "feat-b", "main")));
let plan = create_submission_plan(&analysis, &mock, "origin", "main")
.await
.expect("create plan");
let steps = &plan.execution_steps;
let push_a = find_step_index(
steps,
|s| matches!(s, ExecutionStep::Push(b) if b.name == "feat-a"),
);
let update_b = find_step_index(
steps,
|s| matches!(s, ExecutionStep::UpdateBase(u) if u.bookmark.name == "feat-b"),
);
if let (Some(push_idx), Some(update_idx)) = (push_a, update_b) {
assert_step_order(
steps,
"Push(A) before UpdateBase(B) - can't retarget to unpushed branch",
push_idx,
update_idx,
);
}
}
#[tokio::test]
async fn test_partial_existing_prs_mixed_operations() {
let repo = TempJjRepo::new();
repo.build_stack(&[
("feat-a", "Add A"),
("feat-b", "Add B"),
("feat-c", "Add C"),
]);
let workspace = repo.workspace();
let graph = build_change_graph(&workspace).expect("build graph");
let analysis = analyze_submission(&graph, Some("feat-c")).expect("analyze");
let mock = MockPlatformService::with_config(github_config());
mock.set_find_pr_response("feat-a", Some(make_pr(1, "feat-a", "main")));
mock.set_find_pr_response("feat-b", Some(make_pr(2, "feat-b", "main")));
let plan = create_submission_plan(&analysis, &mock, "origin", "main")
.await
.expect("create plan");
assert_eq!(plan.count_updates(), 1, "B needs base update");
assert_eq!(plan.count_creates(), 1, "C needs PR creation");
let steps = &plan.execution_steps;
let push_a = find_step_index(
steps,
|s| matches!(s, ExecutionStep::Push(b) if b.name == "feat-a"),
);
let update_b = find_step_index(
steps,
|s| matches!(s, ExecutionStep::UpdateBase(u) if u.bookmark.name == "feat-b"),
);
if let (Some(push_idx), Some(update_idx)) = (push_a, update_b) {
assert_step_order(steps, "Push(A) before UpdateBase(B)", push_idx, update_idx);
}
let push_c = find_step_index(
steps,
|s| matches!(s, ExecutionStep::Push(b) if b.name == "feat-c"),
);
let create_c = find_step_index(
steps,
|s| matches!(s, ExecutionStep::CreatePr(c) if c.bookmark.name == "feat-c"),
);
if let (Some(push_idx), Some(create_idx)) = (push_c, create_c) {
assert_step_order(steps, "Push(C) before CreatePr(C)", push_idx, create_idx);
}
}
#[tokio::test]
async fn test_draft_pr_in_stack() {
let repo = TempJjRepo::new();
repo.build_stack(&[("feat-a", "Add A"), ("feat-b", "Add B")]);
let workspace = repo.workspace();
let graph = build_change_graph(&workspace).expect("build graph");
let analysis = analyze_submission(&graph, Some("feat-b")).expect("analyze");
let mock = MockPlatformService::with_config(github_config());
mock.set_find_pr_response("feat-a", Some(make_pr_draft(1, "feat-a", "main")));
let plan = create_submission_plan(&analysis, &mock, "origin", "main")
.await
.expect("create plan");
assert_eq!(plan.existing_prs.len(), 1);
assert!(plan.existing_prs.get("feat-a").unwrap().is_draft);
}
#[tokio::test]
async fn test_constraints_skip_synced_bookmarks() {
let repo = TempJjRepo::new();
repo.build_stack(&[("feat-a", "Add A"), ("feat-b", "Add B")]);
let workspace = repo.workspace();
let graph = build_change_graph(&workspace).expect("build graph");
let analysis = analyze_submission(&graph, Some("feat-b")).expect("analyze");
let mock = MockPlatformService::with_config(github_config());
mock.set_find_pr_response("feat-a", Some(make_pr(1, "feat-a", "main")));
mock.set_find_pr_response("feat-b", Some(make_pr(2, "feat-b", "feat-a")));
let plan = create_submission_plan(&analysis, &mock, "origin", "main")
.await
.expect("create plan");
assert_eq!(plan.count_creates(), 0);
assert_eq!(plan.count_updates(), 0);
}
#[tokio::test]
async fn test_all_prs_exist_correct_bases() {
let repo = TempJjRepo::new();
repo.build_stack(&[
("feat-a", "Add A"),
("feat-b", "Add B"),
("feat-c", "Add C"),
]);
let workspace = repo.workspace();
let graph = build_change_graph(&workspace).expect("build graph");
let analysis = analyze_submission(&graph, Some("feat-c")).expect("analyze");
let mock = MockPlatformService::with_config(github_config());
mock.set_find_pr_response("feat-a", Some(make_pr(1, "feat-a", "main")));
mock.set_find_pr_response("feat-b", Some(make_pr(2, "feat-b", "feat-a")));
mock.set_find_pr_response("feat-c", Some(make_pr(3, "feat-c", "feat-b")));
let plan = create_submission_plan(&analysis, &mock, "origin", "main")
.await
.expect("create plan");
assert_eq!(plan.count_creates(), 0, "no PRs to create");
assert_eq!(plan.count_updates(), 0, "no bases to update");
assert_eq!(plan.existing_prs.len(), 3, "all PRs tracked");
}
#[tokio::test]
async fn test_ten_level_stack_ordering() {
let repo = TempJjRepo::new();
let bookmarks: Vec<(&str, &str)> = (0..10)
.map(|i| {
let name = Box::leak(format!("feat-{i}").into_boxed_str());
let msg = Box::leak(format!("Add feature {i}").into_boxed_str());
(name as &str, msg as &str)
})
.collect();
repo.build_stack(&bookmarks);
let workspace = repo.workspace();
let graph = build_change_graph(&workspace).expect("build graph");
let analysis = analyze_submission(&graph, Some("feat-9")).expect("analyze");
assert_eq!(analysis.segments.len(), 10);
let mock = MockPlatformService::with_config(github_config());
let plan = create_submission_plan(&analysis, &mock, "origin", "main")
.await
.expect("create plan");
assert_eq!(plan.count_pushes(), 10);
assert_eq!(plan.count_creates(), 10);
let steps = &plan.execution_steps;
let mut prev_push_idx = None;
for i in 0..10 {
let name = format!("feat-{i}");
let push_idx = find_step_index(
steps,
|s| matches!(s, ExecutionStep::Push(b) if b.name == name),
)
.unwrap_or_else(|| panic!("Push for {name} not found"));
if let Some(prev) = prev_push_idx {
assert!(
prev < push_idx,
"Push(feat-{}) at {prev} should come before Push({name}) at {push_idx}",
i - 1
);
}
prev_push_idx = Some(push_idx);
}
let mut prev_create_idx = None;
for i in 0..10 {
let name = format!("feat-{i}");
let create_idx = find_step_index(
steps,
|s| matches!(s, ExecutionStep::CreatePr(c) if c.bookmark.name == name),
)
.unwrap_or_else(|| panic!("CreatePr for {name} not found"));
if let Some(prev) = prev_create_idx {
assert!(
prev < create_idx,
"CreatePr(feat-{}) at {prev} should come before CreatePr({name}) at {create_idx}",
i - 1
);
}
prev_create_idx = Some(create_idx);
}
}
#[tokio::test]
async fn test_constraint_display_formatting() {
let repo = TempJjRepo::new();
repo.build_stack(&[("feat-a", "Add A"), ("feat-b", "Add B")]);
let workspace = repo.workspace();
let graph = build_change_graph(&workspace).expect("build graph");
let analysis = analyze_submission(&graph, Some("feat-b")).expect("analyze");
let mock = MockPlatformService::with_config(github_config());
mock.set_find_pr_response("feat-b", Some(make_pr(2, "feat-b", "main")));
let plan = create_submission_plan(&analysis, &mock, "origin", "main")
.await
.expect("create plan");
assert!(!plan.constraints.is_empty(), "should have constraints");
for constraint in &plan.constraints {
let display = format!("{constraint}");
assert!(
!display.is_empty(),
"constraint display should not be empty"
);
assert!(
display.contains("→"),
"constraint display should contain arrow: {display}"
);
}
}
#[test]
fn test_scheduler_cycle_error_format() {
use jj_ryu::error::Error;
let error = Error::SchedulerCycle {
message: "test cycle".to_string(),
cycle_nodes: vec!["push feat-a".to_string(), "update feat-b".to_string()],
};
let display = format!("{error}");
assert!(display.contains("scheduler cycle detected"));
assert!(display.contains("test cycle"));
match error {
Error::SchedulerCycle { cycle_nodes, .. } => {
assert_eq!(cycle_nodes.len(), 2);
assert!(cycle_nodes.contains(&"push feat-a".to_string()));
}
_ => panic!("wrong error variant"),
}
}