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!(
411 body,
412 "* **{} #{} {STACK_COMMENT_THIS_PR}**",
413 item.pr_title, item.pr_number
414 );
415 } else {
416 let _ = writeln!(body, "* {} #{}", item.pr_title, item.pr_number);
417 }
418 }
419 Platform::GitLab => {
420 if is_current {
422 let _ = writeln!(
423 body,
424 "* **[{} !{}]({}) {STACK_COMMENT_THIS_PR}**",
425 item.pr_title, item.pr_number, item.pr_url
426 );
427 } else {
428 let _ = writeln!(
429 body,
430 "* [{} !{}]({})",
431 item.pr_title, item.pr_number, item.pr_url
432 );
433 }
434 }
435 }
436 }
437
438 let _ = writeln!(body, "* `{}`", data.base_branch);
440
441 let _ = write!(
442 body,
443 "\n---\nThis stack of pull requests is managed by [jj-ryu](https://github.com/dmmulroy/jj-ryu)."
444 );
445
446 Ok(body)
447}
448
449async fn create_or_update_stack_comment(
451 platform: &dyn PlatformService,
452 data: &StackCommentData,
453 current_idx: usize,
454 pr_number: u64,
455) -> Result<()> {
456 let body = format_stack_comment_for_platform(data, current_idx, platform.config().platform)?;
457
458 let comments = platform.list_pr_comments(pr_number).await?;
460 let existing = comments
461 .iter()
462 .find(|c| c.body.contains(COMMENT_DATA_PREFIX) || c.body.contains(COMMENT_DATA_PREFIX_OLD));
463
464 if let Some(comment) = existing {
465 platform
466 .update_pr_comment(pr_number, comment.id, &body)
467 .await?;
468 } else {
469 platform.create_pr_comment(pr_number, &body).await?;
470 }
471
472 Ok(())
473}
474
475#[cfg(test)]
480mod tests {
481 use super::*;
482 use crate::types::NarrowedBookmarkSegment;
483
484 fn make_pr(number: u64, bookmark: &str) -> PullRequest {
485 PullRequest {
486 number,
487 html_url: format!("https://github.com/test/test/pull/{number}"),
488 base_ref: "main".to_string(),
489 head_ref: bookmark.to_string(),
490 title: format!("PR for {bookmark}"),
491 node_id: Some(format!("PR_node_{number}")),
492 is_draft: false,
493 }
494 }
495
496 fn make_bookmark(name: &str) -> Bookmark {
497 Bookmark {
498 name: name.to_string(),
499 commit_id: format!("{name}_commit"),
500 change_id: format!("{name}_change"),
501 has_remote: false,
502 is_synced: false,
503 }
504 }
505
506 #[test]
509 fn test_submission_result_new() {
510 let result = SubmissionResult::new();
511 assert!(result.success);
512 assert!(result.errors.is_empty());
513 }
514
515 #[test]
516 fn test_submission_result_fail() {
517 let mut result = SubmissionResult::new();
518 result.fail("something went wrong".to_string());
519
520 assert!(!result.success);
521 assert_eq!(result.errors.len(), 1);
522 assert_eq!(result.errors[0], "something went wrong");
523 }
524
525 #[test]
526 fn test_submission_result_soft_fail() {
527 let mut result = SubmissionResult::new();
528 result.soft_fail("minor issue".to_string());
529
530 assert!(result.success);
532 assert_eq!(result.errors.len(), 1);
533 }
534
535 #[test]
538 fn test_step_outcome_success_without_pr() {
539 let outcome = StepOutcome::Success(None);
540 assert!(matches!(outcome, StepOutcome::Success(None)));
541 }
542
543 #[test]
544 fn test_step_outcome_success_with_pr() {
545 let pr = make_pr(1, "feat-a");
546 let outcome = StepOutcome::Success(Some(("feat-a".to_string(), pr)));
547 assert!(matches!(outcome, StepOutcome::Success(Some(_))));
548 }
549
550 #[test]
551 fn test_step_outcome_fatal_error() {
552 let outcome = StepOutcome::FatalError("boom".to_string());
553 assert!(matches!(outcome, StepOutcome::FatalError(_)));
554 }
555
556 #[test]
557 fn test_step_outcome_soft_error() {
558 let outcome = StepOutcome::SoftError("minor".to_string());
559 assert!(matches!(outcome, StepOutcome::SoftError(_)));
560 }
561
562 #[test]
565 fn test_format_step_push() {
566 let bm = make_bookmark("feat-a");
567 let step = ExecutionStep::Push(bm);
568 let output = format_step_for_dry_run(&step, "origin");
569 assert_eq!(output, " → push feat-a to origin");
570 }
571
572 #[test]
573 fn test_format_step_create_pr() {
574 let bm = make_bookmark("feat-a");
575 let create = PrToCreate {
576 bookmark: bm,
577 base_branch: "main".to_string(),
578 title: "Add feature".to_string(),
579 draft: false,
580 };
581 let step = ExecutionStep::CreatePr(create);
582 let output = format_step_for_dry_run(&step, "origin");
583 assert_eq!(output, " → create PR feat-a → main (Add feature)");
584 }
585
586 #[test]
587 fn test_format_step_create_pr_draft() {
588 let bm = make_bookmark("feat-a");
589 let create = PrToCreate {
590 bookmark: bm,
591 base_branch: "main".to_string(),
592 title: "Add feature".to_string(),
593 draft: true,
594 };
595 let step = ExecutionStep::CreatePr(create);
596 let output = format_step_for_dry_run(&step, "origin");
597 assert!(output.contains("[draft]"));
598 }
599
600 #[test]
601 fn test_format_step_update_base() {
602 let bm = make_bookmark("feat-b");
603 let update = PrBaseUpdate {
604 bookmark: bm,
605 current_base: "main".to_string(),
606 expected_base: "feat-a".to_string(),
607 pr: make_pr(42, "feat-b"),
608 };
609 let step = ExecutionStep::UpdateBase(update);
610 let output = format_step_for_dry_run(&step, "origin");
611 assert_eq!(output, " → update feat-b (PR #42) main → feat-a");
612 }
613
614 #[test]
615 fn test_format_step_publish() {
616 let pr = make_pr(99, "feat-a");
617 let step = ExecutionStep::PublishPr(pr);
618 let output = format_step_for_dry_run(&step, "origin");
619 assert_eq!(output, " → publish PR #99 (feat-a)");
620 }
621
622 #[test]
625 fn test_build_stack_comment_data() {
626 let plan = SubmissionPlan {
627 segments: vec![
628 NarrowedBookmarkSegment {
629 bookmark: make_bookmark("feat-a"),
630 changes: vec![],
631 },
632 NarrowedBookmarkSegment {
633 bookmark: make_bookmark("feat-b"),
634 changes: vec![],
635 },
636 ],
637 constraints: vec![],
638 execution_steps: vec![],
639 existing_prs: HashMap::new(),
640 remote: "origin".to_string(),
641 default_branch: "main".to_string(),
642 };
643
644 let mut bookmark_to_pr = HashMap::new();
645 bookmark_to_pr.insert("feat-a".to_string(), make_pr(1, "feat-a"));
646 bookmark_to_pr.insert("feat-b".to_string(), make_pr(2, "feat-b"));
647
648 let data = build_stack_comment_data(&plan, &bookmark_to_pr);
649
650 assert_eq!(data.version, 1);
651 assert_eq!(data.base_branch, "main");
652 assert_eq!(data.stack.len(), 2);
653 assert_eq!(data.stack[0].bookmark_name, "feat-a");
654 assert_eq!(data.stack[0].pr_number, 1);
655 assert_eq!(data.stack[0].pr_title, "PR for feat-a");
656 assert_eq!(data.stack[1].bookmark_name, "feat-b");
657 assert_eq!(data.stack[1].pr_number, 2);
658 }
659
660 #[test]
661 fn test_build_stack_comment_data_filters_missing_prs() {
662 let plan = SubmissionPlan {
663 segments: vec![
664 NarrowedBookmarkSegment {
665 bookmark: make_bookmark("feat-a"),
666 changes: vec![],
667 },
668 NarrowedBookmarkSegment {
669 bookmark: make_bookmark("feat-b"),
670 changes: vec![],
671 },
672 ],
673 constraints: vec![],
674 execution_steps: vec![],
675 existing_prs: HashMap::new(),
676 remote: "origin".to_string(),
677 default_branch: "main".to_string(),
678 };
679
680 let mut bookmark_to_pr = HashMap::new();
682 bookmark_to_pr.insert("feat-a".to_string(), make_pr(1, "feat-a"));
683
684 let data = build_stack_comment_data(&plan, &bookmark_to_pr);
685
686 assert_eq!(data.stack.len(), 1);
687 assert_eq!(data.stack[0].bookmark_name, "feat-a");
688 }
689
690 #[test]
691 fn test_format_stack_comment_marks_current() {
692 let data = StackCommentData {
693 version: 1,
694 stack: vec![
695 StackItem {
696 bookmark_name: "feat-a".to_string(),
697 pr_url: "https://example.com/1".to_string(),
698 pr_number: 1,
699 pr_title: "feat: add auth".to_string(),
700 },
701 StackItem {
702 bookmark_name: "feat-b".to_string(),
703 pr_url: "https://example.com/2".to_string(),
704 pr_number: 2,
705 pr_title: "feat: add sessions".to_string(),
706 },
707 ],
708 base_branch: "main".to_string(),
709 };
710
711 let body = format_stack_comment(&data, 1).unwrap();
713 assert!(body.contains(&format!("#{} {STACK_COMMENT_THIS_PR}", 2)));
714 assert!(!body.contains(&format!("#{} {STACK_COMMENT_THIS_PR}", 1)));
715 }
716
717 #[test]
718 fn test_format_stack_comment_contains_prefix() {
719 let data = StackCommentData {
720 version: 1,
721 stack: vec![StackItem {
722 bookmark_name: "feat-a".to_string(),
723 pr_url: "https://example.com/1".to_string(),
724 pr_number: 1,
725 pr_title: "feat: add auth".to_string(),
726 }],
727 base_branch: "main".to_string(),
728 };
729
730 let body = format_stack_comment(&data, 0).unwrap();
731 assert!(body.contains(COMMENT_DATA_PREFIX));
732 assert!(body.contains(COMMENT_DATA_POSTFIX));
733 }
734
735 #[test]
736 fn test_format_stack_comment_gitlab_uses_exclamation_mark() {
737 let data = StackCommentData {
738 version: 1,
739 stack: vec![
740 StackItem {
741 bookmark_name: "feat-a".to_string(),
742 pr_url: "https://gitlab.com/test/test/-/merge_requests/1".to_string(),
743 pr_number: 1,
744 pr_title: "feat: add auth".to_string(),
745 },
746 StackItem {
747 bookmark_name: "feat-b".to_string(),
748 pr_url: "https://gitlab.com/test/test/-/merge_requests/2".to_string(),
749 pr_number: 2,
750 pr_title: "feat: add sessions".to_string(),
751 },
752 ],
753 base_branch: "main".to_string(),
754 };
755
756 let body = format_stack_comment_for_platform(&data, 1, Platform::GitLab).unwrap();
758
759 assert!(body.contains("!1"), "GitLab should use !N for MRs: {body}");
761 assert!(body.contains("!2"), "GitLab should use !N for MRs: {body}");
762 assert!(!body.contains("#1"), "GitLab should NOT use #N: {body}");
763 assert!(!body.contains("#2"), "GitLab should NOT use #N: {body}");
764
765 assert!(
767 body.contains("https://gitlab.com/test/test/-/merge_requests/1"),
768 "GitLab should include full URLs: {body}"
769 );
770
771 assert!(
773 body.contains(&format!(
774 "!2]({}) {STACK_COMMENT_THIS_PR}",
775 "https://gitlab.com/test/test/-/merge_requests/2"
776 )),
777 "Current PR should have marker: {body}"
778 );
779 }
780
781 #[test]
782 fn test_format_stack_comment_github_uses_hash() {
783 let data = StackCommentData {
784 version: 1,
785 stack: vec![StackItem {
786 bookmark_name: "feat-a".to_string(),
787 pr_url: "https://github.com/test/test/pull/1".to_string(),
788 pr_number: 1,
789 pr_title: "feat: add auth".to_string(),
790 }],
791 base_branch: "main".to_string(),
792 };
793
794 let body = format_stack_comment_for_platform(&data, 0, Platform::GitHub).unwrap();
796
797 assert!(body.contains("#1"), "GitHub should use #N: {body}");
798 assert!(!body.contains("!1"), "GitHub should NOT use !N: {body}");
799 assert!(
801 !body.contains("](https://github.com/test/test/pull"),
802 "GitHub should NOT have markdown links to PRs: {body}"
803 );
804 }
805
806 #[test]
809 fn test_plan_is_empty() {
810 let plan = SubmissionPlan {
811 segments: vec![],
812 constraints: vec![],
813 execution_steps: vec![],
814 existing_prs: HashMap::new(),
815 remote: "origin".to_string(),
816 default_branch: "main".to_string(),
817 };
818
819 assert!(plan.is_empty());
820 }
821
822 #[test]
823 fn test_plan_counts() {
824 let bm = make_bookmark("feat-a");
825 let plan = SubmissionPlan {
826 segments: vec![NarrowedBookmarkSegment {
827 bookmark: bm.clone(),
828 changes: vec![],
829 }],
830 constraints: vec![],
831 execution_steps: vec![
832 ExecutionStep::Push(bm.clone()),
833 ExecutionStep::CreatePr(PrToCreate {
834 bookmark: bm,
835 base_branch: "main".to_string(),
836 title: "Add feat-a".to_string(),
837 draft: false,
838 }),
839 ],
840 existing_prs: HashMap::new(),
841 remote: "origin".to_string(),
842 default_branch: "main".to_string(),
843 };
844
845 assert!(!plan.is_empty());
846 assert_eq!(plan.count_pushes(), 1);
847 assert_eq!(plan.count_creates(), 1);
848 assert_eq!(plan.count_updates(), 0);
849 assert_eq!(plan.count_publishes(), 0);
850 }
851}