mod trailers;
mod unwrap;
use std::collections::HashMap;
use std::collections::HashSet;
use std::fmt;
use miette::Diagnostic;
use thiserror::Error;
use crate::cli::submit::PrMode;
use crate::cli::submit::SyncPrContent;
use crate::cli::submit::TrailerHandling;
use crate::forge::CreatePrParams;
use crate::forge::Forge;
use crate::forge::ForgeError;
use crate::forge::PullRequest;
use crate::forge::comment::STAKK_REPO_URL;
use crate::forge::comment::StackCommentContext;
use crate::forge::comment::StackCommentData;
use crate::forge::comment::StackEntry;
use crate::forge::comment::StackEntryContext;
use crate::forge::comment::StackPlacement;
use crate::forge::comment::find_stack_comment;
use crate::forge::comment::find_stack_in_body;
use crate::forge::comment::format_stack_comment;
use crate::forge::comment::splice_stack_into_body;
use crate::forge::comment::strip_stack_from_body;
use crate::forge::comment::with_comment_preamble;
use crate::graph::types::BookmarkSegment;
use crate::graph::types::ChangeGraph;
use crate::graph::types::SegmentCommit;
use crate::jj::Jj;
use crate::jj::JjError;
use crate::jj::runner::JjRunner;
use crate::submit::trailers::split_trailers;
use crate::submit::unwrap::unwrap_markdown;
#[derive(Debug, Error, Diagnostic)]
pub enum SubmitError {
#[error("bookmark '{bookmark}' not found in any stack")]
#[diagnostic(
code(stakk::submit::bookmark_not_found),
help("run `stakk` with no arguments to see available stacks")
)]
BookmarkNotFound { bookmark: String },
#[error("segment for change {change_id} has no bookmark name")]
#[diagnostic(
code(stakk::submit::segment_missing_bookmark),
help("this is likely a bug in stakk — please report it")
)]
SegmentMissingBookmark { change_id: String },
#[error("failed to check for existing PR for '{bookmark}'")]
#[diagnostic(
code(stakk::submit::pr_lookup_failed),
help("check your network connection and GitHub token permissions")
)]
PrLookupFailed {
bookmark: String,
#[source]
source: ForgeError,
},
#[error(
"bookmark assignment '{bookmark}' targets change {change_id}, which is not on the \
selected path"
)]
#[diagnostic(
code(stakk::submit::assignment_off_path),
help("this indicates a bug in the selection layer — please report it")
)]
AssignmentOffPath { bookmark: String, change_id: String },
#[error(
"change {change_id} is divergent: it matches more than one commit on the selected path"
)]
#[diagnostic(
code(stakk::submit::divergent_change),
help(
"resolve the divergence first, e.g. `jj abandon` the copy you do not want, then re-run"
)
)]
DivergentChange { change_id: String },
#[error("failed to list local bookmarks")]
#[diagnostic(
code(stakk::submit::bookmark_list_failed),
help("check that `jj bookmark list` runs in this repo")
)]
BookmarkListFailed {
#[source]
source: JjError,
},
#[error("bookmark(s) already exist: {}", bookmarks.join(", "))]
#[diagnostic(
code(stakk::submit::bookmark_names_taken),
help(
"the names were free when the selection was made — another bookmark has appeared \
since; rename with --new REV=NAME, or reuse the existing bookmark with --keep NAME"
)
)]
BookmarkNamesTaken { bookmarks: Vec<String> },
#[error("failed to create bookmark '{bookmark}'")]
#[diagnostic(
code(stakk::submit::bookmark_create_failed),
help(
"check that the name is not already taken (`jj bookmark list`) and that the target \
commit still exists"
)
)]
BookmarkCreateFailed {
bookmark: String,
#[source]
source: JjError,
},
#[error("failed to push bookmark '{bookmark}'")]
#[diagnostic(
code(stakk::submit::push_failed),
help("ensure the bookmark exists and the remote is reachable")
)]
PushFailed {
bookmark: String,
#[source]
source: JjError,
},
#[error("failed to update PR base for '{bookmark}'")]
#[diagnostic(
code(stakk::submit::base_update_failed),
help(
"the PR exists but its base branch could not be changed — check your token permissions"
)
)]
BaseUpdateFailed {
bookmark: String,
#[source]
source: ForgeError,
},
#[error("failed to create PR for '{bookmark}'")]
#[diagnostic(
code(stakk::submit::pr_create_failed),
help("check your token permissions and that the head branch exists on the remote")
)]
PrCreateFailed {
bookmark: String,
#[source]
source: ForgeError,
},
#[error("failed to manage stack comment on PR #{pr_number}")]
#[diagnostic(
code(stakk::submit::comment_failed),
help("check your token permissions for commenting on PRs")
)]
CommentFailed {
pr_number: u64,
#[source]
source: ForgeError,
},
#[error("template rendering failed: {message}")]
#[diagnostic(
code(stakk::submit::template_render_failed),
help("check the template syntax (minijinja/Jinja2)")
)]
TemplateRenderFailed { message: String },
#[error("failed to update body of PR #{pr_number}")]
#[diagnostic(
code(stakk::submit::body_update_failed),
help("check your token permissions for updating PR descriptions")
)]
BodyUpdateFailed {
pr_number: u64,
#[source]
source: ForgeError,
},
#[error("failed to sync title of PR #{pr_number} for '{bookmark}'")]
#[diagnostic(
code(stakk::submit::title_sync_failed),
help("the PR exists but its title could not be updated — check your token permissions")
)]
TitleSyncFailed {
pr_number: u64,
bookmark: String,
#[source]
source: ForgeError,
},
#[error("failed to sync body of PR #{pr_number} for '{bookmark}'")]
#[diagnostic(
code(stakk::submit::body_sync_failed),
help("the PR exists but its body could not be updated — check your token permissions")
)]
BodySyncFailed {
pr_number: u64,
bookmark: String,
#[source]
source: ForgeError,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SubmissionAnalysis {
pub segments: Vec<BookmarkSegment>,
pub default_branch: String,
}
#[derive(Debug, Clone)]
#[expect(
clippy::struct_excessive_bools,
reason = "these are independent action flags, not a state machine"
)]
pub struct BookmarkPlan {
pub bookmark_name: String,
pub base: String,
pub title: String,
pub body: Option<String>,
pub existing_pr: Option<PullRequest>,
pub needs_push: bool,
pub needs_create: bool,
pub needs_base_update: bool,
pub needs_title_sync: bool,
pub needs_body_sync: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BookmarkAssignment {
pub change_id: String,
pub short_change_id: String,
pub bookmark_name: String,
pub is_new: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BookmarkCreation {
pub bookmark_name: String,
pub change_id: String,
pub short_change_id: String,
}
#[derive(Debug)]
pub struct SubmissionPlan {
pub bookmark_creations: Vec<BookmarkCreation>,
pub bookmark_plans: Vec<BookmarkPlan>,
pub remote: String,
pub pr_mode: PrMode,
pub default_branch: String,
}
#[derive(Debug)]
pub struct SubmissionResult {
pub stack_entries: Vec<StackEntry>,
}
pub fn analyze_submission(
target_bookmark: &str,
change_graph: &ChangeGraph,
default_branch: &str,
) -> Result<SubmissionAnalysis, SubmitError> {
let stack = change_graph
.stacks
.iter()
.find(|s| {
s.segments
.iter()
.any(|seg| seg.bookmark_names.contains(&target_bookmark.to_string()))
})
.ok_or_else(|| SubmitError::BookmarkNotFound {
bookmark: target_bookmark.to_string(),
})?;
let target_index = stack
.segments
.iter()
.position(|seg| seg.bookmark_names.contains(&target_bookmark.to_string()))
.expect("bookmark was found in stack above");
let segments = stack.segments[..=target_index]
.iter()
.map(|seg| BookmarkSegment {
bookmark_names: seg.bookmark_names.clone(),
change_id: seg.change_id.clone(),
commits: seg.commits.clone(),
})
.collect();
Ok(SubmissionAnalysis {
segments,
default_branch: default_branch.to_string(),
})
}
pub fn analysis_from_selection(
path: &[SegmentCommit],
assignments: &[BookmarkAssignment],
default_branch: &str,
) -> Result<SubmissionAnalysis, SubmitError> {
let boundaries: HashMap<&str, &BookmarkAssignment> = assignments
.iter()
.map(|a| (a.change_id.as_str(), a))
.collect();
let mut segments = Vec::new();
let mut consumed: HashSet<&str> = HashSet::new();
let mut pending: Vec<SegmentCommit> = Vec::new();
for commit in path {
pending.push(commit.clone());
if let Some(assignment) = boundaries.get(commit.change_id.as_str()) {
if !consumed.insert(assignment.change_id.as_str()) {
return Err(SubmitError::DivergentChange {
change_id: assignment.change_id.clone(),
});
}
pending.reverse();
segments.push(BookmarkSegment {
bookmark_names: vec![assignment.bookmark_name.clone()],
change_id: assignment.change_id.clone(),
commits: std::mem::take(&mut pending),
});
}
}
if let Some(missed) = assignments
.iter()
.find(|a| !consumed.contains(a.change_id.as_str()))
{
return Err(SubmitError::AssignmentOffPath {
bookmark: missed.bookmark_name.clone(),
change_id: missed.change_id.clone(),
});
}
Ok(SubmissionAnalysis {
segments,
default_branch: default_branch.to_string(),
})
}
fn build_pr_body(commits: &[SegmentCommit], trailers: TrailerHandling) -> Option<String> {
if commits.is_empty() {
return None;
}
let parts: Vec<String> = commits
.iter()
.enumerate()
.filter_map(|(idx, c)| {
let (body, trailer_block) = split_trailers(c.description.trim());
let body_text = if commits.len() == 1 && idx == 0 {
body.lines().skip(1).collect::<Vec<_>>().join("\n")
} else {
body.to_string()
};
let unwrapped = unwrap_markdown(body_text.trim());
let kept_trailers = match trailers {
TrailerHandling::Keep => trailer_block,
TrailerHandling::Strip => None,
};
match (unwrapped.is_empty(), kept_trailers) {
(true, None) => None,
(true, Some(tb)) => Some(tb.to_string()),
(false, None) => Some(unwrapped),
(false, Some(tb)) => Some(format!("{unwrapped}\n\n{tb}")),
}
})
.collect();
if parts.is_empty() {
return None;
}
let body = parts.join("\n\n---\n\n");
if body.is_empty() { None } else { Some(body) }
}
pub async fn create_submission_plan<F: Forge>(
analysis: &SubmissionAnalysis,
bookmark_creations: Vec<BookmarkCreation>,
forge: &F,
remote: &str,
pr_mode: PrMode,
sync: SyncPrContent,
trailers: TrailerHandling,
) -> Result<SubmissionPlan, SubmitError> {
let bookmark_names: Vec<String> = analysis
.segments
.iter()
.map(|seg| {
seg.bookmark_names
.first()
.cloned()
.ok_or_else(|| SubmitError::SegmentMissingBookmark {
change_id: seg.change_id.clone(),
})
})
.collect::<Result<_, _>>()?;
let pr_futures: Vec<_> = bookmark_names
.iter()
.map(|name| forge.find_pr_for_branch(name))
.collect();
let pr_results = futures::future::join_all(pr_futures).await;
let mut bookmark_plans = Vec::new();
for (i, (segment, pr_result)) in analysis.segments.iter().zip(pr_results).enumerate() {
let bookmark_name = bookmark_names[i].clone();
let base = if i == 0 {
analysis.default_branch.clone()
} else {
bookmark_names[i - 1].clone()
};
let title = segment.commits.first().map_or_else(
|| bookmark_name.clone(),
|c| {
c.description
.lines()
.next()
.unwrap_or(&c.description)
.to_string()
},
);
let existing_pr = pr_result.map_err(|source| SubmitError::PrLookupFailed {
bookmark: bookmark_name.clone(),
source,
})?;
let needs_base_update = existing_pr.as_ref().is_some_and(|pr| pr.base_ref != base);
let needs_create = existing_pr.is_none();
let body = build_pr_body(&segment.commits, trailers);
let wants_title = matches!(sync, SyncPrContent::Title | SyncPrContent::All);
let wants_body = matches!(sync, SyncPrContent::Body | SyncPrContent::All);
let needs_title_sync =
wants_title && existing_pr.as_ref().is_some_and(|pr| pr.title != title);
let needs_body_sync = wants_body
&& !needs_create
&& existing_pr.as_ref().is_some_and(|pr| {
let existing_user_body = pr
.body
.as_deref()
.map(strip_stack_from_body)
.unwrap_or_default();
let normalized_existing = unwrap_markdown(existing_user_body.trim());
let normalized_new = body
.as_deref()
.map(|b| b.trim().to_string())
.unwrap_or_default();
normalized_new != normalized_existing
});
bookmark_plans.push(BookmarkPlan {
bookmark_name,
base,
title,
body,
existing_pr,
needs_push: true,
needs_create,
needs_base_update,
needs_title_sync,
needs_body_sync,
});
}
Ok(SubmissionPlan {
bookmark_creations,
bookmark_plans,
remote: remote.to_string(),
pr_mode,
default_branch: analysis.default_branch.clone(),
})
}
impl fmt::Display for SubmissionPlan {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let draft_label = if self.pr_mode == PrMode::Draft {
", draft"
} else {
""
};
write!(
f,
"Submission plan ({} bookmark(s), remote: {}{draft_label}):",
self.bookmark_plans.len(),
self.remote,
)?;
for creation in &self.bookmark_creations {
write!(
f,
"\n Create bookmark {} at {}",
creation.bookmark_name, creation.short_change_id,
)?;
}
for bp in &self.bookmark_plans {
write!(f, "\n {} (base: {})", bp.bookmark_name, bp.base)?;
if bp.needs_push {
write!(f, "\n - push bookmark to {}", self.remote)?;
}
if bp.needs_create {
write!(f, "\n - create PR: \"{}\"", bp.title)?;
}
if bp.needs_base_update
&& let Some(pr) = &bp.existing_pr
{
write!(
f,
"\n - update PR #{} base: {} -> {}",
pr.number, pr.base_ref, bp.base,
)?;
}
if bp.needs_title_sync
&& let Some(pr) = &bp.existing_pr
{
write!(f, "\n - sync PR #{} title from commits", pr.number)?;
}
if bp.needs_body_sync
&& let Some(pr) = &bp.existing_pr
{
write!(f, "\n - sync PR #{} body from commits", pr.number)?;
}
if !bp.needs_create
&& !bp.needs_base_update
&& !bp.needs_title_sync
&& !bp.needs_body_sync
&& let Some(pr) = &bp.existing_pr
{
write!(f, "\n - PR #{} up to date", pr.number)?;
}
}
Ok(())
}
}
pub async fn execute_submission_plan<R: JjRunner, F: Forge>(
plan: &SubmissionPlan,
jj: &Jj<R>,
forge: &F,
comment_env: &minijinja::Environment<'_>,
placement: StackPlacement,
) -> Result<SubmissionResult, SubmitError> {
let pb = indicatif::ProgressBar::new_spinner();
pb.enable_steady_tick(std::time::Duration::from_millis(120));
let effective = resolve_placement(placement);
if !plan.bookmark_creations.is_empty() {
pb.set_message("Checking bookmark names...");
let reserved = jj
.get_local_bookmark_names()
.await
.map_err(|source| SubmitError::BookmarkListFailed { source })?;
let taken: Vec<String> = plan
.bookmark_creations
.iter()
.map(|c| c.bookmark_name.clone())
.filter(|name| reserved.contains(name))
.collect();
if !taken.is_empty() {
return Err(SubmitError::BookmarkNamesTaken { bookmarks: taken });
}
}
for creation in &plan.bookmark_creations {
pb.set_message(format!("Creating bookmark: {}", creation.bookmark_name));
jj.create_bookmark(&creation.bookmark_name, &creation.change_id)
.await
.map_err(|source| SubmitError::BookmarkCreateFailed {
bookmark: creation.bookmark_name.clone(),
source,
})?;
}
let mut stack_entries = Vec::new();
let effective_body = |bp: &BookmarkPlan| -> Option<String> {
if bp.needs_create || bp.needs_body_sync {
bp.body.clone()
} else {
bp.existing_pr.as_ref().and_then(|pr| pr.body.clone())
}
};
for bp in &plan.bookmark_plans {
if bp.needs_push {
pb.set_message(format!("Pushing bookmark: {}", bp.bookmark_name));
jj.push_bookmark(&bp.bookmark_name, &plan.remote)
.await
.map_err(|source| SubmitError::PushFailed {
bookmark: bp.bookmark_name.clone(),
source,
})?;
}
if bp.needs_base_update
&& let Some(pr) = &bp.existing_pr
{
pb.set_message(format!("Updating PR #{} base...", pr.number));
forge
.update_pr_base(pr.number, &bp.base)
.await
.map_err(|source| SubmitError::BaseUpdateFailed {
bookmark: bp.bookmark_name.clone(),
source,
})?;
}
if bp.needs_title_sync
&& let Some(pr) = &bp.existing_pr
{
pb.set_message(format!("Syncing PR #{} title...", pr.number));
forge
.update_pr_title(pr.number, &bp.title)
.await
.map_err(|source| SubmitError::TitleSyncFailed {
pr_number: pr.number,
bookmark: bp.bookmark_name.clone(),
source,
})?;
}
if bp.needs_body_sync
&& effective != EffectivePlacement::Body
&& let Some(pr) = &bp.existing_pr
{
let new_body = bp.body.as_deref().unwrap_or("");
pb.set_message(format!("Syncing PR #{} body...", pr.number));
forge
.update_pr_body(pr.number, new_body)
.await
.map_err(|source| SubmitError::BodySyncFailed {
pr_number: pr.number,
bookmark: bp.bookmark_name.clone(),
source,
})?;
}
let pr = if let Some(existing) = &bp.existing_pr {
pb.println(format!(
" Existing PR #{}: {}",
existing.number, existing.html_url,
));
existing.clone()
} else {
pb.set_message(format!("Creating PR: {}", bp.title));
let pr = forge
.create_pr(CreatePrParams {
title: bp.title.clone(),
head: bp.bookmark_name.clone(),
base: bp.base.clone(),
body: bp.body.clone(),
draft: plan.pr_mode == PrMode::Draft,
})
.await
.map_err(|source| SubmitError::PrCreateFailed {
bookmark: bp.bookmark_name.clone(),
source,
})?;
pb.println(format!(" Created PR #{}: {}", pr.number, pr.html_url));
pr
};
stack_entries.push(StackEntry {
bookmark_name: bp.bookmark_name.clone(),
pr_url: pr.html_url.clone(),
pr_number: pr.number,
});
}
let cleanup_only = effective == EffectivePlacement::Cleanup || stack_entries.len() == 1;
if effective == EffectivePlacement::Ignore {
} else if cleanup_only {
pb.set_message("Cleaning up stack artifacts...");
let cleanup_futures: Vec<_> = stack_entries
.iter()
.enumerate()
.map(|(i, entry)| {
let bp = &plan.bookmark_plans[i];
let existing_body = effective_body(bp).unwrap_or_default();
let created_now = bp.needs_create;
let pb = &pb;
async move {
if created_now {
return Ok(false);
}
cleanup_stack_artifacts(forge, entry.pr_number, &existing_body, pb).await
}
})
.collect();
let results = futures::future::join_all(cleanup_futures).await;
let mut cleaned = 0usize;
for result in results {
if result? {
cleaned += 1;
}
}
if cleaned > 0 {
pb.println(format!(" Removed stale stack info from {cleaned} PR(s)."));
}
} else if stack_entries.len() > 1 {
pb.set_message("Updating stack comments...");
let comment_data = StackCommentData {
version: 0,
stack: stack_entries.clone(),
};
let template = comment_env.get_template("stack_comment").map_err(|e| {
SubmitError::TemplateRenderFailed {
message: e.to_string(),
}
})?;
let entry_contexts: Vec<StackEntryContext> = stack_entries
.iter()
.enumerate()
.map(|(i, entry)| {
let bp = &plan.bookmark_plans[i];
StackEntryContext {
bookmark_name: entry.bookmark_name.clone(),
pr_url: entry.pr_url.clone(),
pr_number: entry.pr_number,
title: bp.title.clone(),
base: bp.base.clone(),
is_draft: plan.pr_mode == PrMode::Draft && bp.needs_create,
position: i + 1,
is_current: false, }
})
.collect();
match effective {
EffectivePlacement::Comment => {
let comment_futures: Vec<_> = stack_entries
.iter()
.enumerate()
.map(|(i, entry)| {
let mut entries = entry_contexts.clone();
entries[i].is_current = true;
let ctx = StackCommentContext {
stack_size: entries.len(),
current_bookmark: entry.bookmark_name.clone(),
default_branch: plan.default_branch.clone(),
stakk_url: STAKK_REPO_URL.to_string(),
stack: entries,
};
let rendered = format_stack_comment(&comment_data, &ctx, &template)
.map(|s| with_comment_preamble(&s));
let pr_number = entry.pr_number;
let existing_body = effective_body(&plan.bookmark_plans[i]);
let pb = &pb;
async move {
let rendered = rendered?;
let existing_comments =
forge.list_comments(pr_number).await.map_err(|source| {
SubmitError::CommentFailed { pr_number, source }
})?;
if let Some(existing) = find_stack_comment(&existing_comments) {
forge.update_comment(existing.id, &rendered).await.map_err(
|source| SubmitError::CommentFailed { pr_number, source },
)?;
} else {
forge.create_comment(pr_number, &rendered).await.map_err(
|source| SubmitError::CommentFailed { pr_number, source },
)?;
if let Some(body) = &existing_body
&& find_stack_in_body(body).is_some()
{
let stripped = strip_stack_from_body(body);
if let Err(e) = forge.update_pr_body(pr_number, &stripped).await
{
pb.println(format!(
" Warning: failed to strip stack from PR \
#{pr_number} body during migration: {e}"
));
}
}
}
Ok::<(), SubmitError>(())
}
})
.collect();
let comment_results = futures::future::join_all(comment_futures).await;
for result in comment_results {
result?;
}
pb.println(format!(
" Stack comment updated on {} PR(s).",
stack_entries.len()
));
}
EffectivePlacement::Body => {
let body_futures: Vec<_> =
stack_entries
.iter()
.enumerate()
.map(|(i, entry)| {
let mut entries = entry_contexts.clone();
entries[i].is_current = true;
let ctx = StackCommentContext {
stack_size: entries.len(),
current_bookmark: entry.bookmark_name.clone(),
default_branch: plan.default_branch.clone(),
stakk_url: STAKK_REPO_URL.to_string(),
stack: entries,
};
let rendered = format_stack_comment(&comment_data, &ctx, &template);
let pr_number = entry.pr_number;
let bp = &plan.bookmark_plans[i];
let existing_body = effective_body(bp).unwrap_or_default();
let had_fence = find_stack_in_body(&existing_body).is_some();
let pb = &pb;
async move {
let rendered = rendered?;
let new_body = splice_stack_into_body(&existing_body, &rendered);
forge.update_pr_body(pr_number, &new_body).await.map_err(
|source| SubmitError::BodyUpdateFailed { pr_number, source },
)?;
if !had_fence {
let comments =
forge.list_comments(pr_number).await.map_err(|source| {
SubmitError::CommentFailed { pr_number, source }
})?;
if let Some(old) = find_stack_comment(&comments)
&& let Err(e) = forge.delete_comment(old.id).await
{
pb.println(format!(
" Warning: failed to delete old stack comment on PR \
#{pr_number} during migration: {e}"
));
}
}
Ok::<(), SubmitError>(())
}
})
.collect();
let body_results = futures::future::join_all(body_futures).await;
for result in body_results {
result?;
}
pb.println(format!(
" Stack section updated in {} PR bodies.",
stack_entries.len()
));
}
EffectivePlacement::Cleanup | EffectivePlacement::Ignore => {}
}
}
pb.finish_and_clear();
Ok(SubmissionResult { stack_entries })
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EffectivePlacement {
Comment,
Body,
Cleanup,
Ignore,
}
fn resolve_placement(placement: StackPlacement) -> EffectivePlacement {
match placement {
StackPlacement::Comment => EffectivePlacement::Comment,
StackPlacement::Body => EffectivePlacement::Body,
StackPlacement::None => EffectivePlacement::Cleanup,
StackPlacement::Ignore => EffectivePlacement::Ignore,
}
}
async fn cleanup_stack_artifacts<F: Forge>(
forge: &F,
pr_number: u64,
existing_body: &str,
pb: &indicatif::ProgressBar,
) -> Result<bool, SubmitError> {
let mut found = false;
let comments = forge
.list_comments(pr_number)
.await
.map_err(|source| SubmitError::CommentFailed { pr_number, source })?;
if let Some(old) = find_stack_comment(&comments) {
found = true;
if let Err(e) = forge.delete_comment(old.id).await {
pb.println(format!(
" Warning: failed to clean up old stack comment on PR #{pr_number}: {e}"
));
}
}
if find_stack_in_body(existing_body).is_some() {
found = true;
let stripped = strip_stack_from_body(existing_body);
if let Err(e) = forge.update_pr_body(pr_number, &stripped).await {
pb.println(format!(
" Warning: failed to strip stack from PR #{pr_number} body: {e}"
));
}
}
Ok(found)
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::Mutex;
use super::*;
use crate::forge::Comment;
use crate::forge::ForgeError;
use crate::forge::PrState;
use crate::forge::comment::build_comment_env;
use crate::graph::types::BranchStack;
use crate::graph::types::SegmentCommit;
use crate::jj::JjError;
type OpLog = Arc<Mutex<Vec<Op>>>;
#[derive(Debug, Clone, PartialEq, Eq)]
enum Op {
CreateBookmark(String),
Push(String),
BaseUpdate(u64),
CreatePr(String),
}
fn test_comment_env() -> minijinja::Environment<'static> {
build_comment_env(None).unwrap()
}
fn make_segment(names: &[&str], change_id: &str, desc: &str) -> BookmarkSegment {
BookmarkSegment {
bookmark_names: names.iter().map(ToString::to_string).collect(),
change_id: change_id.to_string(),
commits: vec![SegmentCommit {
commit_id: format!("c_{change_id}"),
change_id: change_id.to_string(),
description: desc.to_string(),
author: crate::jj::types::Signature {
name: "Test".to_string(),
email: "test@test.com".to_string(),
timestamp: "T".to_string(),
},
committer: crate::jj::types::Signature {
name: "Test".to_string(),
email: "test@test.com".to_string(),
timestamp: "T".to_string(),
},
files: vec![],
is_immutable: false,
local_bookmark_names: vec![],
short_change_id: change_id[..4.min(change_id.len())].to_string(),
}],
}
}
fn make_graph(stacks: Vec<BranchStack>) -> ChangeGraph {
ChangeGraph {
adjacency_list: HashMap::new(),
stack_leaves: std::collections::HashSet::new(),
stack_roots: std::collections::HashSet::new(),
segments: HashMap::new(),
tainted_change_ids: std::collections::HashSet::new(),
excluded_bookmark_count: 0,
stacks,
}
}
fn make_pr(number: u64, head: &str, base: &str) -> PullRequest {
PullRequest {
number,
html_url: format!("https://github.com/test/repo/pull/{number}"),
title: format!("PR for {head}"),
head_ref: head.to_string(),
base_ref: base.to_string(),
state: PrState::Open,
body: None,
}
}
fn make_pr_with_body(number: u64, head: &str, base: &str, body: &str) -> PullRequest {
PullRequest {
number,
html_url: format!("https://github.com/test/repo/pull/{number}"),
title: format!("PR for {head}"),
head_ref: head.to_string(),
base_ref: base.to_string(),
state: PrState::Open,
body: Some(body.to_string()),
}
}
struct MockForge {
existing_prs: HashMap<String, PullRequest>,
created_prs: Mutex<Vec<CreatePrParams>>,
created_comments: Mutex<Vec<(u64, String)>>,
updated_comments: Mutex<Vec<(u64, String)>>,
updated_bases: Mutex<Vec<(u64, String)>>,
updated_titles: Mutex<Vec<(u64, String)>>,
updated_bodies: Mutex<Vec<(u64, String)>>,
deleted_comments: Mutex<Vec<u64>>,
existing_comments: HashMap<u64, Vec<Comment>>,
listed_comments: Mutex<Vec<u64>>,
next_pr_number: Mutex<u64>,
ops: Option<OpLog>,
}
impl MockForge {
fn new() -> Self {
Self {
existing_prs: HashMap::new(),
created_prs: Mutex::new(Vec::new()),
created_comments: Mutex::new(Vec::new()),
updated_comments: Mutex::new(Vec::new()),
updated_bases: Mutex::new(Vec::new()),
updated_titles: Mutex::new(Vec::new()),
updated_bodies: Mutex::new(Vec::new()),
deleted_comments: Mutex::new(Vec::new()),
existing_comments: HashMap::new(),
listed_comments: Mutex::new(Vec::new()),
next_pr_number: Mutex::new(100),
ops: None,
}
}
fn with_ops(mut self, ops: OpLog) -> Self {
self.ops = Some(ops);
self
}
fn with_existing_pr(mut self, head: &str, pr: PullRequest) -> Self {
self.existing_prs.insert(head.to_string(), pr);
self
}
fn with_existing_comments(mut self, pr_number: u64, comments: Vec<Comment>) -> Self {
self.existing_comments.insert(pr_number, comments);
self
}
}
impl Forge for MockForge {
async fn get_authenticated_user(&self) -> Result<String, ForgeError> {
Ok("test-user".to_string())
}
fn find_pr_for_branch(
&self,
head: &str,
) -> impl std::future::Future<Output = Result<Option<PullRequest>, ForgeError>> + Send
{
let result = self.existing_prs.get(head).cloned();
async move { Ok(result) }
}
fn create_pr(
&self,
params: CreatePrParams,
) -> impl std::future::Future<Output = Result<PullRequest, ForgeError>> + Send {
let mut counter = self.next_pr_number.lock().unwrap();
let number = *counter;
*counter += 1;
let pr = PullRequest {
number,
html_url: format!("https://github.com/test/repo/pull/{number}"),
title: params.title.clone(),
head_ref: params.head.clone(),
base_ref: params.base.clone(),
state: PrState::Open,
body: params.body.clone(),
};
if let Some(ops) = &self.ops {
ops.lock().unwrap().push(Op::CreatePr(params.head.clone()));
}
self.created_prs.lock().unwrap().push(params);
async move { Ok(pr) }
}
fn update_pr_base(
&self,
pr_number: u64,
new_base: &str,
) -> impl std::future::Future<Output = Result<(), ForgeError>> + Send {
if let Some(ops) = &self.ops {
ops.lock().unwrap().push(Op::BaseUpdate(pr_number));
}
self.updated_bases
.lock()
.unwrap()
.push((pr_number, new_base.to_string()));
async { Ok(()) }
}
fn update_pr_title(
&self,
pr_number: u64,
title: &str,
) -> impl std::future::Future<Output = Result<(), ForgeError>> + Send {
self.updated_titles
.lock()
.unwrap()
.push((pr_number, title.to_string()));
async { Ok(()) }
}
fn list_comments(
&self,
pr_number: u64,
) -> impl std::future::Future<Output = Result<Vec<Comment>, ForgeError>> + Send {
self.listed_comments.lock().unwrap().push(pr_number);
let comments = self
.existing_comments
.get(&pr_number)
.cloned()
.unwrap_or_default();
async move { Ok(comments) }
}
fn create_comment(
&self,
pr_number: u64,
body: &str,
) -> impl std::future::Future<Output = Result<Comment, ForgeError>> + Send {
let comment = Comment {
id: pr_number * 1000,
body: body.to_string(),
};
self.created_comments
.lock()
.unwrap()
.push((pr_number, body.to_string()));
async move { Ok(comment) }
}
fn update_comment(
&self,
comment_id: u64,
body: &str,
) -> impl std::future::Future<Output = Result<(), ForgeError>> + Send {
self.updated_comments
.lock()
.unwrap()
.push((comment_id, body.to_string()));
async { Ok(()) }
}
fn update_pr_body(
&self,
pr_number: u64,
body: &str,
) -> impl std::future::Future<Output = Result<(), ForgeError>> + Send {
self.updated_bodies
.lock()
.unwrap()
.push((pr_number, body.to_string()));
async { Ok(()) }
}
fn delete_comment(
&self,
comment_id: u64,
) -> impl std::future::Future<Output = Result<(), ForgeError>> + Send {
self.deleted_comments.lock().unwrap().push(comment_id);
async { Ok(()) }
}
}
type PushLog = Arc<Mutex<Vec<(String, String)>>>;
struct MockJjRunner {
push_calls: PushLog,
ops: Option<OpLog>,
local_bookmarks: Vec<String>,
}
impl MockJjRunner {
fn new() -> (Self, PushLog) {
let calls: PushLog = Arc::new(Mutex::new(Vec::new()));
(
Self {
push_calls: Arc::clone(&calls),
ops: None,
local_bookmarks: Vec::new(),
},
calls,
)
}
fn new_with_ops(ops: OpLog) -> (Self, PushLog) {
let calls: PushLog = Arc::new(Mutex::new(Vec::new()));
(
Self {
push_calls: Arc::clone(&calls),
ops: Some(ops),
local_bookmarks: Vec::new(),
},
calls,
)
}
fn with_local_bookmarks(mut self, names: &[&str]) -> Self {
self.local_bookmarks = names.iter().map(ToString::to_string).collect();
self
}
}
impl crate::jj::runner::JjRunner for MockJjRunner {
fn run_jj(
&self,
args: &[&str],
) -> impl std::future::Future<Output = Result<String, JjError>> + Send {
let mut output = String::new();
if args[0] == "bookmark" && args[1] == "list" {
for name in &self.local_bookmarks {
output.push('"');
output.push_str(name);
output.push_str("\"\n");
}
}
if args[0] == "bookmark"
&& args[1] == "create"
&& let Some(ops) = &self.ops
{
ops.lock()
.unwrap()
.push(Op::CreateBookmark(args[2].to_string()));
}
if args[0] == "git" && args[1] == "push" {
let bookmark = args
.iter()
.position(|a| *a == "--bookmark")
.map(|i| args[i + 1].to_string())
.unwrap_or_default();
let remote = args
.iter()
.position(|a| *a == "--remote")
.map(|i| args[i + 1].to_string())
.unwrap_or_default();
if let Some(ops) = &self.ops {
ops.lock().unwrap().push(Op::Push(bookmark.clone()));
}
self.push_calls.lock().unwrap().push((bookmark, remote));
}
async move { Ok(output) }
}
}
#[test]
fn analyze_single_bookmark() {
let seg = make_segment(&["feat-a"], "ch_a", "add feature a");
let graph = make_graph(vec![BranchStack {
segments: vec![seg],
}]);
let result = analyze_submission("feat-a", &graph, "main").unwrap();
assert_eq!(result.segments.len(), 1);
assert_eq!(result.segments[0].bookmark_names, vec!["feat-a"]);
assert_eq!(result.default_branch, "main");
}
#[test]
fn analyze_middle_of_stack() {
let seg_a = make_segment(&["feat-a"], "ch_a", "feature a");
let seg_b = make_segment(&["feat-b"], "ch_b", "feature b");
let seg_c = make_segment(&["feat-c"], "ch_c", "feature c");
let graph = make_graph(vec![BranchStack {
segments: vec![seg_a, seg_b, seg_c],
}]);
let result = analyze_submission("feat-b", &graph, "main").unwrap();
assert_eq!(result.segments.len(), 2);
assert_eq!(result.segments[0].bookmark_names, vec!["feat-a"]);
assert_eq!(result.segments[1].bookmark_names, vec!["feat-b"]);
}
#[test]
fn analyze_leaf_of_stack() {
let seg_a = make_segment(&["feat-a"], "ch_a", "feature a");
let seg_b = make_segment(&["feat-b"], "ch_b", "feature b");
let graph = make_graph(vec![BranchStack {
segments: vec![seg_a, seg_b],
}]);
let result = analyze_submission("feat-b", &graph, "main").unwrap();
assert_eq!(result.segments.len(), 2);
}
#[test]
fn analyze_bookmark_not_found() {
let seg = make_segment(&["feat-a"], "ch_a", "feature a");
let graph = make_graph(vec![BranchStack {
segments: vec![seg],
}]);
let result = analyze_submission("nonexistent", &graph, "main");
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("nonexistent"),
"error should mention the bookmark name: {err}"
);
}
#[test]
fn analyze_multiple_stacks_finds_correct_one() {
let stack1 = BranchStack {
segments: vec![make_segment(&["alpha"], "ch_alpha", "alpha")],
};
let stack2 = BranchStack {
segments: vec![
make_segment(&["beta"], "ch_beta", "beta"),
make_segment(&["gamma"], "ch_gamma", "gamma"),
],
};
let graph = make_graph(vec![stack1, stack2]);
let result = analyze_submission("gamma", &graph, "main").unwrap();
assert_eq!(result.segments.len(), 2);
assert_eq!(result.segments[0].bookmark_names, vec!["beta"]);
assert_eq!(result.segments[1].bookmark_names, vec!["gamma"]);
}
#[test]
fn analyze_explicit_target_submits_all_ancestors() {
let seg_a = make_segment(&["feat-a"], "ch_a", "feature a");
let seg_b = make_segment(&["feat-b"], "ch_b", "feature b");
let seg_c = make_segment(&["feat-c"], "ch_c", "feature c");
let graph = make_graph(vec![BranchStack {
segments: vec![seg_a, seg_b, seg_c],
}]);
let result = analyze_submission("feat-c", &graph, "main").unwrap();
assert_eq!(result.segments.len(), 3);
assert_eq!(result.segments[0].bookmark_names, vec!["feat-a"]);
assert_eq!(result.segments[1].bookmark_names, vec!["feat-b"]);
assert_eq!(result.segments[2].bookmark_names, vec!["feat-c"]);
for seg in &result.segments {
assert_eq!(seg.commits.len(), 1);
}
}
fn make_assignment(change_id: &str, name: &str, is_new: bool) -> BookmarkAssignment {
BookmarkAssignment {
change_id: change_id.to_string(),
short_change_id: change_id[..4.min(change_id.len())].to_string(),
bookmark_name: name.to_string(),
is_new,
}
}
fn make_segment_multi(names: &[&str], change_id: &str, descs: &[&str]) -> BookmarkSegment {
let mut seg = make_segment(names, change_id, descs[0]);
let template = seg.commits[0].clone();
for (i, desc) in descs.iter().enumerate().skip(1) {
let mut c = template.clone();
c.change_id = format!("{change_id}_{i}");
c.commit_id = format!("c_{change_id}_{i}");
c.short_change_id = format!("{}{i}", template.short_change_id);
c.description = (*desc).to_string();
seg.commits.push(c);
}
seg
}
fn path_of(stack: &BranchStack) -> Vec<SegmentCommit> {
stack.commits_trunk_to_tip().cloned().collect()
}
fn commit_ids(segment: &BookmarkSegment) -> Vec<&str> {
segment
.commits
.iter()
.map(|c| c.change_id.as_str())
.collect()
}
#[test]
fn from_selection_leaf_only_folds_ancestor() {
let stack = BranchStack {
segments: vec![
make_segment(&["feat-a"], "ch_a", "feature a"),
make_segment(&["feat-b"], "ch_b", "feature b"),
],
};
let path = path_of(&stack);
let direct =
analysis_from_selection(&path, &[make_assignment("ch_b", "feat-b", false)], "main")
.unwrap();
assert_eq!(direct.default_branch, "main");
assert_eq!(direct.segments.len(), 1);
assert_eq!(direct.segments[0].bookmark_names, vec!["feat-b"]);
assert_eq!(direct.segments[0].change_id, "ch_b");
assert_eq!(commit_ids(&direct.segments[0]), vec!["ch_b", "ch_a"]);
}
#[test]
fn from_selection_subset_folds_unkept_middle() {
let stack = BranchStack {
segments: vec![
make_segment(&["feat-a"], "ch_a", "feature a"),
make_segment(&["feat-b"], "ch_b", "feature b"),
make_segment(&["feat-c"], "ch_c", "feature c"),
],
};
let path = path_of(&stack);
let direct = analysis_from_selection(
&path,
&[
make_assignment("ch_a", "feat-a", false),
make_assignment("ch_c", "feat-c", false),
],
"main",
)
.unwrap();
assert_eq!(direct.default_branch, "main");
assert_eq!(direct.segments.len(), 2);
assert_eq!(direct.segments[0].bookmark_names, vec!["feat-a"]);
assert_eq!(direct.segments[0].change_id, "ch_a");
assert_eq!(commit_ids(&direct.segments[0]), vec!["ch_a"]);
assert_eq!(direct.segments[1].bookmark_names, vec!["feat-c"]);
assert_eq!(direct.segments[1].change_id, "ch_c");
assert_eq!(commit_ids(&direct.segments[1]), vec!["ch_c", "ch_b"]);
}
#[test]
fn from_selection_all_boundaries_no_folding() {
let stack = BranchStack {
segments: vec![
make_segment(&["feat-a"], "ch_a", "feature a"),
make_segment(&["feat-b"], "ch_b", "feature b"),
],
};
let path = path_of(&stack);
let direct = analysis_from_selection(
&path,
&[
make_assignment("ch_a", "feat-a", false),
make_assignment("ch_b", "feat-b", false),
],
"main",
)
.unwrap();
assert_eq!(direct.default_branch, "main");
assert_eq!(direct.segments.len(), 2);
assert_eq!(direct.segments[0].bookmark_names, vec!["feat-a"]);
assert_eq!(commit_ids(&direct.segments[0]), vec!["ch_a"]);
assert_eq!(direct.segments[1].bookmark_names, vec!["feat-b"]);
assert_eq!(commit_ids(&direct.segments[1]), vec!["ch_b"]);
}
#[test]
fn from_selection_splits_segment_at_new_bookmark() {
let stack = BranchStack {
segments: vec![make_segment_multi(
&["feat"],
"ch_f",
&["newest work", "older work"],
)],
};
let path = path_of(&stack);
let direct = analysis_from_selection(
&path,
&[
make_assignment("ch_f_1", "my-new-base", true),
make_assignment("ch_f", "feat", false),
],
"main",
)
.unwrap();
assert_eq!(direct.segments.len(), 2);
assert_eq!(direct.segments[0].bookmark_names, vec!["my-new-base"]);
assert_eq!(direct.segments[0].change_id, "ch_f_1");
assert_eq!(direct.segments[0].commits.len(), 1);
assert_eq!(direct.segments[0].commits[0].description, "older work");
assert_eq!(direct.segments[1].bookmark_names, vec!["feat"]);
assert_eq!(direct.segments[1].commits.len(), 1);
assert_eq!(direct.segments[1].commits[0].description, "newest work");
}
#[test]
fn from_selection_drops_commits_above_topmost_mark() {
let stack = BranchStack {
segments: vec![
make_segment(&["feat-a"], "ch_a", "feature a"),
make_segment(&["feat-b"], "ch_b", "feature b"),
],
};
let path = path_of(&stack);
let direct =
analysis_from_selection(&path, &[make_assignment("ch_a", "feat-a", false)], "main")
.unwrap();
assert_eq!(direct.segments.len(), 1);
assert_eq!(direct.segments[0].bookmark_names, vec!["feat-a"]);
assert_eq!(direct.segments[0].commits.len(), 1);
}
#[test]
fn from_selection_multi_fold_orders_newest_first() {
let stack = BranchStack {
segments: vec![
make_segment(&["feat-a"], "ch_a", "feature a"),
make_segment(&["feat-b"], "ch_b", "feature b"),
make_segment(&["feat-c"], "ch_c", "feature c"),
make_segment(&["feat-d"], "ch_d", "feature d"),
],
};
let path = path_of(&stack);
let direct =
analysis_from_selection(&path, &[make_assignment("ch_d", "feat-d", false)], "main")
.unwrap();
assert_eq!(direct.segments.len(), 1);
assert_eq!(
commit_ids(&direct.segments[0]),
vec!["ch_d", "ch_c", "ch_b", "ch_a"]
);
}
#[test]
fn from_selection_errors_on_off_path_assignment() {
let stack = BranchStack {
segments: vec![make_segment(&["feat-a"], "ch_a", "feature a")],
};
let path = path_of(&stack);
let err = analysis_from_selection(
&path,
&[
make_assignment("ch_a", "feat-a", false),
make_assignment("ch_elsewhere", "stray", true),
],
"main",
)
.unwrap_err();
assert!(matches!(
err,
SubmitError::AssignmentOffPath { ref bookmark, ref change_id }
if bookmark == "stray" && change_id == "ch_elsewhere"
));
}
#[test]
fn from_selection_errors_on_divergent_change() {
let stack = BranchStack {
segments: vec![
make_segment(&["feat-a"], "ch_dup", "first copy"),
make_segment(&["feat-b"], "ch_dup", "second copy"),
],
};
let path = path_of(&stack);
let err =
analysis_from_selection(&path, &[make_assignment("ch_dup", "feat-a", false)], "main")
.unwrap_err();
assert!(matches!(
err,
SubmitError::DivergentChange { ref change_id } if change_id == "ch_dup"
));
}
#[test]
fn from_selection_empty_assignments() {
let stack = BranchStack {
segments: vec![make_segment(&["feat-a"], "ch_a", "feature a")],
};
let path = path_of(&stack);
let direct = analysis_from_selection(&path, &[], "main").unwrap();
assert!(direct.segments.is_empty());
assert_eq!(direct.default_branch, "main");
}
#[tokio::test]
async fn plan_all_new_prs() {
let analysis = SubmissionAnalysis {
segments: vec![
make_segment(&["feat-a"], "ch_a", "feature a"),
make_segment(&["feat-b"], "ch_b", "feature b"),
],
default_branch: "main".to_string(),
};
let forge = MockForge::new();
let plan = create_submission_plan(
&analysis,
vec![],
&forge,
"origin",
PrMode::Regular,
SyncPrContent::None,
TrailerHandling::Keep,
)
.await
.unwrap();
assert_eq!(plan.bookmark_plans.len(), 2);
assert!(plan.bookmark_plans[0].needs_create);
assert!(!plan.bookmark_plans[0].needs_base_update);
assert_eq!(plan.bookmark_plans[0].base, "main");
assert!(plan.bookmark_plans[1].needs_create);
assert!(!plan.bookmark_plans[1].needs_base_update);
assert_eq!(plan.bookmark_plans[1].base, "feat-a");
}
#[tokio::test]
async fn plan_existing_pr_correct_base() {
let analysis = SubmissionAnalysis {
segments: vec![make_segment(&["feat-a"], "ch_a", "feature a")],
default_branch: "main".to_string(),
};
let forge = MockForge::new().with_existing_pr("feat-a", make_pr(42, "feat-a", "main"));
let plan = create_submission_plan(
&analysis,
vec![],
&forge,
"origin",
PrMode::Regular,
SyncPrContent::None,
TrailerHandling::Keep,
)
.await
.unwrap();
assert!(!plan.bookmark_plans[0].needs_create);
assert!(!plan.bookmark_plans[0].needs_base_update);
assert_eq!(
plan.bookmark_plans[0].existing_pr.as_ref().unwrap().number,
42
);
}
#[tokio::test]
async fn plan_existing_pr_wrong_base() {
let analysis = SubmissionAnalysis {
segments: vec![
make_segment(&["feat-a"], "ch_a", "feature a"),
make_segment(&["feat-b"], "ch_b", "feature b"),
],
default_branch: "main".to_string(),
};
let forge = MockForge::new()
.with_existing_pr("feat-a", make_pr(10, "feat-a", "main"))
.with_existing_pr("feat-b", make_pr(11, "feat-b", "main"));
let plan = create_submission_plan(
&analysis,
vec![],
&forge,
"origin",
PrMode::Regular,
SyncPrContent::None,
TrailerHandling::Keep,
)
.await
.unwrap();
assert!(!plan.bookmark_plans[0].needs_base_update);
assert!(plan.bookmark_plans[1].needs_base_update);
assert_eq!(plan.bookmark_plans[1].base, "feat-a");
}
#[tokio::test]
async fn plan_mixed_existing_and_new() {
let analysis = SubmissionAnalysis {
segments: vec![
make_segment(&["feat-a"], "ch_a", "feature a"),
make_segment(&["feat-b"], "ch_b", "feature b"),
],
default_branch: "main".to_string(),
};
let forge = MockForge::new().with_existing_pr("feat-a", make_pr(10, "feat-a", "main"));
let plan = create_submission_plan(
&analysis,
vec![],
&forge,
"origin",
PrMode::Regular,
SyncPrContent::None,
TrailerHandling::Keep,
)
.await
.unwrap();
assert!(!plan.bookmark_plans[0].needs_create);
assert!(plan.bookmark_plans[1].needs_create);
}
#[tokio::test]
async fn plan_sync_title_detects_change() {
let analysis = SubmissionAnalysis {
segments: vec![make_segment(&["feat-a"], "ch_a", "feature a")],
default_branch: "main".to_string(),
};
let forge = MockForge::new().with_existing_pr("feat-a", make_pr(42, "feat-a", "main"));
let plan = create_submission_plan(
&analysis,
vec![],
&forge,
"origin",
PrMode::Regular,
SyncPrContent::All,
TrailerHandling::Keep,
)
.await
.unwrap();
assert!(plan.bookmark_plans[0].needs_title_sync);
assert!(!plan.bookmark_plans[0].needs_body_sync);
}
#[tokio::test]
async fn plan_sync_title_skips_when_same() {
let analysis = SubmissionAnalysis {
segments: vec![make_segment(&["feat-a"], "ch_a", "PR for feat-a")],
default_branch: "main".to_string(),
};
let forge = MockForge::new().with_existing_pr("feat-a", make_pr(42, "feat-a", "main"));
let plan = create_submission_plan(
&analysis,
vec![],
&forge,
"origin",
PrMode::Regular,
SyncPrContent::All,
TrailerHandling::Keep,
)
.await
.unwrap();
assert!(!plan.bookmark_plans[0].needs_title_sync);
}
#[tokio::test]
async fn plan_sync_body_detects_change() {
let analysis = SubmissionAnalysis {
segments: vec![make_segment(
&["feat-a"],
"ch_a",
"feature a\n\nnew body text",
)],
default_branch: "main".to_string(),
};
let forge = MockForge::new().with_existing_pr(
"feat-a",
make_pr_with_body(42, "feat-a", "main", "old body"),
);
let plan = create_submission_plan(
&analysis,
vec![],
&forge,
"origin",
PrMode::Regular,
SyncPrContent::All,
TrailerHandling::Keep,
)
.await
.unwrap();
assert!(plan.bookmark_plans[0].needs_body_sync);
}
#[tokio::test]
async fn plan_sync_body_ignores_fenced_section() {
let fenced_body =
"old body\n\n<!-- STAKK_BODY_START -->\nstack info\n<!-- STAKK_BODY_END -->";
let analysis = SubmissionAnalysis {
segments: vec![make_segment(&["feat-a"], "ch_a", "feature a\n\nold body")],
default_branch: "main".to_string(),
};
let forge = MockForge::new().with_existing_pr(
"feat-a",
make_pr_with_body(42, "feat-a", "main", fenced_body),
);
let plan = create_submission_plan(
&analysis,
vec![],
&forge,
"origin",
PrMode::Regular,
SyncPrContent::All,
TrailerHandling::Keep,
)
.await
.unwrap();
assert!(!plan.bookmark_plans[0].needs_body_sync);
}
#[tokio::test]
async fn plan_sync_disabled_does_not_set_flags() {
let analysis = SubmissionAnalysis {
segments: vec![make_segment(&["feat-a"], "ch_a", "feature a")],
default_branch: "main".to_string(),
};
let forge = MockForge::new().with_existing_pr("feat-a", make_pr(42, "feat-a", "main"));
let plan = create_submission_plan(
&analysis,
vec![],
&forge,
"origin",
PrMode::Regular,
SyncPrContent::None,
TrailerHandling::Keep,
)
.await
.unwrap();
assert!(!plan.bookmark_plans[0].needs_title_sync);
assert!(!plan.bookmark_plans[0].needs_body_sync);
}
#[tokio::test]
async fn plan_sync_new_pr_does_not_set_flags() {
let analysis = SubmissionAnalysis {
segments: vec![make_segment(&["feat-a"], "ch_a", "feature a")],
default_branch: "main".to_string(),
};
let forge = MockForge::new();
let plan = create_submission_plan(
&analysis,
vec![],
&forge,
"origin",
PrMode::Regular,
SyncPrContent::All,
TrailerHandling::Keep,
)
.await
.unwrap();
assert!(!plan.bookmark_plans[0].needs_title_sync);
assert!(!plan.bookmark_plans[0].needs_body_sync);
}
#[tokio::test]
async fn plan_sync_title_only_does_not_set_body_flag() {
let analysis = SubmissionAnalysis {
segments: vec![make_segment(&["feat-a"], "ch_a", "feature a\n\nnew body")],
default_branch: "main".to_string(),
};
let forge = MockForge::new().with_existing_pr(
"feat-a",
make_pr_with_body(42, "feat-a", "main", "old body"),
);
let plan = create_submission_plan(
&analysis,
vec![],
&forge,
"origin",
PrMode::Regular,
SyncPrContent::Title,
TrailerHandling::Keep,
)
.await
.unwrap();
assert!(plan.bookmark_plans[0].needs_title_sync);
assert!(!plan.bookmark_plans[0].needs_body_sync);
}
#[tokio::test]
async fn plan_sync_body_only_does_not_set_title_flag() {
let analysis = SubmissionAnalysis {
segments: vec![make_segment(&["feat-a"], "ch_a", "feature a\n\nnew body")],
default_branch: "main".to_string(),
};
let forge = MockForge::new().with_existing_pr(
"feat-a",
make_pr_with_body(42, "feat-a", "main", "old body"),
);
let plan = create_submission_plan(
&analysis,
vec![],
&forge,
"origin",
PrMode::Regular,
SyncPrContent::Body,
TrailerHandling::Keep,
)
.await
.unwrap();
assert!(!plan.bookmark_plans[0].needs_title_sync);
assert!(plan.bookmark_plans[0].needs_body_sync);
}
#[test]
fn plan_display_dry_run() {
let plan = SubmissionPlan {
bookmark_plans: vec![
BookmarkPlan {
bookmark_name: "feat-a".to_string(),
base: "main".to_string(),
title: "feature a".to_string(),
body: None,
existing_pr: None,
needs_push: true,
needs_create: true,
needs_base_update: false,
needs_title_sync: false,
needs_body_sync: false,
},
BookmarkPlan {
bookmark_name: "feat-b".to_string(),
base: "feat-a".to_string(),
title: "feature b".to_string(),
body: None,
existing_pr: Some(make_pr(42, "feat-b", "main")),
needs_push: true,
needs_create: false,
needs_base_update: true,
needs_title_sync: false,
needs_body_sync: false,
},
],
bookmark_creations: vec![],
remote: "origin".to_string(),
pr_mode: PrMode::Regular,
default_branch: "main".to_string(),
};
let output = plan.to_string();
assert!(output.contains("2 bookmark(s)"));
assert!(output.contains("feat-a (base: main)"));
assert!(output.contains("create PR: \"feature a\""));
assert!(output.contains("push bookmark to origin"));
assert!(output.contains("update PR #42 base: main -> feat-a"));
}
#[test]
fn plan_display_shows_sync_lines() {
let plan = SubmissionPlan {
bookmark_plans: vec![BookmarkPlan {
bookmark_name: "feat-a".to_string(),
base: "main".to_string(),
title: "feature a".to_string(),
body: Some("body text".to_string()),
existing_pr: Some(make_pr(42, "feat-a", "main")),
needs_push: true,
needs_create: false,
needs_base_update: false,
needs_title_sync: true,
needs_body_sync: true,
}],
bookmark_creations: vec![],
remote: "origin".to_string(),
pr_mode: PrMode::Regular,
default_branch: "main".to_string(),
};
let output = plan.to_string();
assert!(output.contains("sync PR #42 title from commits"));
assert!(output.contains("sync PR #42 body from commits"));
assert!(!output.contains("up to date"));
}
#[test]
fn plan_display_shows_pending_bookmark_creations() {
let plan = SubmissionPlan {
bookmark_creations: vec![BookmarkCreation {
bookmark_name: "my-feature".to_string(),
change_id: "ch_new_full_id".to_string(),
short_change_id: "ch_n".to_string(),
}],
bookmark_plans: vec![BookmarkPlan {
bookmark_name: "my-feature".to_string(),
base: "main".to_string(),
title: "my feature".to_string(),
body: None,
existing_pr: None,
needs_push: true,
needs_create: true,
needs_base_update: false,
needs_title_sync: false,
needs_body_sync: false,
}],
remote: "origin".to_string(),
pr_mode: PrMode::Regular,
default_branch: "main".to_string(),
};
let output = plan.to_string();
assert!(output.contains("Create bookmark my-feature at ch_n"));
let creation_pos = output.find("Create bookmark").unwrap();
let plan_pos = output.find("my-feature (base: main)").unwrap();
assert!(creation_pos < plan_pos);
}
#[tokio::test]
async fn execute_creates_bookmarks_before_pushing() {
let ops: OpLog = Arc::new(Mutex::new(Vec::new()));
let plan = SubmissionPlan {
bookmark_creations: vec![BookmarkCreation {
bookmark_name: "feat-new".to_string(),
change_id: "ch_new".to_string(),
short_change_id: "ch_n".to_string(),
}],
bookmark_plans: vec![BookmarkPlan {
bookmark_name: "feat-new".to_string(),
base: "main".to_string(),
title: "new feature".to_string(),
body: None,
existing_pr: None,
needs_push: true,
needs_create: true,
needs_base_update: false,
needs_title_sync: false,
needs_body_sync: false,
}],
remote: "origin".to_string(),
pr_mode: PrMode::Regular,
default_branch: "main".to_string(),
};
let (runner, _push_calls) = MockJjRunner::new_with_ops(Arc::clone(&ops));
let jj = Jj::new(runner);
let forge = MockForge::new().with_ops(Arc::clone(&ops));
let env = test_comment_env();
execute_submission_plan(&plan, &jj, &forge, &env, StackPlacement::Comment)
.await
.unwrap();
let ops = ops.lock().unwrap();
assert_eq!(
*ops,
vec![
Op::CreateBookmark("feat-new".to_string()),
Op::Push("feat-new".to_string()),
Op::CreatePr("feat-new".to_string()),
],
);
}
#[tokio::test]
async fn execute_rejects_taken_bookmark_names_before_mutating() {
let ops: OpLog = Arc::new(Mutex::new(Vec::new()));
let plan = SubmissionPlan {
bookmark_creations: vec![
BookmarkCreation {
bookmark_name: "feat-free".to_string(),
change_id: "ch_a".to_string(),
short_change_id: "ch_a".to_string(),
},
BookmarkCreation {
bookmark_name: "feat-taken".to_string(),
change_id: "ch_b".to_string(),
short_change_id: "ch_b".to_string(),
},
],
bookmark_plans: vec![BookmarkPlan {
bookmark_name: "feat-free".to_string(),
base: "main".to_string(),
title: "new feature".to_string(),
body: None,
existing_pr: None,
needs_push: true,
needs_create: true,
needs_base_update: false,
needs_title_sync: false,
needs_body_sync: false,
}],
remote: "origin".to_string(),
pr_mode: PrMode::Regular,
default_branch: "main".to_string(),
};
let (runner, _push_calls) = MockJjRunner::new_with_ops(Arc::clone(&ops));
let jj = Jj::new(runner.with_local_bookmarks(&["main", "feat-taken"]));
let forge = MockForge::new().with_ops(Arc::clone(&ops));
let env = test_comment_env();
let err = execute_submission_plan(&plan, &jj, &forge, &env, StackPlacement::Comment)
.await
.unwrap_err();
assert!(
matches!(&err, SubmitError::BookmarkNamesTaken { bookmarks } if bookmarks == &["feat-taken"]),
"unexpected error: {err:?}",
);
assert!(ops.lock().unwrap().is_empty(), "repo was mutated: {ops:?}");
}
#[tokio::test]
async fn execute_accepts_free_bookmark_names() {
let ops: OpLog = Arc::new(Mutex::new(Vec::new()));
let plan = SubmissionPlan {
bookmark_creations: vec![BookmarkCreation {
bookmark_name: "feat-new".to_string(),
change_id: "ch_new".to_string(),
short_change_id: "ch_n".to_string(),
}],
bookmark_plans: vec![BookmarkPlan {
bookmark_name: "feat-new".to_string(),
base: "main".to_string(),
title: "new feature".to_string(),
body: None,
existing_pr: None,
needs_push: true,
needs_create: true,
needs_base_update: false,
needs_title_sync: false,
needs_body_sync: false,
}],
remote: "origin".to_string(),
pr_mode: PrMode::Regular,
default_branch: "main".to_string(),
};
let (runner, _push_calls) = MockJjRunner::new_with_ops(Arc::clone(&ops));
let jj = Jj::new(runner.with_local_bookmarks(&["main", "other"]));
let forge = MockForge::new().with_ops(Arc::clone(&ops));
let env = test_comment_env();
execute_submission_plan(&plan, &jj, &forge, &env, StackPlacement::Comment)
.await
.unwrap();
assert_eq!(
*ops.lock().unwrap(),
vec![
Op::CreateBookmark("feat-new".to_string()),
Op::Push("feat-new".to_string()),
Op::CreatePr("feat-new".to_string()),
],
);
}
#[tokio::test]
async fn execute_creates_new_prs() {
let plan = SubmissionPlan {
bookmark_plans: vec![
BookmarkPlan {
bookmark_name: "feat-a".to_string(),
base: "main".to_string(),
title: "feature a".to_string(),
body: None,
existing_pr: None,
needs_push: true,
needs_create: true,
needs_base_update: false,
needs_title_sync: false,
needs_body_sync: false,
},
BookmarkPlan {
bookmark_name: "feat-b".to_string(),
base: "feat-a".to_string(),
title: "feature b".to_string(),
body: None,
existing_pr: None,
needs_push: true,
needs_create: true,
needs_base_update: false,
needs_title_sync: false,
needs_body_sync: false,
},
],
bookmark_creations: vec![],
remote: "origin".to_string(),
pr_mode: PrMode::Regular,
default_branch: "main".to_string(),
};
let (runner, _push_calls) = MockJjRunner::new();
let jj = Jj::new(runner);
let forge = MockForge::new();
let env = test_comment_env();
let result = execute_submission_plan(&plan, &jj, &forge, &env, StackPlacement::Comment)
.await
.unwrap();
assert_eq!(result.stack_entries.len(), 2);
let created = forge.created_prs.lock().unwrap();
assert_eq!(created.len(), 2);
assert_eq!(created[0].head, "feat-a");
assert_eq!(created[0].base, "main");
assert_eq!(created[1].head, "feat-b");
assert_eq!(created[1].base, "feat-a");
}
#[tokio::test]
async fn execute_updates_base() {
let plan = SubmissionPlan {
bookmark_plans: vec![BookmarkPlan {
bookmark_name: "feat-a".to_string(),
base: "develop".to_string(),
title: "feature a".to_string(),
body: None,
existing_pr: Some(make_pr(42, "feat-a", "main")),
needs_push: true,
needs_create: false,
needs_base_update: true,
needs_title_sync: false,
needs_body_sync: false,
}],
bookmark_creations: vec![],
remote: "origin".to_string(),
pr_mode: PrMode::Regular,
default_branch: "main".to_string(),
};
let (runner, _push_calls) = MockJjRunner::new();
let jj = Jj::new(runner);
let forge = MockForge::new();
let env = test_comment_env();
execute_submission_plan(&plan, &jj, &forge, &env, StackPlacement::Comment)
.await
.unwrap();
let updated = forge.updated_bases.lock().unwrap();
assert_eq!(updated.len(), 1);
assert_eq!(updated[0], (42, "develop".to_string()));
}
#[tokio::test]
async fn execute_creates_stack_comments() {
let plan = SubmissionPlan {
bookmark_plans: vec![
BookmarkPlan {
bookmark_name: "feat-a".to_string(),
base: "main".to_string(),
title: "feature a".to_string(),
body: None,
existing_pr: None,
needs_push: true,
needs_create: true,
needs_base_update: false,
needs_title_sync: false,
needs_body_sync: false,
},
BookmarkPlan {
bookmark_name: "feat-b".to_string(),
base: "feat-a".to_string(),
title: "feature b".to_string(),
body: None,
existing_pr: None,
needs_push: true,
needs_create: true,
needs_base_update: false,
needs_title_sync: false,
needs_body_sync: false,
},
],
bookmark_creations: vec![],
remote: "origin".to_string(),
pr_mode: PrMode::Regular,
default_branch: "main".to_string(),
};
let (runner, _push_calls) = MockJjRunner::new();
let jj = Jj::new(runner);
let forge = MockForge::new();
let env = test_comment_env();
execute_submission_plan(&plan, &jj, &forge, &env, StackPlacement::Comment)
.await
.unwrap();
let comments = forge.created_comments.lock().unwrap();
assert_eq!(comments.len(), 2);
assert!(comments[0].1.contains("STAKK_STACK"));
assert!(comments[1].1.contains("STAKK_STACK"));
}
#[tokio::test]
async fn execute_updates_existing_stack_comments() {
let env = test_comment_env();
let tmpl = env.get_template("stack_comment").unwrap();
let existing_comment_body = format_stack_comment(
&StackCommentData {
version: 0,
stack: vec![StackEntry {
bookmark_name: "old".to_string(),
pr_url: "https://example.com/1".to_string(),
pr_number: 1,
}],
},
&StackCommentContext {
stack: vec![StackEntryContext {
bookmark_name: "old".to_string(),
pr_url: "https://example.com/1".to_string(),
pr_number: 1,
title: "old feature".to_string(),
base: "main".to_string(),
is_draft: false,
position: 1,
is_current: true,
}],
stack_size: 1,
default_branch: "main".to_string(),
current_bookmark: "old".to_string(),
stakk_url: STAKK_REPO_URL.to_string(),
},
&tmpl,
)
.unwrap();
let plan = SubmissionPlan {
bookmark_plans: vec![
BookmarkPlan {
bookmark_name: "feat-a".to_string(),
base: "main".to_string(),
title: "feature a".to_string(),
body: None,
existing_pr: Some(make_pr(50, "feat-a", "main")),
needs_push: true,
needs_create: false,
needs_base_update: false,
needs_title_sync: false,
needs_body_sync: false,
},
BookmarkPlan {
bookmark_name: "feat-b".to_string(),
base: "feat-a".to_string(),
title: "feature b".to_string(),
body: None,
existing_pr: None,
needs_push: true,
needs_create: true,
needs_base_update: false,
needs_title_sync: false,
needs_body_sync: false,
},
],
bookmark_creations: vec![],
remote: "origin".to_string(),
pr_mode: PrMode::Regular,
default_branch: "main".to_string(),
};
let (runner, _push_calls) = MockJjRunner::new();
let jj = Jj::new(runner);
let forge = MockForge::new().with_existing_comments(
50,
vec![Comment {
id: 999,
body: existing_comment_body,
}],
);
execute_submission_plan(&plan, &jj, &forge, &env, StackPlacement::Comment)
.await
.unwrap();
let created = forge.created_comments.lock().unwrap();
assert_eq!(created.len(), 1);
let updated = forge.updated_comments.lock().unwrap();
assert_eq!(updated.len(), 1);
assert_eq!(updated[0].0, 999);
}
#[tokio::test]
async fn execute_pushes_bookmarks() {
let plan = SubmissionPlan {
bookmark_plans: vec![
BookmarkPlan {
bookmark_name: "feat-a".to_string(),
base: "main".to_string(),
title: "feature a".to_string(),
body: None,
existing_pr: None,
needs_push: true,
needs_create: true,
needs_base_update: false,
needs_title_sync: false,
needs_body_sync: false,
},
BookmarkPlan {
bookmark_name: "feat-b".to_string(),
base: "feat-a".to_string(),
title: "feature b".to_string(),
body: None,
existing_pr: None,
needs_push: true,
needs_create: true,
needs_base_update: false,
needs_title_sync: false,
needs_body_sync: false,
},
],
bookmark_creations: vec![],
remote: "my-remote".to_string(),
pr_mode: PrMode::Regular,
default_branch: "main".to_string(),
};
let (runner, push_calls) = MockJjRunner::new();
let jj = Jj::new(runner);
let forge = MockForge::new();
let env = test_comment_env();
execute_submission_plan(&plan, &jj, &forge, &env, StackPlacement::Comment)
.await
.unwrap();
let calls = push_calls.lock().unwrap();
assert_eq!(calls.len(), 2);
assert_eq!(calls[0], ("feat-a".to_string(), "my-remote".to_string()));
assert_eq!(calls[1], ("feat-b".to_string(), "my-remote".to_string()));
}
#[test]
fn plan_display_shows_draft() {
let plan = SubmissionPlan {
bookmark_plans: vec![BookmarkPlan {
bookmark_name: "feat-a".to_string(),
base: "main".to_string(),
title: "feature a".to_string(),
body: None,
existing_pr: None,
needs_push: true,
needs_create: true,
needs_base_update: false,
needs_title_sync: false,
needs_body_sync: false,
}],
bookmark_creations: vec![],
remote: "origin".to_string(),
pr_mode: PrMode::Draft,
default_branch: "main".to_string(),
};
let output = plan.to_string();
assert!(
output.contains("draft"),
"expected 'draft' in plan display: {output}"
);
}
#[tokio::test]
async fn execute_creates_draft_prs() {
let plan = SubmissionPlan {
bookmark_plans: vec![BookmarkPlan {
bookmark_name: "feat-a".to_string(),
base: "main".to_string(),
title: "feature a".to_string(),
body: None,
existing_pr: None,
needs_push: true,
needs_create: true,
needs_base_update: false,
needs_title_sync: false,
needs_body_sync: false,
}],
bookmark_creations: vec![],
remote: "origin".to_string(),
pr_mode: PrMode::Draft,
default_branch: "main".to_string(),
};
let (runner, _push_calls) = MockJjRunner::new();
let jj = Jj::new(runner);
let forge = MockForge::new();
let env = test_comment_env();
execute_submission_plan(&plan, &jj, &forge, &env, StackPlacement::Comment)
.await
.unwrap();
let created = forge.created_prs.lock().unwrap();
assert_eq!(created.len(), 1);
assert!(created[0].draft, "expected PR to be created as draft");
}
#[tokio::test]
async fn execute_syncs_title_and_body_for_existing_pr() {
let plan = SubmissionPlan {
bookmark_plans: vec![BookmarkPlan {
bookmark_name: "feat-a".to_string(),
base: "main".to_string(),
title: "updated title".to_string(),
body: Some("updated body".to_string()),
existing_pr: Some(make_pr(42, "feat-a", "main")),
needs_push: true,
needs_create: false,
needs_base_update: false,
needs_title_sync: true,
needs_body_sync: true,
}],
bookmark_creations: vec![],
remote: "origin".to_string(),
pr_mode: PrMode::Regular,
default_branch: "main".to_string(),
};
let (runner, _push_calls) = MockJjRunner::new();
let jj = Jj::new(runner);
let forge = MockForge::new();
let env = test_comment_env();
execute_submission_plan(&plan, &jj, &forge, &env, StackPlacement::Comment)
.await
.unwrap();
let updated_titles = forge.updated_titles.lock().unwrap();
assert_eq!(updated_titles.len(), 1);
assert_eq!(updated_titles[0], (42, "updated title".to_string()));
let updated_bodies = forge.updated_bodies.lock().unwrap();
assert_eq!(updated_bodies.len(), 1);
assert_eq!(updated_bodies[0], (42, "updated body".to_string()));
}
#[tokio::test]
async fn execute_syncs_clears_body_when_no_commit_body() {
let plan = SubmissionPlan {
bookmark_plans: vec![BookmarkPlan {
bookmark_name: "feat-a".to_string(),
base: "main".to_string(),
title: "title only".to_string(),
body: None,
existing_pr: Some(make_pr(42, "feat-a", "main")),
needs_push: true,
needs_create: false,
needs_base_update: false,
needs_title_sync: true,
needs_body_sync: true,
}],
bookmark_creations: vec![],
remote: "origin".to_string(),
pr_mode: PrMode::Regular,
default_branch: "main".to_string(),
};
let (runner, _push_calls) = MockJjRunner::new();
let jj = Jj::new(runner);
let forge = MockForge::new();
let env = test_comment_env();
execute_submission_plan(&plan, &jj, &forge, &env, StackPlacement::Comment)
.await
.unwrap();
let updated_titles = forge.updated_titles.lock().unwrap();
assert_eq!(updated_titles[0], (42, "title only".to_string()));
let updated_bodies = forge.updated_bodies.lock().unwrap();
assert_eq!(updated_bodies[0], (42, String::new()));
}
#[tokio::test]
async fn execute_no_sync_when_flag_off() {
let plan = SubmissionPlan {
bookmark_plans: vec![BookmarkPlan {
bookmark_name: "feat-a".to_string(),
base: "main".to_string(),
title: "feature a".to_string(),
body: Some("body".to_string()),
existing_pr: Some(make_pr(42, "feat-a", "main")),
needs_push: true,
needs_create: false,
needs_base_update: false,
needs_title_sync: false,
needs_body_sync: false,
}],
bookmark_creations: vec![],
remote: "origin".to_string(),
pr_mode: PrMode::Regular,
default_branch: "main".to_string(),
};
let (runner, _push_calls) = MockJjRunner::new();
let jj = Jj::new(runner);
let forge = MockForge::new();
let env = test_comment_env();
execute_submission_plan(&plan, &jj, &forge, &env, StackPlacement::Comment)
.await
.unwrap();
let updated_titles = forge.updated_titles.lock().unwrap();
assert!(updated_titles.is_empty());
}
#[tokio::test]
async fn execute_body_mode_sync_uses_commit_body_for_fence() {
let plan = SubmissionPlan {
bookmark_plans: vec![
BookmarkPlan {
bookmark_name: "feat-a".to_string(),
base: "main".to_string(),
title: "feature a".to_string(),
body: Some("new commit body".to_string()),
existing_pr: Some(make_pr(10, "feat-a", "main")),
needs_push: true,
needs_create: false,
needs_base_update: false,
needs_title_sync: false,
needs_body_sync: true,
},
BookmarkPlan {
bookmark_name: "feat-b".to_string(),
base: "feat-a".to_string(),
title: "feature b".to_string(),
body: Some("commit body b".to_string()),
existing_pr: Some(make_pr(11, "feat-b", "feat-a")),
needs_push: true,
needs_create: false,
needs_base_update: false,
needs_title_sync: false,
needs_body_sync: true,
},
],
bookmark_creations: vec![],
remote: "origin".to_string(),
pr_mode: PrMode::Regular,
default_branch: "main".to_string(),
};
let (runner, _push_calls) = MockJjRunner::new();
let jj = Jj::new(runner);
let forge = MockForge::new();
let env = test_comment_env();
execute_submission_plan(&plan, &jj, &forge, &env, StackPlacement::Body)
.await
.unwrap();
let updated_bodies = forge.updated_bodies.lock().unwrap();
assert_eq!(updated_bodies.len(), 2);
assert!(updated_bodies[0].1.contains("new commit body"));
assert!(updated_bodies[0].1.contains("STAKK_BODY_START"));
assert!(updated_bodies[1].1.contains("commit body b"));
assert!(updated_bodies[1].1.contains("STAKK_BODY_START"));
}
#[test]
fn build_pr_body_single_commit_with_body() {
let commits = vec![SegmentCommit {
commit_id: "c1".to_string(),
change_id: "ch1".to_string(),
description: "Add feature X\n\nThis adds feature X with foo and bar.".to_string(),
author: crate::jj::types::Signature {
name: "Test".to_string(),
email: "test@test.com".to_string(),
timestamp: "T".to_string(),
},
committer: crate::jj::types::Signature {
name: "Test".to_string(),
email: "test@test.com".to_string(),
timestamp: "T".to_string(),
},
files: vec![],
is_immutable: false,
local_bookmark_names: vec![],
short_change_id: "ch1".to_string(),
}];
let body = build_pr_body(&commits, TrailerHandling::Keep);
assert_eq!(
body.as_deref(),
Some("This adds feature X with foo and bar.")
);
}
#[test]
fn build_pr_body_single_commit_title_only() {
let commits = vec![SegmentCommit {
commit_id: "c1".to_string(),
change_id: "ch1".to_string(),
description: "Add feature X".to_string(),
author: crate::jj::types::Signature {
name: "Test".to_string(),
email: "test@test.com".to_string(),
timestamp: "T".to_string(),
},
committer: crate::jj::types::Signature {
name: "Test".to_string(),
email: "test@test.com".to_string(),
timestamp: "T".to_string(),
},
files: vec![],
is_immutable: false,
local_bookmark_names: vec![],
short_change_id: "ch1".to_string(),
}];
let body = build_pr_body(&commits, TrailerHandling::Keep);
assert_eq!(body, None);
}
#[test]
fn build_pr_body_multiple_commits() {
let commits = vec![
SegmentCommit {
commit_id: "c1".to_string(),
change_id: "ch1".to_string(),
description: "First commit".to_string(),
author: crate::jj::types::Signature {
name: "Test".to_string(),
email: "test@test.com".to_string(),
timestamp: "T".to_string(),
},
committer: crate::jj::types::Signature {
name: "Test".to_string(),
email: "test@test.com".to_string(),
timestamp: "T".to_string(),
},
files: vec![],
is_immutable: false,
local_bookmark_names: vec![],
short_change_id: "ch1".to_string(),
},
SegmentCommit {
commit_id: "c2".to_string(),
change_id: "ch2".to_string(),
description: "Second commit".to_string(),
author: crate::jj::types::Signature {
name: "Test".to_string(),
email: "test@test.com".to_string(),
timestamp: "T".to_string(),
},
committer: crate::jj::types::Signature {
name: "Test".to_string(),
email: "test@test.com".to_string(),
timestamp: "T".to_string(),
},
files: vec![],
is_immutable: false,
local_bookmark_names: vec![],
short_change_id: "ch2".to_string(),
},
];
let body = build_pr_body(&commits, TrailerHandling::Keep);
assert_eq!(
body.as_deref(),
Some("First commit\n\n---\n\nSecond commit")
);
}
#[test]
fn build_pr_body_empty() {
let body = build_pr_body(&[], TrailerHandling::Keep);
assert_eq!(body, None);
}
#[test]
fn build_pr_body_single_commit_strips_trailers() {
let commits = vec![SegmentCommit {
commit_id: "c1".to_string(),
change_id: "ch1".to_string(),
description: "Add feature X\n\nThis adds feature X.\n\nSigned-off-by: Alice \
<a@b>\nRefs: DAT-123"
.to_string(),
author: crate::jj::types::Signature {
name: "Test".to_string(),
email: "test@test.com".to_string(),
timestamp: "T".to_string(),
},
committer: crate::jj::types::Signature {
name: "Test".to_string(),
email: "test@test.com".to_string(),
timestamp: "T".to_string(),
},
files: vec![],
is_immutable: false,
local_bookmark_names: vec![],
short_change_id: "ch1".to_string(),
}];
let body = build_pr_body(&commits, TrailerHandling::Strip);
assert_eq!(body.as_deref(), Some("This adds feature X."));
}
#[test]
fn build_pr_body_single_commit_title_plus_trailers_only() {
let commits = vec![SegmentCommit {
commit_id: "c1".to_string(),
change_id: "ch1".to_string(),
description: "Add feature X\n\nSigned-off-by: Alice <a@b>".to_string(),
author: crate::jj::types::Signature {
name: "Test".to_string(),
email: "test@test.com".to_string(),
timestamp: "T".to_string(),
},
committer: crate::jj::types::Signature {
name: "Test".to_string(),
email: "test@test.com".to_string(),
timestamp: "T".to_string(),
},
files: vec![],
is_immutable: false,
local_bookmark_names: vec![],
short_change_id: "ch1".to_string(),
}];
let body = build_pr_body(&commits, TrailerHandling::Strip);
assert_eq!(body, None);
}
#[test]
fn build_pr_body_multiple_commits_strips_trailers() {
let commits = vec![
SegmentCommit {
commit_id: "c1".to_string(),
change_id: "ch1".to_string(),
description: "First commit\n\nSigned-off-by: Alice <a@b>".to_string(),
author: crate::jj::types::Signature {
name: "Test".to_string(),
email: "test@test.com".to_string(),
timestamp: "T".to_string(),
},
committer: crate::jj::types::Signature {
name: "Test".to_string(),
email: "test@test.com".to_string(),
timestamp: "T".to_string(),
},
files: vec![],
is_immutable: false,
local_bookmark_names: vec![],
short_change_id: "ch1".to_string(),
},
SegmentCommit {
commit_id: "c2".to_string(),
change_id: "ch2".to_string(),
description: "Second commit\n\nWith a body.\n\nRefs: DAT-456".to_string(),
author: crate::jj::types::Signature {
name: "Test".to_string(),
email: "test@test.com".to_string(),
timestamp: "T".to_string(),
},
committer: crate::jj::types::Signature {
name: "Test".to_string(),
email: "test@test.com".to_string(),
timestamp: "T".to_string(),
},
files: vec![],
is_immutable: false,
local_bookmark_names: vec![],
short_change_id: "ch2".to_string(),
},
];
let body = build_pr_body(&commits, TrailerHandling::Strip);
assert_eq!(
body.as_deref(),
Some("First commit\n\n---\n\nSecond commit\n\nWith a body.")
);
}
#[test]
fn build_pr_body_single_commit_keeps_trailers() {
let commits = vec![SegmentCommit {
commit_id: "c1".to_string(),
change_id: "ch1".to_string(),
description: "Add feature X\n\nThis adds feature X.\n\nSigned-off-by: Alice \
<a@b>\nRefs: DAT-123"
.to_string(),
author: crate::jj::types::Signature {
name: "Test".to_string(),
email: "test@test.com".to_string(),
timestamp: "T".to_string(),
},
committer: crate::jj::types::Signature {
name: "Test".to_string(),
email: "test@test.com".to_string(),
timestamp: "T".to_string(),
},
files: vec![],
is_immutable: false,
local_bookmark_names: vec![],
short_change_id: "ch1".to_string(),
}];
let body = build_pr_body(&commits, TrailerHandling::Keep);
assert_eq!(
body.as_deref(),
Some("This adds feature X.\n\nSigned-off-by: Alice <a@b>\nRefs: DAT-123")
);
}
#[test]
fn build_pr_body_single_commit_title_plus_trailers_only_kept() {
let commits = vec![SegmentCommit {
commit_id: "c1".to_string(),
change_id: "ch1".to_string(),
description: "Add feature X\n\nSigned-off-by: Alice <a@b>".to_string(),
author: crate::jj::types::Signature {
name: "Test".to_string(),
email: "test@test.com".to_string(),
timestamp: "T".to_string(),
},
committer: crate::jj::types::Signature {
name: "Test".to_string(),
email: "test@test.com".to_string(),
timestamp: "T".to_string(),
},
files: vec![],
is_immutable: false,
local_bookmark_names: vec![],
short_change_id: "ch1".to_string(),
}];
let body = build_pr_body(&commits, TrailerHandling::Keep);
assert_eq!(body.as_deref(), Some("Signed-off-by: Alice <a@b>"));
}
#[test]
fn build_pr_body_multiple_commits_keeps_trailers() {
let commits = vec![
SegmentCommit {
commit_id: "c1".to_string(),
change_id: "ch1".to_string(),
description: "First commit\n\nSigned-off-by: Alice <a@b>".to_string(),
author: crate::jj::types::Signature {
name: "Test".to_string(),
email: "test@test.com".to_string(),
timestamp: "T".to_string(),
},
committer: crate::jj::types::Signature {
name: "Test".to_string(),
email: "test@test.com".to_string(),
timestamp: "T".to_string(),
},
files: vec![],
is_immutable: false,
local_bookmark_names: vec![],
short_change_id: "ch1".to_string(),
},
SegmentCommit {
commit_id: "c2".to_string(),
change_id: "ch2".to_string(),
description: "Second commit\n\nWith a body.\n\nRefs: DAT-456".to_string(),
author: crate::jj::types::Signature {
name: "Test".to_string(),
email: "test@test.com".to_string(),
timestamp: "T".to_string(),
},
committer: crate::jj::types::Signature {
name: "Test".to_string(),
email: "test@test.com".to_string(),
timestamp: "T".to_string(),
},
files: vec![],
is_immutable: false,
local_bookmark_names: vec![],
short_change_id: "ch2".to_string(),
},
];
let body = build_pr_body(&commits, TrailerHandling::Keep);
assert_eq!(
body.as_deref(),
Some(
"First commit\n\nSigned-off-by: Alice <a@b>\n\n---\n\nSecond commit\n\nWith a \
body.\n\nRefs: DAT-456"
)
);
}
#[tokio::test]
async fn execute_body_mode_creates_fenced_section() {
let plan = SubmissionPlan {
bookmark_plans: vec![
BookmarkPlan {
bookmark_name: "feat-a".to_string(),
base: "main".to_string(),
title: "feature a".to_string(),
body: None,
existing_pr: None,
needs_push: true,
needs_create: true,
needs_base_update: false,
needs_title_sync: false,
needs_body_sync: false,
},
BookmarkPlan {
bookmark_name: "feat-b".to_string(),
base: "feat-a".to_string(),
title: "feature b".to_string(),
body: None,
existing_pr: None,
needs_push: true,
needs_create: true,
needs_base_update: false,
needs_title_sync: false,
needs_body_sync: false,
},
],
bookmark_creations: vec![],
remote: "origin".to_string(),
pr_mode: PrMode::Regular,
default_branch: "main".to_string(),
};
let (runner, _push_calls) = MockJjRunner::new();
let jj = Jj::new(runner);
let forge = MockForge::new();
let env = test_comment_env();
execute_submission_plan(&plan, &jj, &forge, &env, StackPlacement::Body)
.await
.unwrap();
let updated_bodies = forge.updated_bodies.lock().unwrap();
assert_eq!(updated_bodies.len(), 2);
assert!(
updated_bodies[0].1.contains("STAKK_BODY_START"),
"expected body fence: {}",
updated_bodies[0].1
);
assert!(
updated_bodies[0].1.contains("STAKK_STACK"),
"expected stack metadata in body: {}",
updated_bodies[0].1
);
let created_comments = forge.created_comments.lock().unwrap();
assert_eq!(created_comments.len(), 0);
}
#[tokio::test]
async fn execute_body_mode_updates_existing_fence() {
use crate::forge::comment::splice_stack_into_body;
let existing_body = splice_stack_into_body("Original PR body", "old stack content");
let plan = SubmissionPlan {
bookmark_plans: vec![
BookmarkPlan {
bookmark_name: "feat-a".to_string(),
base: "main".to_string(),
title: "feature a".to_string(),
body: None,
existing_pr: Some(make_pr_with_body(50, "feat-a", "main", &existing_body)),
needs_push: true,
needs_create: false,
needs_base_update: false,
needs_title_sync: false,
needs_body_sync: false,
},
BookmarkPlan {
bookmark_name: "feat-b".to_string(),
base: "feat-a".to_string(),
title: "feature b".to_string(),
body: None,
existing_pr: None,
needs_push: true,
needs_create: true,
needs_base_update: false,
needs_title_sync: false,
needs_body_sync: false,
},
],
bookmark_creations: vec![],
remote: "origin".to_string(),
pr_mode: PrMode::Regular,
default_branch: "main".to_string(),
};
let (runner, _push_calls) = MockJjRunner::new();
let jj = Jj::new(runner);
let forge = MockForge::new();
let env = test_comment_env();
execute_submission_plan(&plan, &jj, &forge, &env, StackPlacement::Body)
.await
.unwrap();
let updated_bodies = forge.updated_bodies.lock().unwrap();
assert_eq!(updated_bodies.len(), 2);
assert!(updated_bodies[0].1.contains("Original PR body"));
assert!(!updated_bodies[0].1.contains("old stack content"));
assert!(updated_bodies[0].1.contains("STAKK_STACK"));
let created_comments = forge.created_comments.lock().unwrap();
assert_eq!(created_comments.len(), 0);
let deleted = forge.deleted_comments.lock().unwrap();
assert_eq!(deleted.len(), 0);
}
#[tokio::test]
async fn execute_body_mode_migration_deletes_old_comment() {
let env = test_comment_env();
let tmpl = env.get_template("stack_comment").unwrap();
let old_comment_body = format_stack_comment(
&StackCommentData {
version: 0,
stack: vec![StackEntry {
bookmark_name: "feat-a".to_string(),
pr_url: "https://example.com/1".to_string(),
pr_number: 50,
}],
},
&StackCommentContext {
stack: vec![StackEntryContext {
bookmark_name: "feat-a".to_string(),
pr_url: "https://example.com/1".to_string(),
pr_number: 50,
title: "feature a".to_string(),
base: "main".to_string(),
is_draft: false,
position: 1,
is_current: true,
}],
stack_size: 1,
default_branch: "main".to_string(),
current_bookmark: "feat-a".to_string(),
stakk_url: STAKK_REPO_URL.to_string(),
},
&tmpl,
)
.unwrap();
let plan = SubmissionPlan {
bookmark_plans: vec![
BookmarkPlan {
bookmark_name: "feat-a".to_string(),
base: "main".to_string(),
title: "feature a".to_string(),
body: None,
existing_pr: Some(make_pr_with_body(50, "feat-a", "main", "Plain body")),
needs_push: true,
needs_create: false,
needs_base_update: false,
needs_title_sync: false,
needs_body_sync: false,
},
BookmarkPlan {
bookmark_name: "feat-b".to_string(),
base: "feat-a".to_string(),
title: "feature b".to_string(),
body: None,
existing_pr: None,
needs_push: true,
needs_create: true,
needs_base_update: false,
needs_title_sync: false,
needs_body_sync: false,
},
],
bookmark_creations: vec![],
remote: "origin".to_string(),
pr_mode: PrMode::Regular,
default_branch: "main".to_string(),
};
let (runner, _push_calls) = MockJjRunner::new();
let jj = Jj::new(runner);
let forge = MockForge::new().with_existing_comments(
50,
vec![Comment {
id: 999,
body: old_comment_body,
}],
);
execute_submission_plan(&plan, &jj, &forge, &env, StackPlacement::Body)
.await
.unwrap();
let updated_bodies = forge.updated_bodies.lock().unwrap();
assert_eq!(updated_bodies.len(), 2);
assert!(updated_bodies[0].1.contains("STAKK_BODY_START"));
let deleted = forge.deleted_comments.lock().unwrap();
assert_eq!(deleted.len(), 1);
assert_eq!(deleted[0], 999);
}
#[tokio::test]
async fn execute_comment_mode_migration_strips_body() {
use crate::forge::comment::splice_stack_into_body;
let body_with_fence = splice_stack_into_body("Original PR body", "old stack content");
let plan = SubmissionPlan {
bookmark_plans: vec![
BookmarkPlan {
bookmark_name: "feat-a".to_string(),
base: "main".to_string(),
title: "feature a".to_string(),
body: None,
existing_pr: Some(make_pr_with_body(50, "feat-a", "main", &body_with_fence)),
needs_push: true,
needs_create: false,
needs_base_update: false,
needs_title_sync: false,
needs_body_sync: false,
},
BookmarkPlan {
bookmark_name: "feat-b".to_string(),
base: "feat-a".to_string(),
title: "feature b".to_string(),
body: None,
existing_pr: None,
needs_push: true,
needs_create: true,
needs_base_update: false,
needs_title_sync: false,
needs_body_sync: false,
},
],
bookmark_creations: vec![],
remote: "origin".to_string(),
pr_mode: PrMode::Regular,
default_branch: "main".to_string(),
};
let (runner, _push_calls) = MockJjRunner::new();
let jj = Jj::new(runner);
let forge = MockForge::new();
let env = test_comment_env();
execute_submission_plan(&plan, &jj, &forge, &env, StackPlacement::Comment)
.await
.unwrap();
let created_comments = forge.created_comments.lock().unwrap();
assert_eq!(created_comments.len(), 2);
assert!(created_comments[0].1.contains("STAKK_STACK"));
let updated_bodies = forge.updated_bodies.lock().unwrap();
assert_eq!(updated_bodies.len(), 1);
assert!(
!updated_bodies[0].1.contains("STAKK_BODY_START"),
"fence should be stripped: {}",
updated_bodies[0].1
);
assert!(updated_bodies[0].1.contains("Original PR body"));
}
#[tokio::test]
async fn execute_single_bookmark_skips_stack_comment() {
let plan = SubmissionPlan {
bookmark_plans: vec![BookmarkPlan {
bookmark_name: "feat-a".to_string(),
base: "main".to_string(),
title: "feature a".to_string(),
body: None,
existing_pr: None,
needs_push: true,
needs_create: true,
needs_base_update: false,
needs_title_sync: false,
needs_body_sync: false,
}],
bookmark_creations: vec![],
remote: "origin".to_string(),
pr_mode: PrMode::Regular,
default_branch: "main".to_string(),
};
let (runner, _push_calls) = MockJjRunner::new();
let jj = Jj::new(runner);
let forge = MockForge::new();
let env = test_comment_env();
let result = execute_submission_plan(&plan, &jj, &forge, &env, StackPlacement::Comment)
.await
.unwrap();
assert_eq!(result.stack_entries.len(), 1);
let created_prs = forge.created_prs.lock().unwrap();
assert_eq!(created_prs.len(), 1);
let created_comments = forge.created_comments.lock().unwrap();
assert_eq!(created_comments.len(), 0);
let updated_bodies = forge.updated_bodies.lock().unwrap();
assert_eq!(updated_bodies.len(), 0);
}
#[tokio::test]
async fn execute_single_bookmark_cleans_up_old_comment() {
let env = test_comment_env();
let tmpl = env.get_template("stack_comment").unwrap();
let old_comment_body = format_stack_comment(
&StackCommentData {
version: 0,
stack: vec![StackEntry {
bookmark_name: "feat-a".to_string(),
pr_url: "https://example.com/1".to_string(),
pr_number: 50,
}],
},
&StackCommentContext {
stack: vec![StackEntryContext {
bookmark_name: "feat-a".to_string(),
pr_url: "https://example.com/1".to_string(),
pr_number: 50,
title: "feature a".to_string(),
base: "main".to_string(),
is_draft: false,
position: 1,
is_current: true,
}],
stack_size: 1,
default_branch: "main".to_string(),
current_bookmark: "feat-a".to_string(),
stakk_url: STAKK_REPO_URL.to_string(),
},
&tmpl,
)
.unwrap();
let plan = SubmissionPlan {
bookmark_plans: vec![BookmarkPlan {
bookmark_name: "feat-a".to_string(),
base: "main".to_string(),
title: "feature a".to_string(),
body: None,
existing_pr: Some(make_pr(50, "feat-a", "main")),
needs_push: true,
needs_create: false,
needs_base_update: false,
needs_title_sync: false,
needs_body_sync: false,
}],
bookmark_creations: vec![],
remote: "origin".to_string(),
pr_mode: PrMode::Regular,
default_branch: "main".to_string(),
};
let (runner, _push_calls) = MockJjRunner::new();
let jj = Jj::new(runner);
let forge = MockForge::new().with_existing_comments(
50,
vec![Comment {
id: 999,
body: old_comment_body,
}],
);
execute_submission_plan(&plan, &jj, &forge, &env, StackPlacement::Comment)
.await
.unwrap();
let deleted = forge.deleted_comments.lock().unwrap();
assert_eq!(deleted.len(), 1);
assert_eq!(deleted[0], 999);
let created = forge.created_comments.lock().unwrap();
assert_eq!(created.len(), 0);
}
#[tokio::test]
async fn execute_single_bookmark_cleans_up_old_body_fence() {
use crate::forge::comment::splice_stack_into_body;
let body_with_fence = splice_stack_into_body("Original PR body", "old stack content");
let plan = SubmissionPlan {
bookmark_plans: vec![BookmarkPlan {
bookmark_name: "feat-a".to_string(),
base: "main".to_string(),
title: "feature a".to_string(),
body: None,
existing_pr: Some(make_pr_with_body(50, "feat-a", "main", &body_with_fence)),
needs_push: true,
needs_create: false,
needs_base_update: false,
needs_title_sync: false,
needs_body_sync: false,
}],
bookmark_creations: vec![],
remote: "origin".to_string(),
pr_mode: PrMode::Regular,
default_branch: "main".to_string(),
};
let (runner, _push_calls) = MockJjRunner::new();
let jj = Jj::new(runner);
let forge = MockForge::new();
let env = test_comment_env();
execute_submission_plan(&plan, &jj, &forge, &env, StackPlacement::Body)
.await
.unwrap();
let updated_bodies = forge.updated_bodies.lock().unwrap();
assert_eq!(updated_bodies.len(), 1);
assert!(
!updated_bodies[0].1.contains("STAKK_BODY_START"),
"fence should be stripped: {}",
updated_bodies[0].1
);
assert!(updated_bodies[0].1.contains("Original PR body"));
let created = forge.created_comments.lock().unwrap();
assert_eq!(created.len(), 0);
}
#[tokio::test]
async fn execute_none_placement_writes_no_comments_or_bodies() {
let plan = SubmissionPlan {
bookmark_plans: vec![
BookmarkPlan {
bookmark_name: "feat-a".to_string(),
base: "main".to_string(),
title: "feature a".to_string(),
body: None,
existing_pr: None,
needs_push: true,
needs_create: true,
needs_base_update: false,
needs_title_sync: false,
needs_body_sync: false,
},
BookmarkPlan {
bookmark_name: "feat-b".to_string(),
base: "feat-a".to_string(),
title: "feature b".to_string(),
body: None,
existing_pr: None,
needs_push: true,
needs_create: true,
needs_base_update: false,
needs_title_sync: false,
needs_body_sync: false,
},
],
bookmark_creations: vec![],
remote: "origin".to_string(),
pr_mode: PrMode::Regular,
default_branch: "main".to_string(),
};
let (runner, _push_calls) = MockJjRunner::new();
let jj = Jj::new(runner);
let forge = MockForge::new();
let env = test_comment_env();
let result = execute_submission_plan(&plan, &jj, &forge, &env, StackPlacement::None)
.await
.unwrap();
assert_eq!(result.stack_entries.len(), 2);
let created_prs = forge.created_prs.lock().unwrap();
assert_eq!(created_prs.len(), 2);
let created_comments = forge.created_comments.lock().unwrap();
assert_eq!(created_comments.len(), 0);
let updated_comments = forge.updated_comments.lock().unwrap();
assert_eq!(updated_comments.len(), 0);
let updated_bodies = forge.updated_bodies.lock().unwrap();
assert_eq!(updated_bodies.len(), 0);
let listed = forge.listed_comments.lock().unwrap();
assert!(listed.is_empty(), "unexpected comment lookups: {listed:?}");
}
#[tokio::test]
async fn execute_none_placement_cleans_up_existing_artifacts() {
use crate::forge::comment::splice_stack_into_body;
let body_with_fence = splice_stack_into_body("Original PR body", "old stack content");
let env = test_comment_env();
let tmpl = env.get_template("stack_comment").unwrap();
let old_comment_body = format_stack_comment(
&StackCommentData {
version: 0,
stack: vec![StackEntry {
bookmark_name: "feat-a".to_string(),
pr_url: "https://example.com/1".to_string(),
pr_number: 50,
}],
},
&StackCommentContext {
stack: vec![StackEntryContext {
bookmark_name: "feat-a".to_string(),
pr_url: "https://example.com/1".to_string(),
pr_number: 50,
title: "feature a".to_string(),
base: "main".to_string(),
is_draft: false,
position: 1,
is_current: true,
}],
stack_size: 1,
default_branch: "main".to_string(),
current_bookmark: "feat-a".to_string(),
stakk_url: STAKK_REPO_URL.to_string(),
},
&tmpl,
)
.unwrap();
let plan = SubmissionPlan {
bookmark_plans: vec![
BookmarkPlan {
bookmark_name: "feat-a".to_string(),
base: "main".to_string(),
title: "feature a".to_string(),
body: None,
existing_pr: Some(make_pr_with_body(50, "feat-a", "main", &body_with_fence)),
needs_push: true,
needs_create: false,
needs_base_update: false,
needs_title_sync: false,
needs_body_sync: false,
},
BookmarkPlan {
bookmark_name: "feat-b".to_string(),
base: "feat-a".to_string(),
title: "feature b".to_string(),
body: None,
existing_pr: None,
needs_push: true,
needs_create: true,
needs_base_update: false,
needs_title_sync: false,
needs_body_sync: false,
},
],
bookmark_creations: vec![],
remote: "origin".to_string(),
pr_mode: PrMode::Regular,
default_branch: "main".to_string(),
};
let (runner, _push_calls) = MockJjRunner::new();
let jj = Jj::new(runner);
let forge = MockForge::new().with_existing_comments(
50,
vec![Comment {
id: 999,
body: old_comment_body,
}],
);
execute_submission_plan(&plan, &jj, &forge, &env, StackPlacement::None)
.await
.unwrap();
let deleted = forge.deleted_comments.lock().unwrap();
assert_eq!(deleted.len(), 1);
assert_eq!(deleted[0], 999);
let updated_bodies = forge.updated_bodies.lock().unwrap();
assert_eq!(updated_bodies.len(), 1);
assert!(
!updated_bodies[0].1.contains("STAKK_BODY_START"),
"fence should be stripped: {}",
updated_bodies[0].1
);
assert!(updated_bodies[0].1.contains("Original PR body"));
let created = forge.created_comments.lock().unwrap();
assert_eq!(created.len(), 0);
let listed = forge.listed_comments.lock().unwrap();
assert_eq!(*listed, vec![50]);
}
#[tokio::test]
async fn execute_ignore_placement_touches_no_artifacts() {
use crate::forge::comment::splice_stack_into_body;
let body_with_fence = splice_stack_into_body("Original PR body", "old stack content");
let plan = SubmissionPlan {
bookmark_plans: vec![
BookmarkPlan {
bookmark_name: "feat-a".to_string(),
base: "main".to_string(),
title: "feature a".to_string(),
body: None,
existing_pr: Some(make_pr_with_body(50, "feat-a", "main", &body_with_fence)),
needs_push: true,
needs_create: false,
needs_base_update: false,
needs_title_sync: false,
needs_body_sync: false,
},
BookmarkPlan {
bookmark_name: "feat-b".to_string(),
base: "feat-a".to_string(),
title: "feature b".to_string(),
body: None,
existing_pr: None,
needs_push: true,
needs_create: true,
needs_base_update: false,
needs_title_sync: false,
needs_body_sync: false,
},
],
bookmark_creations: vec![],
remote: "origin".to_string(),
pr_mode: PrMode::Regular,
default_branch: "main".to_string(),
};
let (runner, _push_calls) = MockJjRunner::new();
let jj = Jj::new(runner);
let forge = MockForge::new().with_existing_comments(
50,
vec![Comment {
id: 999,
body: "<!--- STAKK_STACK: e30= --->\nold stack comment".to_string(),
}],
);
let env = test_comment_env();
let result = execute_submission_plan(&plan, &jj, &forge, &env, StackPlacement::Ignore)
.await
.unwrap();
assert_eq!(result.stack_entries.len(), 2);
assert_eq!(forge.created_prs.lock().unwrap().len(), 1);
assert!(forge.created_comments.lock().unwrap().is_empty());
assert!(forge.updated_comments.lock().unwrap().is_empty());
assert!(forge.updated_bodies.lock().unwrap().is_empty());
assert!(forge.deleted_comments.lock().unwrap().is_empty());
assert!(forge.listed_comments.lock().unwrap().is_empty());
}
#[tokio::test]
async fn execute_ignore_single_bookmark_skips_cleanup() {
let plan = SubmissionPlan {
bookmark_plans: vec![BookmarkPlan {
bookmark_name: "feat-a".to_string(),
base: "main".to_string(),
title: "feature a".to_string(),
body: None,
existing_pr: Some(make_pr(50, "feat-a", "main")),
needs_push: true,
needs_create: false,
needs_base_update: false,
needs_title_sync: false,
needs_body_sync: false,
}],
bookmark_creations: vec![],
remote: "origin".to_string(),
pr_mode: PrMode::Regular,
default_branch: "main".to_string(),
};
let (runner, _push_calls) = MockJjRunner::new();
let jj = Jj::new(runner);
let forge = MockForge::new().with_existing_comments(
50,
vec![Comment {
id: 999,
body: "<!--- STAKK_STACK: e30= --->\nold stack comment".to_string(),
}],
);
let env = test_comment_env();
execute_submission_plan(&plan, &jj, &forge, &env, StackPlacement::Ignore)
.await
.unwrap();
assert!(forge.deleted_comments.lock().unwrap().is_empty());
assert!(forge.listed_comments.lock().unwrap().is_empty());
}
#[tokio::test]
async fn execute_interleaves_push_and_base_update() {
let ops: OpLog = Arc::new(Mutex::new(Vec::new()));
let plan = SubmissionPlan {
bookmark_plans: vec![
BookmarkPlan {
bookmark_name: "feat-a".to_string(),
base: "main".to_string(),
title: "feature a".to_string(),
body: None,
existing_pr: Some(make_pr(10, "feat-a", "feat-b")),
needs_push: true,
needs_create: false,
needs_base_update: true,
needs_title_sync: false,
needs_body_sync: false,
},
BookmarkPlan {
bookmark_name: "feat-b".to_string(),
base: "feat-a".to_string(),
title: "feature b".to_string(),
body: None,
existing_pr: Some(make_pr(11, "feat-b", "main")),
needs_push: true,
needs_create: false,
needs_base_update: true,
needs_title_sync: false,
needs_body_sync: false,
},
],
bookmark_creations: vec![],
remote: "origin".to_string(),
pr_mode: PrMode::Draft,
default_branch: "main".to_string(),
};
let (runner, _push_calls) = MockJjRunner::new_with_ops(Arc::clone(&ops));
let jj = Jj::new(runner);
let forge = MockForge::new()
.with_existing_pr("feat-a", make_pr(10, "feat-a", "feat-b"))
.with_existing_pr("feat-b", make_pr(11, "feat-b", "main"))
.with_ops(Arc::clone(&ops));
let env = test_comment_env();
execute_submission_plan(&plan, &jj, &forge, &env, StackPlacement::Comment)
.await
.unwrap();
let ops = ops.lock().unwrap();
assert_eq!(
*ops,
vec![
Op::Push("feat-a".to_string()),
Op::BaseUpdate(10),
Op::Push("feat-b".to_string()),
Op::BaseUpdate(11),
],
"each bookmark must be pushed and have its base updated before the next bookmark is \
pushed (prevents transient empty diffs)"
);
}
#[tokio::test]
async fn execute_interleaves_three_bookmark_reorder() {
let ops: OpLog = Arc::new(Mutex::new(Vec::new()));
let plan = SubmissionPlan {
bookmark_plans: vec![
BookmarkPlan {
bookmark_name: "feat-a".to_string(),
base: "main".to_string(),
title: "feature a".to_string(),
body: None,
existing_pr: Some(make_pr(10, "feat-a", "feat-c")),
needs_push: true,
needs_create: false,
needs_base_update: true,
needs_title_sync: false,
needs_body_sync: false,
},
BookmarkPlan {
bookmark_name: "feat-b".to_string(),
base: "feat-a".to_string(),
title: "feature b".to_string(),
body: None,
existing_pr: Some(make_pr(11, "feat-b", "main")),
needs_push: true,
needs_create: false,
needs_base_update: true,
needs_title_sync: false,
needs_body_sync: false,
},
BookmarkPlan {
bookmark_name: "feat-c".to_string(),
base: "feat-b".to_string(),
title: "feature c".to_string(),
body: None,
existing_pr: Some(make_pr(12, "feat-c", "feat-a")),
needs_push: true,
needs_create: false,
needs_base_update: true,
needs_title_sync: false,
needs_body_sync: false,
},
],
bookmark_creations: vec![],
remote: "origin".to_string(),
pr_mode: PrMode::Draft,
default_branch: "main".to_string(),
};
let (runner, _push_calls) = MockJjRunner::new_with_ops(Arc::clone(&ops));
let jj = Jj::new(runner);
let forge = MockForge::new()
.with_existing_pr("feat-a", make_pr(10, "feat-a", "feat-c"))
.with_existing_pr("feat-b", make_pr(11, "feat-b", "main"))
.with_existing_pr("feat-c", make_pr(12, "feat-c", "feat-a"))
.with_ops(Arc::clone(&ops));
let env = test_comment_env();
execute_submission_plan(&plan, &jj, &forge, &env, StackPlacement::Comment)
.await
.unwrap();
let ops = ops.lock().unwrap();
assert_eq!(
*ops,
vec![
Op::Push("feat-a".to_string()),
Op::BaseUpdate(10),
Op::Push("feat-b".to_string()),
Op::BaseUpdate(11),
Op::Push("feat-c".to_string()),
Op::BaseUpdate(12),
],
"strict interleaving: push(i), update(i), push(i+1), update(i+1), ..."
);
}
#[tokio::test]
async fn execute_interleaves_push_update_and_create() {
let ops: OpLog = Arc::new(Mutex::new(Vec::new()));
let plan = SubmissionPlan {
bookmark_plans: vec![
BookmarkPlan {
bookmark_name: "feat-a".to_string(),
base: "main".to_string(),
title: "feature a".to_string(),
body: None,
existing_pr: Some(make_pr(10, "feat-a", "feat-b")),
needs_push: true,
needs_create: false,
needs_base_update: true,
needs_title_sync: false,
needs_body_sync: false,
},
BookmarkPlan {
bookmark_name: "feat-b".to_string(),
base: "feat-a".to_string(),
title: "feature b".to_string(),
body: None,
existing_pr: None,
needs_push: true,
needs_create: true,
needs_base_update: false,
needs_title_sync: false,
needs_body_sync: false,
},
],
bookmark_creations: vec![],
remote: "origin".to_string(),
pr_mode: PrMode::Draft,
default_branch: "main".to_string(),
};
let (runner, _push_calls) = MockJjRunner::new_with_ops(Arc::clone(&ops));
let jj = Jj::new(runner);
let forge = MockForge::new()
.with_existing_pr("feat-a", make_pr(10, "feat-a", "feat-b"))
.with_ops(Arc::clone(&ops));
let env = test_comment_env();
execute_submission_plan(&plan, &jj, &forge, &env, StackPlacement::Comment)
.await
.unwrap();
let ops = ops.lock().unwrap();
assert_eq!(
*ops,
vec![
Op::Push("feat-a".to_string()),
Op::BaseUpdate(10),
Op::Push("feat-b".to_string()),
Op::CreatePr("feat-b".to_string()),
],
"base update for feat-a must complete before feat-b is pushed"
);
}
}