1use crate::error::{Error, Result};
6use crate::platform::PlatformService;
7use crate::repo::JjWorkspace;
8use crate::submit::plan::{PrBaseUpdate, PrToCreate};
9use crate::submit::{ExecutionStep, Phase, ProgressCallback, PushStatus, SubmissionPlan};
10use crate::types::{Bookmark, Platform, PullRequest};
11use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14use std::fmt::Write;
15
16#[derive(Debug, Clone, Default)]
18pub struct SubmissionResult {
19 pub success: bool,
21 pub created_prs: Vec<PullRequest>,
23 pub updated_prs: Vec<PullRequest>,
25 pub pushed_bookmarks: Vec<String>,
27 pub errors: Vec<String>,
29}
30
31impl SubmissionResult {
32 pub fn new() -> Self {
34 Self {
35 success: true,
36 ..Default::default()
37 }
38 }
39
40 pub fn fail(&mut self, error: String) {
42 self.errors.push(error);
43 self.success = false;
44 }
45
46 pub fn soft_fail(&mut self, error: String) {
48 self.errors.push(error);
49 }
50}
51
52#[derive(Debug)]
54pub enum StepOutcome {
55 Success(Option<(String, PullRequest)>),
57 FatalError(String),
59 SoftError(String),
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
65pub struct StackCommentData {
66 pub version: u8,
68 pub stack: Vec<StackItem>,
70 pub base_branch: String,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
76pub struct StackItem {
77 pub bookmark_name: String,
79 pub pr_url: String,
81 pub pr_number: u64,
83 pub pr_title: String,
85}
86
87pub const COMMENT_DATA_PREFIX: &str = "<!--- JJ-RYU_STACK: ";
89const COMMENT_DATA_PREFIX_OLD: &str = "<!--- JJ-STACK_INFO: ";
90pub const COMMENT_DATA_POSTFIX: &str = " --->";
92pub const STACK_COMMENT_THIS_PR: &str = "👈";
94
95pub fn execute_push(workspace: &mut JjWorkspace, bookmark: &Bookmark, remote: &str) -> StepOutcome {
101 match workspace.git_push(&bookmark.name, remote) {
102 Ok(()) => StepOutcome::Success(None),
103 Err(e) => StepOutcome::FatalError(format!("Failed to push {}: {e}", bookmark.name)),
104 }
105}
106
107pub async fn execute_update_base(
109 platform: &dyn PlatformService,
110 update: &PrBaseUpdate,
111) -> StepOutcome {
112 match platform
113 .update_pr_base(update.pr.number, &update.expected_base)
114 .await
115 {
116 Ok(updated_pr) => StepOutcome::Success(Some((update.bookmark.name.clone(), updated_pr))),
117 Err(e) => StepOutcome::FatalError(format!(
118 "Failed to update PR base for {}: {e}",
119 update.bookmark.name
120 )),
121 }
122}
123
124pub async fn execute_create_pr(platform: &dyn PlatformService, create: &PrToCreate) -> StepOutcome {
126 match platform
127 .create_pr_with_options(
128 &create.bookmark.name,
129 &create.base_branch,
130 &create.title,
131 create.draft,
132 )
133 .await
134 {
135 Ok(pr) => StepOutcome::Success(Some((create.bookmark.name.clone(), pr))),
136 Err(e) => StepOutcome::FatalError(format!(
137 "Failed to create PR for {}: {e}",
138 create.bookmark.name
139 )),
140 }
141}
142
143pub async fn execute_publish_pr(platform: &dyn PlatformService, pr: &PullRequest) -> StepOutcome {
145 match platform.publish_pr(pr.number).await {
146 Ok(updated_pr) => StepOutcome::Success(Some((pr.head_ref.clone(), updated_pr))),
147 Err(e) => StepOutcome::SoftError(format!("Failed to publish PR #{}: {e}", pr.number)),
148 }
149}
150
151pub async fn execute_submission(
164 plan: &SubmissionPlan,
165 workspace: &mut JjWorkspace,
166 platform: &dyn PlatformService,
167 progress: &dyn ProgressCallback,
168 dry_run: bool,
169) -> Result<SubmissionResult> {
170 let mut result = SubmissionResult::new();
171
172 if dry_run {
173 progress
174 .on_message("Dry run - no changes will be made")
175 .await;
176 report_dry_run(plan, progress).await;
177 return Ok(result);
178 }
179
180 let mut bookmark_to_pr: HashMap<String, PullRequest> = plan.existing_prs.clone();
182
183 progress.on_phase(Phase::Executing).await;
185
186 for step in &plan.execution_steps {
187 let outcome = execute_step(step, workspace, platform, &plan.remote, progress).await;
188
189 match outcome {
190 StepOutcome::Success(Some((bookmark, pr))) => {
191 match step {
193 ExecutionStep::CreatePr(_) => result.created_prs.push(pr.clone()),
194 ExecutionStep::UpdateBase(_) | ExecutionStep::PublishPr(_) => {
195 result.updated_prs.push(pr.clone());
196 }
197 ExecutionStep::Push(_) => {}
198 }
199 bookmark_to_pr.insert(bookmark, pr);
200 }
201 StepOutcome::Success(None) => {
202 if let ExecutionStep::Push(bm) = step {
204 result.pushed_bookmarks.push(bm.name.clone());
205 }
206 }
207 StepOutcome::FatalError(msg) => {
208 progress.on_error(&Error::Platform(msg.clone())).await;
209 result.fail(msg);
210 return Ok(result);
211 }
212 StepOutcome::SoftError(msg) => {
213 progress.on_error(&Error::Platform(msg.clone())).await;
214 result.soft_fail(msg);
215 }
216 }
217 }
218
219 progress.on_phase(Phase::AddingComments).await;
221
222 if !bookmark_to_pr.is_empty() {
223 let stack_data = build_stack_comment_data(plan, &bookmark_to_pr);
224
225 for (idx, item) in stack_data.stack.iter().enumerate() {
226 if let Err(e) =
227 create_or_update_stack_comment(platform, &stack_data, idx, item.pr_number).await
228 {
229 let msg = format!(
230 "Failed to update stack comment for {}: {e}",
231 item.bookmark_name
232 );
233 progress.on_error(&Error::Platform(msg.clone())).await;
234 result.soft_fail(msg);
235 }
236 }
237 }
238
239 progress.on_phase(Phase::Complete).await;
240
241 Ok(result)
242}
243
244async fn execute_step(
246 step: &ExecutionStep,
247 workspace: &mut JjWorkspace,
248 platform: &dyn PlatformService,
249 remote: &str,
250 progress: &dyn ProgressCallback,
251) -> StepOutcome {
252 match step {
253 ExecutionStep::Push(bookmark) => {
254 progress
255 .on_bookmark_push(&bookmark.name, PushStatus::Started)
256 .await;
257
258 let outcome = execute_push(workspace, bookmark, remote);
259
260 match &outcome {
261 StepOutcome::Success(_) => {
262 progress
263 .on_bookmark_push(&bookmark.name, PushStatus::Success)
264 .await;
265 }
266 StepOutcome::FatalError(msg) | StepOutcome::SoftError(msg) => {
267 progress
268 .on_bookmark_push(&bookmark.name, PushStatus::Failed(msg.clone()))
269 .await;
270 }
271 }
272
273 outcome
274 }
275
276 ExecutionStep::UpdateBase(update) => {
277 progress
278 .on_message(&format!(
279 "Updating {} base: {} → {}",
280 update.bookmark.name, update.current_base, update.expected_base
281 ))
282 .await;
283
284 let outcome = execute_update_base(platform, update).await;
285
286 if let StepOutcome::Success(Some((bookmark, pr))) = &outcome {
287 progress.on_pr_updated(bookmark, pr).await;
288 }
289
290 outcome
291 }
292
293 ExecutionStep::CreatePr(create) => {
294 let draft_str = if create.draft { " [draft]" } else { "" };
295 progress
296 .on_message(&format!(
297 "Creating PR for {} (base: {}){draft_str}",
298 create.bookmark.name, create.base_branch
299 ))
300 .await;
301
302 let outcome = execute_create_pr(platform, create).await;
303
304 if let StepOutcome::Success(Some((bookmark, pr))) = &outcome {
305 progress.on_pr_created(bookmark, pr).await;
306 }
307
308 outcome
309 }
310
311 ExecutionStep::PublishPr(pr) => {
312 progress
313 .on_message(&format!("Publishing PR #{} ({})", pr.number, pr.head_ref))
314 .await;
315
316 execute_publish_pr(platform, pr).await
317 }
318 }
319}
320
321async fn report_dry_run(plan: &SubmissionPlan, progress: &dyn ProgressCallback) {
327 if plan.execution_steps.is_empty() {
328 progress.on_message("Nothing to do - already in sync").await;
329 return;
330 }
331
332 progress.on_message("Would execute:").await;
333 for step in &plan.execution_steps {
334 let msg = format_step_for_dry_run(step, &plan.remote);
335 progress.on_message(&msg).await;
336 }
337}
338
339pub fn format_step_for_dry_run(step: &ExecutionStep, remote: &str) -> String {
341 match step {
342 ExecutionStep::Push(bm) => format!(" → push {} to {}", bm.name, remote),
344 _ => format!(" → {step}"),
346 }
347}
348
349#[allow(clippy::implicit_hasher)]
355pub fn build_stack_comment_data(
356 plan: &SubmissionPlan,
357 bookmark_to_pr: &HashMap<String, PullRequest>,
358) -> StackCommentData {
359 let stack: Vec<StackItem> = plan
360 .segments
361 .iter()
362 .filter_map(|seg| {
363 bookmark_to_pr.get(&seg.bookmark.name).map(|pr| StackItem {
364 bookmark_name: seg.bookmark.name.clone(),
365 pr_url: pr.html_url.clone(),
366 pr_number: pr.number,
367 pr_title: pr.title.clone(),
368 })
369 })
370 .collect();
371
372 StackCommentData {
373 version: 1,
374 stack,
375 base_branch: plan.default_branch.clone(),
376 }
377}
378
379pub fn format_stack_comment(data: &StackCommentData, current_idx: usize) -> Result<String> {
383 format_stack_comment_for_platform(data, current_idx, Platform::GitHub)
384}
385
386fn format_stack_comment_for_platform(
391 data: &StackCommentData,
392 current_idx: usize,
393 platform: Platform,
394) -> Result<String> {
395 let encoded_data = BASE64.encode(
396 serde_json::to_string(data)
397 .map_err(|e| Error::Internal(format!("Failed to serialize stack data: {e}")))?,
398 );
399
400 let mut body = format!("{COMMENT_DATA_PREFIX}{encoded_data}{COMMENT_DATA_POSTFIX}\n");
401
402 let reversed_idx = data.stack.len() - 1 - current_idx;
404 for (i, item) in data.stack.iter().rev().enumerate() {
405 let is_current = i == reversed_idx;
406 match platform {
407 Platform::GitHub => {
408 if is_current {
410 let _ = writeln!(body, "* **#{} {STACK_COMMENT_THIS_PR}**", item.pr_number);
411 } else {
412 let _ = writeln!(body, "* #{}", item.pr_number);
413 }
414 }
415 Platform::GitLab => {
416 if is_current {
418 let _ = writeln!(
419 body,
420 "* **[{} !{}]({}) {STACK_COMMENT_THIS_PR}**",
421 item.pr_title, item.pr_number, item.pr_url
422 );
423 } else {
424 let _ = writeln!(
425 body,
426 "* [{} !{}]({})",
427 item.pr_title, item.pr_number, item.pr_url
428 );
429 }
430 }
431 }
432 }
433
434 let _ = writeln!(body, "* `{}`", data.base_branch);
436
437 let _ = write!(
438 body,
439 "\n---\nThis stack of pull requests is managed by [jj-ryu](https://github.com/dmmulroy/jj-ryu)."
440 );
441
442 Ok(body)
443}
444
445async fn create_or_update_stack_comment(
447 platform: &dyn PlatformService,
448 data: &StackCommentData,
449 current_idx: usize,
450 pr_number: u64,
451) -> Result<()> {
452 let body = format_stack_comment_for_platform(data, current_idx, platform.config().platform)?;
453
454 let comments = platform.list_pr_comments(pr_number).await?;
456 let existing = comments
457 .iter()
458 .find(|c| c.body.contains(COMMENT_DATA_PREFIX) || c.body.contains(COMMENT_DATA_PREFIX_OLD));
459
460 if let Some(comment) = existing {
461 platform
462 .update_pr_comment(pr_number, comment.id, &body)
463 .await?;
464 } else {
465 platform.create_pr_comment(pr_number, &body).await?;
466 }
467
468 Ok(())
469}
470
471#[cfg(test)]
476mod tests {
477 use super::*;
478 use crate::types::NarrowedBookmarkSegment;
479
480 fn make_pr(number: u64, bookmark: &str) -> PullRequest {
481 PullRequest {
482 number,
483 html_url: format!("https://github.com/test/test/pull/{number}"),
484 base_ref: "main".to_string(),
485 head_ref: bookmark.to_string(),
486 title: format!("PR for {bookmark}"),
487 node_id: Some(format!("PR_node_{number}")),
488 is_draft: false,
489 }
490 }
491
492 fn make_bookmark(name: &str) -> Bookmark {
493 Bookmark {
494 name: name.to_string(),
495 commit_id: format!("{name}_commit"),
496 change_id: format!("{name}_change"),
497 has_remote: false,
498 is_synced: false,
499 }
500 }
501
502 #[test]
505 fn test_submission_result_new() {
506 let result = SubmissionResult::new();
507 assert!(result.success);
508 assert!(result.errors.is_empty());
509 }
510
511 #[test]
512 fn test_submission_result_fail() {
513 let mut result = SubmissionResult::new();
514 result.fail("something went wrong".to_string());
515
516 assert!(!result.success);
517 assert_eq!(result.errors.len(), 1);
518 assert_eq!(result.errors[0], "something went wrong");
519 }
520
521 #[test]
522 fn test_submission_result_soft_fail() {
523 let mut result = SubmissionResult::new();
524 result.soft_fail("minor issue".to_string());
525
526 assert!(result.success);
528 assert_eq!(result.errors.len(), 1);
529 }
530
531 #[test]
534 fn test_step_outcome_success_without_pr() {
535 let outcome = StepOutcome::Success(None);
536 assert!(matches!(outcome, StepOutcome::Success(None)));
537 }
538
539 #[test]
540 fn test_step_outcome_success_with_pr() {
541 let pr = make_pr(1, "feat-a");
542 let outcome = StepOutcome::Success(Some(("feat-a".to_string(), pr)));
543 assert!(matches!(outcome, StepOutcome::Success(Some(_))));
544 }
545
546 #[test]
547 fn test_step_outcome_fatal_error() {
548 let outcome = StepOutcome::FatalError("boom".to_string());
549 assert!(matches!(outcome, StepOutcome::FatalError(_)));
550 }
551
552 #[test]
553 fn test_step_outcome_soft_error() {
554 let outcome = StepOutcome::SoftError("minor".to_string());
555 assert!(matches!(outcome, StepOutcome::SoftError(_)));
556 }
557
558 #[test]
561 fn test_format_step_push() {
562 let bm = make_bookmark("feat-a");
563 let step = ExecutionStep::Push(bm);
564 let output = format_step_for_dry_run(&step, "origin");
565 assert_eq!(output, " → push feat-a to origin");
566 }
567
568 #[test]
569 fn test_format_step_create_pr() {
570 let bm = make_bookmark("feat-a");
571 let create = PrToCreate {
572 bookmark: bm,
573 base_branch: "main".to_string(),
574 title: "Add feature".to_string(),
575 draft: false,
576 };
577 let step = ExecutionStep::CreatePr(create);
578 let output = format_step_for_dry_run(&step, "origin");
579 assert_eq!(output, " → create PR feat-a → main (Add feature)");
580 }
581
582 #[test]
583 fn test_format_step_create_pr_draft() {
584 let bm = make_bookmark("feat-a");
585 let create = PrToCreate {
586 bookmark: bm,
587 base_branch: "main".to_string(),
588 title: "Add feature".to_string(),
589 draft: true,
590 };
591 let step = ExecutionStep::CreatePr(create);
592 let output = format_step_for_dry_run(&step, "origin");
593 assert!(output.contains("[draft]"));
594 }
595
596 #[test]
597 fn test_format_step_update_base() {
598 let bm = make_bookmark("feat-b");
599 let update = PrBaseUpdate {
600 bookmark: bm,
601 current_base: "main".to_string(),
602 expected_base: "feat-a".to_string(),
603 pr: make_pr(42, "feat-b"),
604 };
605 let step = ExecutionStep::UpdateBase(update);
606 let output = format_step_for_dry_run(&step, "origin");
607 assert_eq!(output, " → update feat-b (PR #42) main → feat-a");
608 }
609
610 #[test]
611 fn test_format_step_publish() {
612 let pr = make_pr(99, "feat-a");
613 let step = ExecutionStep::PublishPr(pr);
614 let output = format_step_for_dry_run(&step, "origin");
615 assert_eq!(output, " → publish PR #99 (feat-a)");
616 }
617
618 #[test]
621 fn test_build_stack_comment_data() {
622 let plan = SubmissionPlan {
623 segments: vec![
624 NarrowedBookmarkSegment {
625 bookmark: make_bookmark("feat-a"),
626 changes: vec![],
627 },
628 NarrowedBookmarkSegment {
629 bookmark: make_bookmark("feat-b"),
630 changes: vec![],
631 },
632 ],
633 constraints: vec![],
634 execution_steps: vec![],
635 existing_prs: HashMap::new(),
636 remote: "origin".to_string(),
637 default_branch: "main".to_string(),
638 };
639
640 let mut bookmark_to_pr = HashMap::new();
641 bookmark_to_pr.insert("feat-a".to_string(), make_pr(1, "feat-a"));
642 bookmark_to_pr.insert("feat-b".to_string(), make_pr(2, "feat-b"));
643
644 let data = build_stack_comment_data(&plan, &bookmark_to_pr);
645
646 assert_eq!(data.version, 1);
647 assert_eq!(data.base_branch, "main");
648 assert_eq!(data.stack.len(), 2);
649 assert_eq!(data.stack[0].bookmark_name, "feat-a");
650 assert_eq!(data.stack[0].pr_number, 1);
651 assert_eq!(data.stack[0].pr_title, "PR for feat-a");
652 assert_eq!(data.stack[1].bookmark_name, "feat-b");
653 assert_eq!(data.stack[1].pr_number, 2);
654 }
655
656 #[test]
657 fn test_build_stack_comment_data_filters_missing_prs() {
658 let plan = SubmissionPlan {
659 segments: vec![
660 NarrowedBookmarkSegment {
661 bookmark: make_bookmark("feat-a"),
662 changes: vec![],
663 },
664 NarrowedBookmarkSegment {
665 bookmark: make_bookmark("feat-b"),
666 changes: vec![],
667 },
668 ],
669 constraints: vec![],
670 execution_steps: vec![],
671 existing_prs: HashMap::new(),
672 remote: "origin".to_string(),
673 default_branch: "main".to_string(),
674 };
675
676 let mut bookmark_to_pr = HashMap::new();
678 bookmark_to_pr.insert("feat-a".to_string(), make_pr(1, "feat-a"));
679
680 let data = build_stack_comment_data(&plan, &bookmark_to_pr);
681
682 assert_eq!(data.stack.len(), 1);
683 assert_eq!(data.stack[0].bookmark_name, "feat-a");
684 }
685
686 #[test]
687 fn test_format_stack_comment_marks_current() {
688 let data = StackCommentData {
689 version: 1,
690 stack: vec![
691 StackItem {
692 bookmark_name: "feat-a".to_string(),
693 pr_url: "https://example.com/1".to_string(),
694 pr_number: 1,
695 pr_title: "feat: add auth".to_string(),
696 },
697 StackItem {
698 bookmark_name: "feat-b".to_string(),
699 pr_url: "https://example.com/2".to_string(),
700 pr_number: 2,
701 pr_title: "feat: add sessions".to_string(),
702 },
703 ],
704 base_branch: "main".to_string(),
705 };
706
707 let body = format_stack_comment(&data, 1).unwrap();
709 assert!(body.contains(&format!("#{} {STACK_COMMENT_THIS_PR}", 2)));
710 assert!(!body.contains(&format!("#{} {STACK_COMMENT_THIS_PR}", 1)));
711 }
712
713 #[test]
714 fn test_format_stack_comment_contains_prefix() {
715 let data = StackCommentData {
716 version: 1,
717 stack: vec![StackItem {
718 bookmark_name: "feat-a".to_string(),
719 pr_url: "https://example.com/1".to_string(),
720 pr_number: 1,
721 pr_title: "feat: add auth".to_string(),
722 }],
723 base_branch: "main".to_string(),
724 };
725
726 let body = format_stack_comment(&data, 0).unwrap();
727 assert!(body.contains(COMMENT_DATA_PREFIX));
728 assert!(body.contains(COMMENT_DATA_POSTFIX));
729 }
730
731 #[test]
732 fn test_format_stack_comment_gitlab_uses_exclamation_mark() {
733 let data = StackCommentData {
734 version: 1,
735 stack: vec![
736 StackItem {
737 bookmark_name: "feat-a".to_string(),
738 pr_url: "https://gitlab.com/test/test/-/merge_requests/1".to_string(),
739 pr_number: 1,
740 pr_title: "feat: add auth".to_string(),
741 },
742 StackItem {
743 bookmark_name: "feat-b".to_string(),
744 pr_url: "https://gitlab.com/test/test/-/merge_requests/2".to_string(),
745 pr_number: 2,
746 pr_title: "feat: add sessions".to_string(),
747 },
748 ],
749 base_branch: "main".to_string(),
750 };
751
752 let body = format_stack_comment_for_platform(&data, 1, Platform::GitLab).unwrap();
754
755 assert!(body.contains("!1"), "GitLab should use !N for MRs: {body}");
757 assert!(body.contains("!2"), "GitLab should use !N for MRs: {body}");
758 assert!(!body.contains("#1"), "GitLab should NOT use #N: {body}");
759 assert!(!body.contains("#2"), "GitLab should NOT use #N: {body}");
760
761 assert!(
763 body.contains("https://gitlab.com/test/test/-/merge_requests/1"),
764 "GitLab should include full URLs: {body}"
765 );
766
767 assert!(
769 body.contains(&format!(
770 "!2]({}) {STACK_COMMENT_THIS_PR}",
771 "https://gitlab.com/test/test/-/merge_requests/2"
772 )),
773 "Current PR should have marker: {body}"
774 );
775 }
776
777 #[test]
778 fn test_format_stack_comment_github_uses_hash() {
779 let data = StackCommentData {
780 version: 1,
781 stack: vec![StackItem {
782 bookmark_name: "feat-a".to_string(),
783 pr_url: "https://github.com/test/test/pull/1".to_string(),
784 pr_number: 1,
785 pr_title: "feat: add auth".to_string(),
786 }],
787 base_branch: "main".to_string(),
788 };
789
790 let body = format_stack_comment_for_platform(&data, 0, Platform::GitHub).unwrap();
792
793 assert!(body.contains("#1"), "GitHub should use #N: {body}");
794 assert!(!body.contains("!1"), "GitHub should NOT use !N: {body}");
795 assert!(
797 !body.contains("](https://github.com/test/test/pull"),
798 "GitHub should NOT have markdown links to PRs: {body}"
799 );
800 assert!(
802 !body.contains("feat: add auth"),
803 "GitHub auto-expands, so title should not be in source: {body}"
804 );
805 }
806
807 #[test]
810 fn test_plan_is_empty() {
811 let plan = SubmissionPlan {
812 segments: vec![],
813 constraints: vec![],
814 execution_steps: vec![],
815 existing_prs: HashMap::new(),
816 remote: "origin".to_string(),
817 default_branch: "main".to_string(),
818 };
819
820 assert!(plan.is_empty());
821 }
822
823 #[test]
824 fn test_plan_counts() {
825 let bm = make_bookmark("feat-a");
826 let plan = SubmissionPlan {
827 segments: vec![NarrowedBookmarkSegment {
828 bookmark: bm.clone(),
829 changes: vec![],
830 }],
831 constraints: vec![],
832 execution_steps: vec![
833 ExecutionStep::Push(bm.clone()),
834 ExecutionStep::CreatePr(PrToCreate {
835 bookmark: bm,
836 base_branch: "main".to_string(),
837 title: "Add feat-a".to_string(),
838 draft: false,
839 }),
840 ],
841 existing_prs: HashMap::new(),
842 remote: "origin".to_string(),
843 default_branch: "main".to_string(),
844 };
845
846 assert!(!plan.is_empty());
847 assert_eq!(plan.count_pushes(), 1);
848 assert_eq!(plan.count_creates(), 1);
849 assert_eq!(plan.count_updates(), 0);
850 assert_eq!(plan.count_publishes(), 0);
851 }
852}