use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use crate::forge::types::PullRequest;
use crate::forge::{Forge, ForgeKind};
use crate::jj::types::NarrowedSegment;
use crate::jj::Jj;
use super::execute::{
format_block_reason, merge_with_retry, reconcile_after_merge, BlockedPr, LocalDivergenceWarning,
MergeResult, MergedPr, SkippedMergedPr,
};
use super::plan::{evaluate_segment, BlockReason, MergePlan, PrMergeStatus};
pub(crate) const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5 * 60);
pub(crate) const MAX_CONSECUTIVE_ERRORS: u32 = 10;
pub(crate) fn interruptible_sleep(duration: Duration, shutdown: &AtomicBool) -> bool {
let end = Instant::now() + duration;
while Instant::now() < end {
if shutdown.load(Ordering::Relaxed) {
return true;
}
thread::sleep(Duration::from_millis(500));
}
false
}
pub(crate) fn format_resolved_reason(reason: &BlockReason) -> &'static str {
match reason {
BlockReason::ChecksPending | BlockReason::ChecksFailing => "CI now passing",
BlockReason::Draft => "No longer a draft",
BlockReason::InsufficientApprovals { .. } => "Approval received",
BlockReason::ChangesRequested => "Changes-requested resolved",
BlockReason::Conflicted => "Conflicts resolved",
BlockReason::MergeabilityUnknown => "Mergeability computed",
BlockReason::NoPr => "PR now exists",
}
}
pub(crate) fn report_status_changes(
bookmark: &str,
prev: Option<&[BlockReason]>,
current: &[BlockReason],
fk: ForgeKind,
) -> bool {
let Some(prev) = prev else {
println!("\n Waiting for '{bookmark}':");
for reason in current {
println!(" - {}", format_block_reason(reason, fk));
}
return true;
};
if prev == current {
return false;
}
let mut printed = false;
for old in prev {
if !current.iter().any(|c| std::mem::discriminant(c) == std::mem::discriminant(old)) {
println!(" {bookmark}: {}", format_resolved_reason(old));
printed = true;
}
}
for new in current {
if !prev.iter().any(|p| std::mem::discriminant(p) == std::mem::discriminant(new)) {
println!(" {bookmark}: {}", format_block_reason(new, fk));
printed = true;
}
}
for new in current {
if let BlockReason::InsufficientApprovals { have: new_have, need } = new {
for old in prev {
if let BlockReason::InsufficientApprovals { have: old_have, .. } = old
&& new_have != old_have
{
println!(" {bookmark}: Approvals now {new_have}/{need}");
printed = true;
}
}
}
}
printed
}
pub(crate) fn refresh_pr_map(
forge: &dyn Forge,
owner: &str,
repo: &str,
) -> Result<HashMap<String, PullRequest>> {
let fresh_prs = forge.list_open_prs(owner, repo)?;
Ok(crate::forge::build_pr_map(fresh_prs, owner))
}
pub struct WatchOptions {
pub shutdown: Arc<AtomicBool>,
pub timeout: Option<Duration>,
pub poll_interval: Duration,
}
pub fn execute_merge_plan_watch(
jj: &dyn Jj,
forge: &dyn Forge,
plan: &MergePlan,
segments: &[NarrowedSegment],
opts: WatchOptions,
) -> Result<MergeResult> {
let shutdown = opts.shutdown;
let timeout = opts.timeout;
let poll_interval = opts.poll_interval;
let owner = &plan.repo_info.owner;
let repo = &plan.repo_info.repo;
let fk = plan.forge_kind;
let mut merged = Vec::new();
let mut blocked_at = None;
let mut skipped_merged = Vec::new();
let mut local_warnings: Vec<LocalDivergenceWarning> = Vec::new();
let mut local_degraded = false;
let mut pr_map = refresh_pr_map(forge, owner, repo)?;
let mut seg_idx = 0;
let mut prev_reasons: Option<Vec<BlockReason>> = None;
let mut consecutive_errors: u32 = 0;
let mut last_heartbeat = Instant::now();
let deadline = timeout.map(|d| Instant::now() + d);
while seg_idx < segments.len() {
if shutdown.load(Ordering::Relaxed) {
break;
}
if let Some(dl) = deadline
&& Instant::now() >= dl
{
println!("\nWatch timed out.");
break;
}
let segment = &segments[seg_idx];
let status = match evaluate_segment(
forge,
&segment.bookmark.name,
&plan.repo_info,
&pr_map,
&plan.options,
) {
Ok(s) => {
consecutive_errors = 0;
s
}
Err(e) => {
consecutive_errors += 1;
let now = local_time_hhmm();
eprintln!(" [{now}] Poll error ({consecutive_errors}/{MAX_CONSECUTIVE_ERRORS}): {e}");
if consecutive_errors >= MAX_CONSECUTIVE_ERRORS {
eprintln!(" Too many consecutive errors \u{2014} giving up.");
break;
}
if interruptible_sleep(poll_interval, &shutdown) {
println!("\nInterrupted.");
break;
}
if let Ok(fresh) = refresh_pr_map(forge, owner, repo) {
pr_map = fresh;
}
continue;
}
};
let prev_seg_idx = seg_idx;
match status {
PrMergeStatus::AlreadyMerged {
bookmark_name,
pr_number,
} => {
if prev_reasons.is_some() {
println!(" {bookmark_name}: Merged externally ({}) \u{2014} moving on",
fk.format_ref(pr_number));
} else {
println!(" '{bookmark_name}' ({}) already merged",
fk.format_ref(pr_number));
}
skipped_merged.push(SkippedMergedPr {
bookmark_name,
pr_number,
});
prev_reasons = None;
seg_idx += 1;
}
PrMergeStatus::Mergeable { bookmark_name, pr } => {
if prev_reasons.is_some() {
println!(" {bookmark_name}: Ready to merge");
}
println!(
"\n Merging '{bookmark_name}' ({}, {})...",
fk.format_ref(pr.number),
plan.options.merge_method
);
println!(" {}", pr.html_url);
merge_with_retry(
forge, owner, repo, pr.number, plan.options.merge_method, fk,
)
.with_context(|| {
format!(
"failed to merge {} for '{bookmark_name}'",
fk.format_ref(pr.number)
)
})?;
merged.push(MergedPr {
bookmark_name,
pr_number: pr.number,
html_url: pr.html_url.clone(),
});
prev_reasons = None;
seg_idx += 1;
}
PrMergeStatus::Blocked {
bookmark_name,
pr: _,
reasons,
} => {
if reasons.iter().any(|r| matches!(r, BlockReason::NoPr)) {
println!("\n Blocked at '{bookmark_name}':");
println!(" - No PR exists for this bookmark");
blocked_at = Some(BlockedPr {
bookmark_name,
pr_number: None,
reasons,
});
break;
}
let changed = report_status_changes(
&bookmark_name,
prev_reasons.as_deref(),
&reasons,
fk,
);
if !changed && last_heartbeat.elapsed() >= HEARTBEAT_INTERVAL {
let now = local_time_hhmm();
let first_reason = reasons
.first()
.map(|r| format_block_reason(r, fk))
.unwrap_or_default();
println!(" [{now}] Still waiting for {bookmark_name}: {first_reason}");
last_heartbeat = Instant::now();
}
if changed {
last_heartbeat = Instant::now();
}
prev_reasons = Some(reasons);
if interruptible_sleep(poll_interval, &shutdown) {
break;
}
if let Ok(fresh) = refresh_pr_map(forge, owner, repo) {
pr_map = fresh;
}
}
}
if seg_idx > prev_seg_idx && seg_idx < segments.len() {
let fresh = reconcile_after_merge(
jj, forge, segments, prev_seg_idx, plan, fk,
&mut local_degraded, &mut local_warnings,
);
if let Some(fresh_map) = fresh {
pr_map = fresh_map;
}
}
}
Ok(MergeResult {
merged,
blocked_at,
skipped_merged,
local_warnings,
})
}
pub(crate) fn local_time_hhmm() -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as libc::time_t)
.unwrap_or(0);
let mut tm: libc::tm = unsafe { std::mem::zeroed() };
#[cfg(unix)]
unsafe { libc::localtime_r(&secs, &mut tm) };
#[cfg(windows)]
unsafe { libc::localtime_s(&mut tm, &secs) };
format!("{:02}:{:02}", tm.tm_hour, tm.tm_min)
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use super::*;
use crate::forge::types::{
ChecksStatus, MergeMethod, PrMergeability, PullRequest, PullRequestRef, RepoInfo,
ReviewSummary,
};
use crate::forge::{Forge, ForgeKind};
use crate::jj::types::{Bookmark, LogEntry, NarrowedSegment};
use crate::jj::Jj;
use crate::merge::plan::MergeOptions;
use anyhow::Result;
struct StubJj;
impl Jj for StubJj {
fn git_fetch(&self) -> Result<()> { Ok(()) }
fn get_my_bookmarks(&self) -> Result<Vec<Bookmark>> { Ok(vec![]) }
fn get_changes_to_commit(&self, _to: &str) -> Result<Vec<LogEntry>> { Ok(vec![]) }
fn get_git_remotes(&self) -> Result<Vec<crate::jj::types::GitRemote>> { Ok(vec![]) }
fn get_default_branch(&self) -> Result<String> { Ok("main".to_string()) }
fn push_bookmark(&self, _name: &str, _remote: &str) -> Result<()> { Ok(()) }
fn get_working_copy_commit_id(&self) -> Result<String> { Ok("wc".to_string()) }
fn rebase_onto(&self, _source: &str, _dest: &str) -> Result<()> { Ok(()) }
fn merge_into(&self, _bookmark: &str, _dest: &str) -> Result<()> { Ok(()) }
fn resolve_change_id(&self, _change_id: &str) -> Result<Vec<String>> {
Ok(vec!["dummy".to_string()])
}
fn is_conflicted(&self, _revset: &str) -> Result<bool> { Ok(false) }
}
struct ScriptedForge {
eval_sequence: Mutex<Vec<EvalResult>>,
open_prs: Mutex<Vec<PullRequest>>,
merge_calls: Mutex<Vec<u64>>,
merged_prs: Mutex<Vec<(String, PullRequest)>>,
}
enum EvalResult {
Mergeable,
Blocked(Vec<BlockReason>),
}
impl ScriptedForge {
fn new(sequence: Vec<EvalResult>) -> Self {
Self {
eval_sequence: Mutex::new(sequence),
open_prs: Mutex::new(Vec::new()),
merge_calls: Mutex::new(Vec::new()),
merged_prs: Mutex::new(Vec::new()),
}
}
fn with_prs(self, prs: Vec<PullRequest>) -> Self {
*self.open_prs.lock().expect("poisoned") = prs;
self
}
fn merge_calls(&self) -> Vec<u64> {
self.merge_calls.lock().expect("poisoned").clone()
}
}
impl Forge for ScriptedForge {
fn list_open_prs(&self, _owner: &str, _repo: &str) -> Result<Vec<PullRequest>> {
Ok(self.open_prs.lock().expect("poisoned").clone())
}
fn get_pr_mergeability(
&self,
_owner: &str,
_repo: &str,
_number: u64,
) -> Result<PrMergeability> {
let mut seq = self.eval_sequence.lock().expect("poisoned");
let result = if seq.is_empty() {
return Ok(PrMergeability { mergeable: Some(true), mergeable_state: "clean".to_string() });
} else {
seq.remove(0)
};
match result {
EvalResult::Mergeable => Ok(PrMergeability { mergeable: Some(true), mergeable_state: "clean".to_string() }),
EvalResult::Blocked(reasons) => {
if reasons.iter().any(|r| matches!(r, BlockReason::Conflicted)) {
Ok(PrMergeability { mergeable: Some(false), mergeable_state: "dirty".to_string() })
} else if reasons
.iter()
.any(|r| matches!(r, BlockReason::MergeabilityUnknown))
{
Ok(PrMergeability { mergeable: None, mergeable_state: "unknown".to_string() })
} else {
Ok(PrMergeability { mergeable: Some(true), mergeable_state: "clean".to_string() })
}
}
}
}
fn get_pr_checks_status(
&self,
_owner: &str,
_repo: &str,
_ref_name: &str,
) -> Result<ChecksStatus> {
Ok(ChecksStatus::Pass)
}
fn get_pr_reviews(
&self,
_owner: &str,
_repo: &str,
_number: u64,
) -> Result<ReviewSummary> {
Ok(ReviewSummary {
approved_count: 1,
changes_requested: false,
})
}
fn merge_pr(
&self,
_owner: &str,
_repo: &str,
number: u64,
_method: MergeMethod,
) -> Result<()> {
self.merge_calls.lock().expect("poisoned").push(number);
Ok(())
}
fn create_pr(
&self, _o: &str, _r: &str, _t: &str, _body: &str, _h: &str, _b: &str, _d: bool,
) -> Result<PullRequest> {
unimplemented!()
}
fn update_pr_base(&self, _o: &str, _r: &str, _n: u64, _b: &str) -> Result<()> { Ok(()) }
fn update_pr_body(&self, _o: &str, _r: &str, _n: u64, _b: &str) -> Result<()> { Ok(()) }
fn mark_pr_ready(&self, _o: &str, _r: &str, _n: u64) -> Result<()> { Ok(()) }
fn request_reviewers(&self, _o: &str, _r: &str, _n: u64, _r2: &[String]) -> Result<()> { Ok(()) }
fn list_comments(&self, _o: &str, _r: &str, _n: u64) -> Result<Vec<crate::forge::IssueComment>> { Ok(vec![]) }
fn create_comment(&self, _o: &str, _r: &str, _n: u64, _b: &str) -> Result<crate::forge::IssueComment> { unimplemented!() }
fn update_comment(&self, _o: &str, _r: &str, _id: u64, _b: &str) -> Result<()> { Ok(()) }
fn get_authenticated_user(&self) -> Result<String> { Ok("user".to_string()) }
fn find_merged_pr(&self, _o: &str, _r: &str, ref_name: &str) -> Result<Option<PullRequest>> {
Ok(self.merged_prs.lock().expect("poisoned")
.iter()
.find(|(name, _)| name == ref_name)
.map(|(_, pr)| pr.clone()))
}
fn get_pr_state(&self, _o: &str, _r: &str, _n: u64) -> Result<crate::forge::types::PrState> {
Ok(crate::forge::types::PrState { merged: false, state: "open".to_string() })
}
}
fn make_pr(name: &str, number: u64) -> PullRequest {
PullRequest {
number,
html_url: format!("https://github.com/o/r/pull/{number}"),
title: name.to_string(),
body: None,
base: PullRequestRef {
ref_name: "main".to_string(),
label: String::new(),
sha: String::new(),
},
head: PullRequestRef {
ref_name: name.to_string(),
label: String::new(),
sha: format!("sha_{name}"),
},
draft: false,
node_id: String::new(),
merged_at: None,
requested_reviewers: vec![],
}
}
fn make_segment(name: &str) -> NarrowedSegment {
NarrowedSegment {
bookmark: Bookmark {
name: name.to_string(),
commit_id: format!("commit_{name}"),
change_id: format!("change_{name}"),
has_remote: true,
is_synced: true,
},
changes: vec![],
merge_source_names: vec![],
}
}
fn repo_info() -> RepoInfo {
RepoInfo {
owner: "o".to_string(),
repo: "r".to_string(),
}
}
fn default_options() -> MergeOptions {
MergeOptions {
merge_method: MergeMethod::Squash,
required_approvals: 1,
require_ci_pass: true,
reconcile_strategy: crate::config::ReconcileStrategy::Merge,
ready: false,
}
}
fn default_plan() -> MergePlan {
MergePlan {
actions: vec![],
repo_info: repo_info(),
forge_kind: ForgeKind::GitHub,
options: default_options(),
default_branch: "main".to_string(),
remote_name: "origin".to_string(),
stack_base: None,
}
}
fn test_opts() -> WatchOptions {
WatchOptions {
shutdown: Arc::new(AtomicBool::new(false)),
timeout: None,
poll_interval: Duration::ZERO,
}
}
#[test]
fn test_watch_merges_immediately_when_ready() {
let forge = ScriptedForge::new(vec![EvalResult::Mergeable])
.with_prs(vec![make_pr("auth", 1)]);
let segments = vec![make_segment("auth")];
let plan = default_plan();
let result = execute_merge_plan_watch(
&StubJj, &forge, &plan, &segments, test_opts(),
)
.unwrap();
assert_eq!(result.merged.len(), 1);
assert_eq!(result.merged[0].pr_number, 1);
assert_eq!(forge.merge_calls(), vec![1]);
}
#[test]
fn test_watch_waits_then_merges() {
let forge = ScriptedForge::new(vec![
EvalResult::Blocked(vec![BlockReason::ChecksPending]),
EvalResult::Mergeable,
])
.with_prs(vec![make_pr("auth", 1)]);
let segments = vec![make_segment("auth")];
let plan = default_plan();
let result = execute_merge_plan_watch(
&StubJj, &forge, &plan, &segments, test_opts(),
)
.unwrap();
assert_eq!(result.merged.len(), 1);
assert_eq!(forge.merge_calls(), vec![1]);
}
#[test]
fn test_watch_continues_across_segments() {
let forge = ScriptedForge::new(vec![
EvalResult::Mergeable,
EvalResult::Blocked(vec![BlockReason::ChecksPending]),
EvalResult::Mergeable,
])
.with_prs(vec![make_pr("auth", 1), make_pr("profile", 2)]);
let segments = vec![make_segment("auth"), make_segment("profile")];
let plan = default_plan();
let result = execute_merge_plan_watch(
&StubJj, &forge, &plan, &segments, test_opts(),
)
.unwrap();
assert_eq!(result.merged.len(), 2);
assert_eq!(forge.merge_calls(), vec![1, 2]);
}
#[test]
fn test_watch_stops_at_nopr() {
let forge = ScriptedForge::new(vec![])
.with_prs(vec![]);
let segments = vec![make_segment("auth")];
let plan = default_plan();
let result = execute_merge_plan_watch(
&StubJj, &forge, &plan, &segments, test_opts(),
)
.unwrap();
assert!(result.merged.is_empty());
assert!(result.blocked_at.is_some());
let blocked = result.blocked_at.unwrap();
assert!(blocked.reasons.iter().any(|r| matches!(r, BlockReason::NoPr)));
}
#[test]
fn test_watch_respects_shutdown_flag() {
let forge = ScriptedForge::new(vec![
EvalResult::Blocked(vec![BlockReason::ChecksPending]),
])
.with_prs(vec![make_pr("auth", 1)]);
let segments = vec![make_segment("auth")];
let plan = default_plan();
let result = execute_merge_plan_watch(
&StubJj, &forge, &plan, &segments,
WatchOptions {
shutdown: Arc::new(AtomicBool::new(true)),
timeout: None,
poll_interval: Duration::ZERO,
},
)
.unwrap();
assert!(result.merged.is_empty());
assert!(forge.merge_calls().is_empty());
}
#[test]
fn test_watch_respects_timeout() {
let forge = ScriptedForge::new(vec![
EvalResult::Blocked(vec![BlockReason::ChecksPending]),
])
.with_prs(vec![make_pr("auth", 1)]);
let segments = vec![make_segment("auth")];
let plan = default_plan();
let result = execute_merge_plan_watch(
&StubJj, &forge, &plan, &segments,
WatchOptions {
shutdown: Arc::new(AtomicBool::new(false)),
timeout: Some(Duration::ZERO),
poll_interval: Duration::ZERO,
},
)
.unwrap();
assert!(result.merged.is_empty());
}
#[test]
fn test_watch_gives_up_after_max_errors() {
struct FailingForge;
impl Forge for FailingForge {
fn list_open_prs(&self, _o: &str, _r: &str) -> Result<Vec<PullRequest>> {
Ok(vec![]) }
fn get_pr_mergeability(&self, _o: &str, _r: &str, _n: u64) -> Result<PrMergeability> {
anyhow::bail!("API error")
}
fn get_pr_checks_status(&self, _o: &str, _r: &str, _ref_name: &str) -> Result<ChecksStatus> {
Ok(ChecksStatus::Pass)
}
fn get_pr_reviews(&self, _o: &str, _r: &str, _n: u64) -> Result<ReviewSummary> {
Ok(ReviewSummary { approved_count: 1, changes_requested: false })
}
fn merge_pr(&self, _o: &str, _r: &str, _n: u64, _m: MergeMethod) -> Result<()> { Ok(()) }
fn create_pr(&self, _o: &str, _r: &str, _t: &str, _body: &str, _h: &str, _b: &str, _d: bool) -> Result<PullRequest> { unimplemented!() }
fn update_pr_base(&self, _o: &str, _r: &str, _n: u64, _b: &str) -> Result<()> { Ok(()) }
fn update_pr_body(&self, _o: &str, _r: &str, _n: u64, _b: &str) -> Result<()> { Ok(()) }
fn mark_pr_ready(&self, _o: &str, _r: &str, _n: u64) -> Result<()> { Ok(()) }
fn request_reviewers(&self, _o: &str, _r: &str, _n: u64, _r2: &[String]) -> Result<()> { Ok(()) }
fn list_comments(&self, _o: &str, _r: &str, _n: u64) -> Result<Vec<crate::forge::IssueComment>> { Ok(vec![]) }
fn create_comment(&self, _o: &str, _r: &str, _n: u64, _b: &str) -> Result<crate::forge::IssueComment> { unimplemented!() }
fn update_comment(&self, _o: &str, _r: &str, _id: u64, _b: &str) -> Result<()> { Ok(()) }
fn get_authenticated_user(&self) -> Result<String> { Ok("user".to_string()) }
fn find_merged_pr(&self, _o: &str, _r: &str, _ref_name: &str) -> Result<Option<PullRequest>> {
anyhow::bail!("API error")
}
fn get_pr_state(&self, _o: &str, _r: &str, _n: u64) -> Result<crate::forge::types::PrState> {
Ok(crate::forge::types::PrState { merged: false, state: "open".to_string() })
}
}
let segments = vec![make_segment("auth")];
let plan = default_plan();
let result = execute_merge_plan_watch(
&StubJj, &FailingForge, &plan, &segments, test_opts(),
)
.unwrap();
assert!(result.merged.is_empty());
}
#[test]
fn test_report_status_changes_first_time() {
let reasons = vec![BlockReason::ChecksPending, BlockReason::Draft];
let changed = report_status_changes("auth", None, &reasons, ForgeKind::GitHub);
assert!(changed);
}
#[test]
fn test_report_status_changes_no_change() {
let reasons = vec![BlockReason::ChecksPending];
let changed = report_status_changes("auth", Some(&reasons), &reasons, ForgeKind::GitHub);
assert!(!changed);
}
#[test]
fn test_report_status_changes_reason_resolved() {
let prev = vec![BlockReason::ChecksPending, BlockReason::Draft];
let current = vec![BlockReason::Draft];
let changed = report_status_changes("auth", Some(&prev), ¤t, ForgeKind::GitHub);
assert!(changed);
}
#[test]
fn test_watch_reconciles_after_already_merged() {
use std::sync::Mutex;
struct RecordingJj {
calls: Mutex<Vec<String>>,
}
impl RecordingJj {
fn new() -> Self { Self { calls: Mutex::new(Vec::new()) } }
fn calls(&self) -> Vec<String> { self.calls.lock().expect("poisoned").clone() }
}
impl Jj for RecordingJj {
fn git_fetch(&self) -> Result<()> {
self.calls.lock().expect("poisoned").push("git_fetch".to_string());
Ok(())
}
fn get_my_bookmarks(&self) -> Result<Vec<Bookmark>> { Ok(vec![]) }
fn get_changes_to_commit(&self, _to: &str) -> Result<Vec<LogEntry>> { Ok(vec![]) }
fn get_git_remotes(&self) -> Result<Vec<crate::jj::types::GitRemote>> { Ok(vec![]) }
fn get_default_branch(&self) -> Result<String> { Ok("main".to_string()) }
fn push_bookmark(&self, name: &str, _remote: &str) -> Result<()> {
self.calls.lock().expect("poisoned").push(format!("push:{name}"));
Ok(())
}
fn get_working_copy_commit_id(&self) -> Result<String> { Ok("wc".to_string()) }
fn rebase_onto(&self, _source: &str, _dest: &str) -> Result<()> { Ok(()) }
fn merge_into(&self, bookmark: &str, dest: &str) -> Result<()> {
self.calls.lock().expect("poisoned").push(format!("merge_into:{bookmark}:{dest}"));
Ok(())
}
fn resolve_change_id(&self, _change_id: &str) -> Result<Vec<String>> {
Ok(vec!["dummy".to_string()])
}
fn is_conflicted(&self, _revset: &str) -> Result<bool> { Ok(false) }
}
let forge = ScriptedForge::new(vec![EvalResult::Mergeable])
.with_prs(vec![make_pr("profile", 2)]);
*forge.merged_prs.lock().expect("poisoned") =
vec![("auth".to_string(), make_pr("auth", 1))];
let segments = vec![make_segment("auth"), make_segment("profile")];
let jj = RecordingJj::new();
let result = execute_merge_plan_watch(
&jj, &forge, &default_plan(), &segments, test_opts(),
)
.unwrap();
assert_eq!(result.skipped_merged.len(), 1);
assert_eq!(result.merged.len(), 1);
let jj_calls = jj.calls();
assert!(
jj_calls.iter().any(|c| c == "git_fetch"),
"reconcile should have run after AlreadyMerged: {jj_calls:?}"
);
}
}