Skip to main content

silver_platter/
publish.rs

1//! Publishing changes
2pub use crate::proposal::DescriptionFormat;
3use crate::vcs::open_branch;
4use crate::Mode;
5use breezyshim::branch::{GenericBranch, MemoryBranch, PyBranch};
6
7use breezyshim::error::Error as BrzError;
8use breezyshim::forge::MergeProposalStatus;
9use breezyshim::merge::{MergeType, Merger};
10use breezyshim::repository::Repository;
11use breezyshim::{Branch, Forge, MergeProposal, RevisionId, Transport};
12
13use std::collections::HashMap;
14
15fn _tag_selector_from_tags(
16    tags: std::collections::HashMap<String, RevisionId>,
17) -> impl Fn(String) -> bool {
18    move |tag| tags.contains_key(tag.as_str())
19}
20
21fn _tag_selector_from_tags_ref(
22    tags: &std::collections::HashMap<String, RevisionId>,
23) -> Box<dyn Fn(String) -> bool + '_> {
24    Box::new(move |tag| tags.contains_key(tag.as_str()))
25}
26
27/// Push derived changes
28pub fn push_derived_changes(
29    local_branch: &dyn PyBranch,
30    main_branch: &dyn PyBranch,
31    forge: &Forge,
32    name: &str,
33    overwrite_existing: Option<bool>,
34    owner: Option<&str>,
35    tags: Option<std::collections::HashMap<String, RevisionId>>,
36    stop_revision: Option<&RevisionId>,
37) -> Result<(Box<dyn Branch>, url::Url), BrzError> {
38    let tags = tags.unwrap_or_default();
39    let (remote_branch, public_branch_url) = forge.publish_derived(
40        local_branch,
41        main_branch,
42        name,
43        overwrite_existing,
44        owner,
45        stop_revision,
46        Some(Box::new(_tag_selector_from_tags(tags))),
47    )?;
48    Ok((remote_branch, public_branch_url))
49}
50
51/// Push result
52pub fn push_result(
53    local_branch: &GenericBranch,
54    remote_branch: &GenericBranch,
55    additional_colocated_branches: Option<&[(String, String)]>,
56    tags: Option<&std::collections::HashMap<String, RevisionId>>,
57    stop_revision: Option<&RevisionId>,
58) -> Result<(), BrzError> {
59    let tag_selector = if let Some(tags) = tags {
60        _tag_selector_from_tags(tags.clone())
61    } else {
62        _tag_selector_from_tags(std::collections::HashMap::new())
63    };
64    local_branch.push(
65        remote_branch,
66        false,
67        stop_revision,
68        Some(Box::new(tag_selector)),
69    )?;
70
71    if let Some(branches) = additional_colocated_branches {
72        for (from_branch_name, to_branch_name) in branches {
73            match local_branch
74                .controldir()
75                .open_branch(Some(from_branch_name.as_str()))
76            {
77                Ok(branch) => {
78                    let tag_selector = if let Some(tags) = tags {
79                        Box::new(_tag_selector_from_tags(tags.clone()))
80                    } else {
81                        Box::new(_tag_selector_from_tags(std::collections::HashMap::new()))
82                    };
83
84                    // Use the remote branch's controldir for pushing colocated branches
85                    // This is the correct approach since we're pushing to the same repository
86                    let target_controldir = remote_branch.controldir();
87                    target_controldir.push_branch(
88                        branch.as_ref(),
89                        Some(to_branch_name.as_str()),
90                        None,
91                        Some(false),
92                        Some(tag_selector),
93                    )?;
94                }
95                Err(BrzError::NotBranchError(..)) => {}
96                Err(e) => return Err(e),
97            };
98        }
99    }
100    Ok(())
101}
102
103/// Push changes to a branch.
104///
105/// # Arguments
106/// * `local_branch` - Local branch to push
107/// * `main_branch` - Main branch to push to
108/// * `forge` - Forge to push to
109/// * `possible_transports` - Possible transports to use
110/// * `additional_colocated_branches` - Additional colocated branches to push
111/// * `tags` - Tags to push
112/// * `stop_revision` - Revision to stop pushing at
113pub fn push_changes(
114    local_branch: &GenericBranch,
115    main_branch: &GenericBranch,
116    forge: Option<&Forge>,
117    possible_transports: Option<&mut Vec<Transport>>,
118    additional_colocated_branches: Option<Vec<(String, String)>>,
119    tags: Option<std::collections::HashMap<String, RevisionId>>,
120    stop_revision: Option<&RevisionId>,
121) -> Result<(), Error> {
122    let push_url = if let Some(forge) = forge {
123        forge.get_push_url(main_branch)
124    } else {
125        main_branch.get_user_url()
126    };
127    log::info!("pushing to {}", push_url);
128    let target_branch = open_branch(&push_url, possible_transports, None, None)?;
129    push_result(
130        local_branch,
131        &target_branch,
132        additional_colocated_branches.as_deref(),
133        tags.as_ref(),
134        stop_revision,
135    )
136    .map_err(Into::into)
137}
138
139/// Find an existing derived branch with the specified name, and proposal.
140///
141/// # Arguments:
142///
143/// * `main_branch` - Main branch
144/// * `forge` - The forge
145/// * `name` - Name of the derived branch
146/// * `overwrite_unrelated` - Whether to overwrite existing (but unrelated) branches
147/// * `owner` - Owner of the branch
148/// * `preferred_schemes` - List of preferred schemes
149///
150/// # Returns:
151///   Tuple with (resume_branch, overwrite_existing, existing_proposal)
152///   The resume_branch is the branch to continue from; overwrite_existing
153///   means there is an existing branch in place that should be overwritten.
154#[allow(dead_code)]
155pub fn find_existing_proposed(
156    main_branch: &GenericBranch,
157    forge: &Forge,
158    name: &str,
159    overwrite_unrelated: bool,
160    owner: Option<&str>,
161    _preferred_schemes: Option<&[&str]>,
162) -> Result<
163    (
164        Option<GenericBranch>,
165        Option<bool>,
166        Option<Vec<MergeProposal>>,
167    ),
168    BrzError,
169> {
170    match forge.get_derived_branch_as_generic(main_branch, name, owner, None) {
171        Ok(derived_branch) => {
172            // Found existing derived branch
173            let proposals =
174                forge.iter_proposals(main_branch, main_branch, MergeProposalStatus::Open)?;
175
176            // Filter proposals that are for our derived branch
177            let derived_url = derived_branch.get_user_url();
178            let matching_proposals: Vec<MergeProposal> = proposals
179                .into_iter()
180                .filter(|proposal| {
181                    if let Ok(Some(source_url)) = proposal.get_source_branch_url() {
182                        source_url == derived_url
183                    } else {
184                        false
185                    }
186                })
187                .collect();
188
189            Ok((
190                Some(derived_branch),
191                Some(!overwrite_unrelated),
192                Some(matching_proposals),
193            ))
194        }
195        Err(BrzError::NotBranchError(..)) => {
196            // No existing derived branch found
197            Ok((None, None, None))
198        }
199        Err(e) => Err(e),
200    }
201}
202
203/// Create or update a merge proposal.
204///
205/// # Arguments
206///
207/// * `local_branch` - Local branch with changes to propose
208/// * `main_branch` - Target branch to propose against
209/// * `forge` - Associated forge for main branch
210/// * `mp_description` - Merge proposal description
211/// * `resume_branch` - Existing derived branch
212/// * `resume_proposal` - Existing merge proposal to resume
213/// * `overwrite_existing` - Whether to overwrite any other existing branch
214/// * `labels` - Labels to add
215/// * `commit_message` - Optional commit message
216/// * `title` - Optional title
217/// * `additional_colocated_branches` - Additional colocated branches to propose
218/// * `allow_empty` - Whether to allow empty merge proposals
219/// * `reviewers` - List of reviewers
220/// * `tags` - Tags to push (None for default behaviour)
221/// * `owner` - Derived branch owner
222/// * `stop_revision` - Revision to stop pushing at
223/// * `allow_collaboration` - Allow target branch owners to modify source branch
224/// * `auto_merge` - Enable merging once CI passes
225/// * `work_in_progress` - Mark merge proposal as work in progress
226/// * `preferred_schemes` - List of preferred schemes
227/// * `overwrite_unrelated` - Whether to overwrite existing (but unrelated) branches
228///
229/// # Returns
230///   Tuple with (proposal, is_new)
231pub fn propose_changes(
232    local_branch: &GenericBranch,
233    main_branch: &GenericBranch,
234    forge: &Forge,
235    name: &str,
236    mp_description: &str,
237    resume_branch: Option<&GenericBranch>,
238    mut resume_proposal: Option<MergeProposal>,
239    overwrite_existing: Option<bool>,
240    labels: Option<Vec<String>>,
241    commit_message: Option<&str>,
242    title: Option<&str>,
243    additional_colocated_branches: Option<Vec<(String, String)>>,
244    allow_empty: Option<bool>,
245    _reviewers: Option<Vec<String>>,
246    tags: Option<HashMap<String, RevisionId>>,
247    owner: Option<&str>,
248    stop_revision: Option<&RevisionId>,
249    _allow_collaboration: Option<bool>,
250    auto_merge: Option<bool>,
251    work_in_progress: Option<bool>,
252) -> Result<(MergeProposal, bool), Error> {
253    if !allow_empty.unwrap_or(false)
254        && check_proposal_diff_empty(local_branch, main_branch, stop_revision)?
255    {
256        return Err(Error::EmptyMergeProposal);
257    }
258    let overwrite_existing = overwrite_existing.unwrap_or(true);
259
260    // Handle pushing to remote branch
261    if let Some(resume_branch) = resume_branch {
262        // Push changes to the existing branch
263        let tag_selector = tags.as_ref().map(|tag_map| {
264            Box::new(_tag_selector_from_tags(tag_map.clone())) as Box<dyn Fn(String) -> bool>
265        });
266        local_branch.push(
267            resume_branch,
268            overwrite_existing,
269            stop_revision,
270            tag_selector,
271        )?;
272    } else {
273        let tag_selector = tags.as_ref().map(|tag_map| {
274            Box::new(_tag_selector_from_tags(tag_map.clone())) as Box<dyn Fn(String) -> bool>
275        });
276        let (_derived_branch, _public_branch_url) = forge.publish_derived(
277            local_branch,
278            main_branch,
279            name,
280            Some(overwrite_existing),
281            owner,
282            stop_revision,
283            tag_selector,
284        )?;
285    }
286    // Push additional colocated branches - GenericBranch implements PyBranch
287    for (from_branch_name, to_branch_name) in additional_colocated_branches.unwrap_or_default() {
288        match local_branch
289            .controldir()
290            .open_branch(Some(from_branch_name.as_str()))
291        {
292            Ok(from_branch) => {
293                let tag_selector = tags.as_ref().map(|tag_map| {
294                    Box::new(_tag_selector_from_tags(tag_map.clone()))
295                        as Box<dyn Fn(String) -> bool>
296                });
297
298                // Get the target controldir (either resume_branch or the derived branch we just pushed)
299                let target_controldir = if let Some(resume_branch) = resume_branch {
300                    resume_branch.controldir()
301                } else {
302                    // We need to get the derived branch controldir from forge
303                    // For now, try to open from main_branch controldir with derived name
304                    main_branch.controldir()
305                };
306
307                match target_controldir.push_branch(
308                    from_branch.as_ref(),
309                    Some(to_branch_name.as_str()),
310                    None,        // stop_revision
311                    Some(false), // overwrite
312                    tag_selector,
313                ) {
314                    Ok(_) => log::debug!(
315                        "Successfully pushed colocated branch {} -> {}",
316                        from_branch_name,
317                        to_branch_name
318                    ),
319                    Err(e) => log::warn!(
320                        "Failed to push colocated branch {} -> {}: {}",
321                        from_branch_name,
322                        to_branch_name,
323                        e
324                    ),
325                }
326            }
327            Err(BrzError::NotBranchError(..)) => {
328                log::debug!("Colocated branch {} not found, skipping", from_branch_name);
329            }
330            Err(e) => {
331                log::warn!(
332                    "Error accessing colocated branch {}: {}",
333                    from_branch_name,
334                    e
335                );
336            }
337        }
338    }
339    if let Some(mp) = resume_proposal.as_ref() {
340        if mp.is_closed()? {
341            match mp.reopen() {
342                Ok(_) => {}
343                Err(e) => {
344                    log::info!(
345                        "Reopening existing proposal failed ({}). Creating new proposal.",
346                        e
347                    );
348                    resume_proposal = None;
349                }
350            }
351        }
352    }
353    if let Some(resume_proposal) = resume_proposal.take() {
354        // Check that the proposal doesn't already has this description.
355        // Setting the description (regardless of whether it changes)
356        // causes Launchpad to send emails.
357        if resume_proposal.get_description()?.as_deref() != Some(mp_description) {
358            match resume_proposal.set_description(Some(mp_description)) {
359                Ok(_) => (),
360                Err(BrzError::UnsupportedOperation(..)) => (),
361                Err(e) => return Err(e.into()),
362            }
363        }
364        if resume_proposal.get_commit_message()?.as_deref() != commit_message {
365            match resume_proposal.set_commit_message(commit_message) {
366                Ok(_) => (),
367                Err(BrzError::UnsupportedOperation(..)) => (),
368                Err(e) => return Err(e.into()),
369            }
370        }
371        if resume_proposal.get_title()?.as_deref() != title {
372            match resume_proposal.set_title(title) {
373                Ok(_) => (),
374                Err(BrzError::UnsupportedOperation(..)) => (),
375                Err(e) => return Err(e.into()),
376            }
377        }
378        Ok((resume_proposal, false))
379    } else {
380        // Create new proposal - GenericBranch implements PyBranch so we can use it
381        let mut proposer = forge.get_proposer(main_branch, local_branch)?;
382        proposer = proposer.description(mp_description);
383        if let Some(title) = title {
384            proposer = proposer.title(title);
385        }
386        if let Some(commit_message) = commit_message {
387            proposer = proposer.commit_message(commit_message);
388        }
389        if let Some(labels) = labels {
390            let label_refs: Vec<&str> = labels.iter().map(|s| s.as_str()).collect();
391            proposer = proposer.labels(&label_refs);
392        }
393        if let Some(reviewers) = _reviewers {
394            let reviewer_refs: Vec<&str> = reviewers.iter().map(|s| s.as_str()).collect();
395            proposer = proposer.reviewers(&reviewer_refs);
396        }
397        if let Some(allow_collaboration) = _allow_collaboration {
398            proposer = proposer.allow_collaboration(allow_collaboration);
399        }
400        if let Some(work_in_progress) = work_in_progress {
401            proposer = proposer.work_in_progress(work_in_progress);
402        }
403        let proposal = proposer.build()?;
404
405        // Set auto_merge if requested
406        if let Some(auto_merge) = auto_merge {
407            if auto_merge {
408                // Call merge with auto=true to enable auto-merge
409                match proposal.merge(true) {
410                    Ok(_) => {}
411                    Err(BrzError::UnsupportedOperation(..)) => {
412                        // Some forges don't support auto-merge
413                        log::debug!("Auto-merge not supported by this forge");
414                    }
415                    Err(e) => return Err(e.into()),
416                }
417            }
418        }
419
420        Ok((proposal, true))
421    }
422}
423
424#[derive(Debug)]
425/// Error type for publishing
426pub enum Error {
427    /// Diverged branches
428    DivergedBranches(),
429
430    /// An unrelated branch existed
431    UnrelatedBranchExists,
432
433    /// Other vcs error
434    Other(BrzError),
435
436    /// Unsupported forge
437    UnsupportedForge(url::Url),
438
439    /// Forge login required
440    ForgeLoginRequired,
441
442    /// Insufficient changes for new proposal
443    InsufficientChangesForNewProposal,
444
445    /// Branch open error
446    BranchOpenError(crate::vcs::BranchOpenError),
447
448    /// Empty merge proposal
449    EmptyMergeProposal,
450
451    /// Permission denied
452    PermissionDenied,
453
454    /// No target branch
455    NoTargetBranch,
456}
457
458impl From<BrzError> for Error {
459    fn from(e: BrzError) -> Self {
460        match e {
461            BrzError::DivergedBranches => Error::DivergedBranches(),
462            BrzError::NotBranchError(..) => Error::UnrelatedBranchExists,
463            BrzError::PermissionDenied(..) => Error::PermissionDenied,
464            BrzError::UnsupportedForge(s) => Error::UnsupportedForge(s),
465            BrzError::ForgeLoginRequired => Error::ForgeLoginRequired,
466            _ => Error::Other(e),
467        }
468    }
469}
470
471impl From<crate::vcs::BranchOpenError> for Error {
472    fn from(e: crate::vcs::BranchOpenError) -> Self {
473        Error::BranchOpenError(e)
474    }
475}
476
477impl std::fmt::Display for Error {
478    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
479        match self {
480            Error::DivergedBranches() => write!(f, "Diverged branches"),
481            Error::Other(e) => write!(f, "{}", e),
482            Error::UnsupportedForge(u) => write!(f, "Unsupported forge: {}", u),
483            Error::ForgeLoginRequired => write!(f, "Forge login required"),
484            Error::BranchOpenError(e) => write!(f, "{}", e),
485            Error::EmptyMergeProposal => write!(f, "Empty merge proposal"),
486            Error::PermissionDenied => write!(f, "Permission denied"),
487            Error::UnrelatedBranchExists => write!(f, "Unrelated branch exists"),
488            Error::InsufficientChangesForNewProposal => {
489                write!(f, "Insufficient changes for new proposal")
490            }
491            Error::NoTargetBranch => write!(f, "No target branch"),
492        }
493    }
494}
495
496/// Publish a set of changes.
497///
498/// # Arguments
499/// * `local_branch` - Local branch to publish
500/// * `main_branch` - Main branch to publish to
501/// * `resume_branch` - Branch to resume publishing from
502/// * `mode` - Mode to use ('push', 'push-derived', 'propose')
503/// * `name` - Branch name to push
504/// * `get_proposal_description` - Function to retrieve proposal description
505/// Builder for publishing changes
506pub struct PublishBuilder<'a> {
507    local_branch: &'a GenericBranch,
508    main_branch: &'a GenericBranch,
509    resume_branch: Option<&'a GenericBranch>,
510    mode: Mode,
511    name: &'a str,
512    forge: Option<&'a Forge>,
513    allow_create_proposal: Option<bool>,
514    labels: Option<Vec<String>>,
515    overwrite_existing: Option<bool>,
516    existing_proposal: Option<MergeProposal>,
517    reviewers: Option<Vec<String>>,
518    tags: Option<HashMap<String, RevisionId>>,
519    derived_owner: Option<&'a str>,
520    allow_collaboration: Option<bool>,
521    stop_revision: Option<&'a RevisionId>,
522    auto_merge: Option<bool>,
523    work_in_progress: Option<bool>,
524}
525
526impl<'a> PublishBuilder<'a> {
527    /// Creates a new PublishBuilder with the required parameters.
528    pub fn new(
529        local_branch: &'a GenericBranch,
530        main_branch: &'a GenericBranch,
531        name: &'a str,
532        mode: Mode,
533    ) -> Self {
534        Self {
535            local_branch,
536            main_branch,
537            resume_branch: None,
538            mode,
539            name,
540            forge: None,
541            allow_create_proposal: None,
542            labels: None,
543            overwrite_existing: None,
544            existing_proposal: None,
545            reviewers: None,
546            tags: None,
547            derived_owner: None,
548            allow_collaboration: None,
549            stop_revision: None,
550            auto_merge: None,
551            work_in_progress: None,
552        }
553    }
554
555    /// Sets the branch to resume from if publishing fails.
556    pub fn resume_branch(mut self, branch: &'a GenericBranch) -> Self {
557        self.resume_branch = Some(branch);
558        self
559    }
560
561    /// Sets the forge to use for publishing.
562    pub fn forge(mut self, forge: &'a Forge) -> Self {
563        self.forge = Some(forge);
564        self
565    }
566
567    /// Sets whether to allow creating a new merge proposal.
568    pub fn allow_create_proposal(mut self, allow: bool) -> Self {
569        self.allow_create_proposal = Some(allow);
570        self
571    }
572
573    /// Sets the labels to apply to the merge proposal.
574    pub fn labels(mut self, labels: Vec<String>) -> Self {
575        self.labels = Some(labels);
576        self
577    }
578
579    /// Sets whether to overwrite an existing merge proposal.
580    pub fn overwrite_existing(mut self, overwrite: bool) -> Self {
581        self.overwrite_existing = Some(overwrite);
582        self
583    }
584
585    /// Sets an existing merge proposal to update.
586    pub fn existing_proposal(mut self, proposal: MergeProposal) -> Self {
587        self.existing_proposal = Some(proposal);
588        self
589    }
590
591    /// Sets the list of reviewers for the merge proposal.
592    pub fn reviewers(mut self, reviewers: Vec<String>) -> Self {
593        self.reviewers = Some(reviewers);
594        self
595    }
596
597    /// Sets tags to apply to the published branch.
598    pub fn tags(mut self, tags: HashMap<String, RevisionId>) -> Self {
599        self.tags = Some(tags);
600        self
601    }
602
603    /// Sets the derived owner for the published branch.
604    pub fn derived_owner(mut self, owner: &'a str) -> Self {
605        self.derived_owner = Some(owner);
606        self
607    }
608
609    /// Sets whether to allow collaboration on the merge proposal.
610    pub fn allow_collaboration(mut self, allow: bool) -> Self {
611        self.allow_collaboration = Some(allow);
612        self
613    }
614
615    /// Sets the revision to stop at when publishing.
616    pub fn stop_revision(mut self, revision: &'a RevisionId) -> Self {
617        self.stop_revision = Some(revision);
618        self
619    }
620
621    /// Sets whether to enable auto-merge for the proposal.
622    pub fn auto_merge(mut self, auto: bool) -> Self {
623        self.auto_merge = Some(auto);
624        self
625    }
626
627    /// Sets whether to mark the proposal as work in progress.
628    pub fn work_in_progress(mut self, wip: bool) -> Self {
629        self.work_in_progress = Some(wip);
630        self
631    }
632
633    /// Publishes the changes to the forge.
634    ///
635    /// # Arguments
636    /// * `get_proposal_description` - Function to generate the proposal description
637    /// * `get_proposal_commit_message` - Function to generate the commit message
638    /// * `get_proposal_title` - Function to generate the proposal title
639    ///
640    /// # Returns
641    /// The result of the publish operation
642    pub fn publish(
643        self,
644        get_proposal_description: impl FnOnce(DescriptionFormat, Option<&MergeProposal>) -> String,
645        get_proposal_commit_message: Option<impl FnOnce(Option<&MergeProposal>) -> Option<String>>,
646        get_proposal_title: Option<impl FnOnce(Option<&MergeProposal>) -> Option<String>>,
647    ) -> Result<PublishResult, Error> {
648        publish_changes(
649            self.local_branch,
650            self.main_branch,
651            self.resume_branch,
652            self.mode,
653            self.name,
654            get_proposal_description,
655            get_proposal_commit_message,
656            get_proposal_title,
657            self.forge,
658            self.allow_create_proposal,
659            self.labels,
660            self.overwrite_existing,
661            self.existing_proposal,
662            self.reviewers,
663            self.tags,
664            self.derived_owner,
665            self.allow_collaboration,
666            self.stop_revision,
667            self.auto_merge,
668            self.work_in_progress,
669        )
670    }
671}
672
673/// * `get_proposal_commit_message` - Function to retrieve proposal commit message
674/// * `get_proposal_title` - Function to retrieve proposal title
675/// * `forge` - Forge, if known
676/// * `allow_create_proposal` - Whether to allow creating proposals
677/// * `labels` - Labels to set for any merge proposals
678/// * `overwrite_existing` - Whether to overwrite existing (but unrelated) branch
679/// * `existing_proposal` - Existing proposal to update
680/// * `reviewers` - List of reviewers for merge proposal
681/// * `tags` - Tags to push (None for default behaviour)
682/// * `derived_owner` - Name of any derived branch
683/// * `allow_collaboration` - Whether to allow target branch owners to modify source branch.
684/// * `auto_merge` - Enable merging once CI passes
685/// * `work_in_progress` - Mark merge proposal as work in progress
686pub fn publish_changes(
687    local_branch: &GenericBranch,
688    main_branch: &GenericBranch,
689    resume_branch: Option<&GenericBranch>,
690    mut mode: Mode,
691    name: &str,
692    get_proposal_description: impl FnOnce(DescriptionFormat, Option<&MergeProposal>) -> String,
693    get_proposal_commit_message: Option<impl FnOnce(Option<&MergeProposal>) -> Option<String>>,
694    get_proposal_title: Option<impl FnOnce(Option<&MergeProposal>) -> Option<String>>,
695    forge: Option<&Forge>,
696    allow_create_proposal: Option<bool>,
697    labels: Option<Vec<String>>,
698    overwrite_existing: Option<bool>,
699    existing_proposal: Option<MergeProposal>,
700    reviewers: Option<Vec<String>>,
701    tags: Option<HashMap<String, RevisionId>>,
702    derived_owner: Option<&str>,
703    allow_collaboration: Option<bool>,
704    stop_revision: Option<&RevisionId>,
705    auto_merge: Option<bool>,
706    work_in_progress: Option<bool>,
707) -> Result<PublishResult, Error> {
708    let stop_revision_owned;
709    let stop_revision = match stop_revision {
710        Some(r) => r,
711        None => {
712            stop_revision_owned = local_branch.last_revision();
713            &stop_revision_owned
714        }
715    };
716    let allow_create_proposal = allow_create_proposal.unwrap_or(true);
717
718    // Only modes that don't require forge operations can work without a forge
719    if forge.is_none() && mode != Mode::Push && mode != Mode::AttemptPush {
720        return Err(Error::UnsupportedForge(main_branch.get_user_url()));
721    }
722
723    // forge will be cloned only when needed for the result
724
725    if *stop_revision == main_branch.last_revision() {
726        if let Some(existing_proposal) = existing_proposal.as_ref() {
727            log::info!("closing existing merge proposal - no new revisions");
728            existing_proposal.close()?;
729        }
730        return Ok(PublishResult {
731            mode,
732            target_branch: main_branch.get_user_url(),
733            forge: forge.cloned(),
734            proposal: existing_proposal,
735            is_new: Some(false),
736        });
737    }
738
739    if let Some(resume_branch) = resume_branch {
740        if resume_branch.last_revision() == *stop_revision {
741            // No new revisions added on this iteration, but changes since main
742            // branch. We may not have gotten round to updating/creating the
743            // merge proposal last time.
744            log::info!("No changes added; making sure merge proposal is up to date.");
745        }
746    }
747    let write_lock = main_branch.lock_write()?;
748    match mode {
749        Mode::PushDerived => {
750            let forge_ref = forge.as_ref().unwrap(); // We checked above that forge is required for this mode
751            let (_remote_branch, _public_url) = push_derived_changes(
752                local_branch,
753                main_branch,
754                forge_ref,
755                name,
756                overwrite_existing,
757                derived_owner,
758                tags,
759                Some(stop_revision),
760            )?;
761            return Ok(PublishResult {
762                mode,
763                target_branch: main_branch.get_user_url(),
764                forge: forge.cloned(),
765                proposal: None,
766                is_new: None,
767            });
768        }
769        Mode::Push | Mode::AttemptPush => {
770            let read_lock = local_branch.lock_read()?;
771            // breezy would do this check too, but we want to be *really* sure.
772            let graph = local_branch.repository().get_graph();
773            if !graph.is_ancestor(&main_branch.last_revision(), stop_revision)? {
774                return Err(Error::DivergedBranches());
775            }
776            std::mem::drop(read_lock);
777            match push_changes(
778                local_branch,
779                main_branch,
780                forge,
781                None,
782                None,
783                tags.clone(),
784                Some(stop_revision),
785            ) {
786                Err(e @ Error::PermissionDenied) => {
787                    if mode == Mode::AttemptPush {
788                        log::info!("push access denied, falling back to propose");
789                        mode = Mode::Propose;
790                    } else {
791                        log::info!("permission denied during push");
792                        return Err(e);
793                    }
794                }
795                Ok(_) => {
796                    return Ok(PublishResult {
797                        proposal: None,
798                        mode,
799                        target_branch: main_branch.get_user_url(),
800                        forge: forge.cloned(),
801                        is_new: None,
802                    });
803                }
804                Err(e) => {
805                    return Err(e);
806                }
807            }
808        }
809        Mode::Bts => {
810            unimplemented!();
811        }
812        Mode::Propose => { // Handled below
813        }
814    }
815
816    assert_eq!(mode, Mode::Propose);
817    if resume_branch.is_none() && !allow_create_proposal {
818        return Err(Error::InsufficientChangesForNewProposal);
819    }
820
821    let forge = forge.ok_or(Error::UnsupportedForge(main_branch.get_user_url()))?;
822
823    let mp_description = get_proposal_description(
824        forge.merge_proposal_description_format().parse().unwrap(),
825        if resume_branch.is_some() {
826            existing_proposal.as_ref()
827        } else {
828            None
829        },
830    );
831    let commit_message = if let Some(get_proposal_commit_message) = get_proposal_commit_message {
832        get_proposal_commit_message(if resume_branch.is_some() {
833            existing_proposal.as_ref()
834        } else {
835            None
836        })
837    } else {
838        None
839    };
840    let title = if let Some(get_proposal_title) = get_proposal_title {
841        get_proposal_title(if resume_branch.is_some() {
842            existing_proposal.as_ref()
843        } else {
844            None
845        })
846    } else {
847        None
848    };
849    let title = if let Some(title) = title {
850        Some(title)
851    } else {
852        match breezyshim::forge::determine_title(mp_description.as_str()) {
853            Ok(title) => Some(title),
854            Err(e) => {
855                log::warn!("Failed to determine title from description: {}", e);
856                None
857            }
858        }
859    };
860    let (proposal, is_new) = propose_changes(
861        local_branch,
862        main_branch,
863        forge, // We checked above that forge is required for Propose mode
864        name,
865        mp_description.as_str(),
866        resume_branch,
867        existing_proposal,
868        overwrite_existing,
869        labels,
870        commit_message.as_deref(),
871        title.as_deref(),
872        None,
873        None,
874        reviewers,
875        tags,
876        derived_owner,
877        Some(stop_revision),
878        allow_collaboration,
879        auto_merge,
880        work_in_progress,
881    )?;
882    std::mem::drop(write_lock);
883    Ok(PublishResult {
884        mode,
885        proposal: Some(proposal),
886        is_new: Some(is_new),
887        target_branch: main_branch.get_user_url(),
888        forge: Some(forge.clone()),
889    })
890}
891
892/// Publish result
893#[derive(Debug)]
894pub struct PublishResult {
895    /// Publish mode
896    pub mode: Mode,
897
898    /// Merge proposal
899    pub proposal: Option<MergeProposal>,
900
901    /// Whether the proposal is new
902    pub is_new: Option<bool>,
903
904    /// Target branch
905    pub target_branch: url::Url,
906
907    /// Forge
908    pub forge: Option<Forge>,
909}
910
911/// Check whether a proposal has any changes.
912pub fn check_proposal_diff_empty(
913    other_branch: &dyn PyBranch,
914    main_branch: &dyn PyBranch,
915    stop_revision: Option<&RevisionId>,
916) -> Result<bool, BrzError> {
917    let stop_revision_owned;
918    let stop_revision = match stop_revision {
919        Some(rev) => rev,
920        None => {
921            stop_revision_owned = other_branch.last_revision();
922            &stop_revision_owned
923        }
924    };
925    let main_revid = main_branch.last_revision();
926    let other_repository = other_branch.repository();
927    other_repository.fetch(&main_branch.repository(), Some(&main_revid))?;
928
929    let lock = other_branch.lock_read();
930    let main_tree = other_repository.revision_tree(&main_revid)?;
931    let revision_graph = other_repository.get_graph();
932    let tree_branch = MemoryBranch::new(&other_repository, None, &main_revid);
933    let mut merger = Merger::new(&tree_branch, &main_tree, &revision_graph);
934    merger.set_other_revision(stop_revision, other_branch)?;
935    if merger.find_base()?.is_none() {
936        merger.set_base_revision(&RevisionId::null(), other_branch)?;
937    }
938    merger.set_merge_type(MergeType::Merge3);
939    let tree_merger = merger.make_merger()?;
940    let tt = tree_merger.make_preview_transform()?;
941    let mut changes = tt.iter_changes()?;
942    std::mem::drop(lock);
943    Ok(!changes.any(|_| true))
944}
945
946/// Enable tag pushing for a branch
947pub fn enable_tag_pushing(branch: &dyn Branch) -> Result<(), BrzError> {
948    let config = branch.get_config();
949    config.set_user_option("branch.fetch_tags", true)?;
950    Ok(())
951}
952
953#[cfg(test)]
954mod tests {
955    use super::*;
956    use breezyshim::testing::TestEnv;
957    use breezyshim::tree::MutableTree;
958    use breezyshim::WorkingTree;
959    use serial_test::serial;
960
961    #[test]
962    #[serial]
963    fn test_no_new_commits() {
964        let _test_env = TestEnv::new();
965        use breezyshim::controldir::create_standalone_workingtree;
966        use breezyshim::controldir::ControlDirFormat;
967        let td = tempfile::tempdir().unwrap();
968        let orig = td.path().join("orig");
969        let tree = create_standalone_workingtree(&orig, &ControlDirFormat::default()).unwrap();
970
971        std::fs::write(orig.join("a"), "a").unwrap();
972        MutableTree::add(&tree, &[std::path::Path::new("a")]).unwrap();
973        tree.build_commit().message("blah").commit().unwrap();
974
975        let proposal_url = url::Url::from_file_path(orig.join("proposal")).unwrap();
976
977        let proposal = tree
978            .controldir()
979            .sprout(proposal_url, None, None, None, None)
980            .unwrap()
981            .open_branch(None)
982            .unwrap();
983        assert!(check_proposal_diff_empty(proposal.as_ref(), &tree.branch(), None).unwrap());
984    }
985
986    #[test]
987    #[serial]
988    fn test_no_op_commits() {
989        let _test_env = TestEnv::new();
990        use breezyshim::controldir::create_standalone_workingtree;
991        use breezyshim::controldir::ControlDirFormat;
992        let td = tempfile::tempdir().unwrap();
993        let orig = td.path().join("orig");
994        let tree = create_standalone_workingtree(&orig, &ControlDirFormat::default()).unwrap();
995
996        std::fs::write(orig.join("a"), "a").unwrap();
997        MutableTree::add(&tree, &[std::path::Path::new("a")]).unwrap();
998        tree.build_commit().message("blah").commit().unwrap();
999
1000        let proposal_url = url::Url::from_file_path(orig.join("proposal")).unwrap();
1001
1002        let proposal = tree
1003            .controldir()
1004            .sprout(proposal_url, None, None, None, None)
1005            .unwrap()
1006            .open_workingtree()
1007            .unwrap();
1008        proposal
1009            .build_commit()
1010            .message("another commit that is pointless")
1011            .commit()
1012            .unwrap();
1013
1014        assert!(check_proposal_diff_empty(&proposal.branch(), &tree.branch(), None).unwrap());
1015    }
1016
1017    #[test]
1018    #[serial]
1019    fn test_indep() {
1020        let _test_env = TestEnv::new();
1021        use breezyshim::bazaar::tree::MutableInventoryTree;
1022        use breezyshim::bazaar::FileId;
1023        use breezyshim::controldir::create_standalone_workingtree;
1024        use breezyshim::controldir::ControlDirFormat;
1025        let td = tempfile::tempdir().unwrap();
1026        let orig = td.path().join("orig");
1027        let tree = create_standalone_workingtree(&orig, &ControlDirFormat::default()).unwrap();
1028
1029        std::fs::write(orig.join("a"), "a").unwrap();
1030        MutableTree::add(&tree, &[std::path::Path::new("a")]).unwrap();
1031        tree.build_commit().message("blah").commit().unwrap();
1032
1033        std::fs::write(orig.join("b"), "b").unwrap();
1034        std::fs::write(orig.join("c"), "c").unwrap();
1035        MutableTree::add(
1036            &tree,
1037            &[std::path::Path::new("b"), std::path::Path::new("c")],
1038        )
1039        .unwrap();
1040        tree.build_commit().message("independent").commit().unwrap();
1041
1042        let proposal_path = orig.join("proposal");
1043        let proposal_url = url::Url::from_file_path(proposal_path.as_path()).unwrap();
1044
1045        let proposal = tree
1046            .controldir()
1047            .sprout(proposal_url, None, None, None, None)
1048            .unwrap()
1049            .open_workingtree()
1050            .unwrap();
1051
1052        assert!(proposal_path.exists());
1053
1054        std::fs::write(proposal_path.join("b"), "b").unwrap();
1055
1056        if proposal.supports_setting_file_ids() {
1057            MutableInventoryTree::add(
1058                &proposal,
1059                &[std::path::Path::new("b")],
1060                &[FileId::from("b")],
1061            )
1062            .unwrap();
1063        } else {
1064            MutableTree::add(&proposal, &[std::path::Path::new("b")]).unwrap();
1065        }
1066        proposal
1067            .build_commit()
1068            .message("not pointless")
1069            .commit()
1070            .unwrap();
1071
1072        assert!(check_proposal_diff_empty(&proposal.branch(), &tree.branch(), None).unwrap());
1073
1074        std::mem::drop(td);
1075    }
1076
1077    #[test]
1078    #[serial]
1079    fn test_changes() {
1080        let _test_env = TestEnv::new();
1081        use breezyshim::controldir::create_standalone_workingtree;
1082        use breezyshim::controldir::ControlDirFormat;
1083        let td = tempfile::tempdir().unwrap();
1084        let orig = td.path().join("orig");
1085        let tree = create_standalone_workingtree(&orig, &ControlDirFormat::default()).unwrap();
1086        std::fs::write(orig.join("a"), "a").unwrap();
1087        MutableTree::add(&tree, &[std::path::Path::new("a")]).unwrap();
1088        tree.build_commit().message("blah").commit().unwrap();
1089
1090        let proposal_url = url::Url::from_file_path(td.path().join("proposal")).unwrap();
1091        let proposal_tree = tree
1092            .controldir()
1093            .sprout(proposal_url, None, None, None, None)
1094            .unwrap()
1095            .open_workingtree()
1096            .unwrap();
1097        std::fs::write(proposal_tree.basedir().join("b"), "b").unwrap();
1098        MutableTree::add(&proposal_tree, &[std::path::Path::new("b")]).unwrap();
1099        proposal_tree
1100            .build_commit()
1101            .message("not pointless")
1102            .commit()
1103            .unwrap();
1104
1105        assert!(!check_proposal_diff_empty(&proposal_tree.branch(), &tree.branch(), None).unwrap());
1106    }
1107
1108    #[test]
1109    #[serial]
1110    fn test_push_result() {
1111        let _test_env = TestEnv::new();
1112        use breezyshim::controldir::{
1113            create_branch_convenience_as_generic, create_standalone_workingtree, ControlDirFormat,
1114        };
1115        let td = tempfile::tempdir().unwrap();
1116        let target_path = td.path().join("target");
1117        let source_path = td.path().join("source");
1118        let target_url = url::Url::from_file_path(target_path).unwrap();
1119        let _target_branch =
1120            create_branch_convenience_as_generic(&target_url, None, &ControlDirFormat::default())
1121                .unwrap();
1122        let target = crate::vcs::open_branch(&target_url, None, None, None).unwrap();
1123        let source =
1124            create_standalone_workingtree(&source_path, &ControlDirFormat::default()).unwrap();
1125        let revid = source
1126            .build_commit()
1127            .message("Some change")
1128            .commit()
1129            .unwrap();
1130        push_result(&source.branch(), &target, None, None, None).unwrap();
1131        assert_eq!(target.last_revision(), revid);
1132    }
1133
1134    #[test]
1135    fn test_publish_builder_construction() {
1136        use breezyshim::controldir::create_standalone_workingtree;
1137        use breezyshim::controldir::ControlDirFormat;
1138
1139        let td = tempfile::tempdir().unwrap();
1140        let tree = create_standalone_workingtree(td.path(), &ControlDirFormat::default()).unwrap();
1141        let local_branch = tree.branch();
1142        let main_branch = tree.branch();
1143
1144        // Test basic builder construction
1145        let builder = PublishBuilder::new(&local_branch, &main_branch, "test-branch", Mode::Push);
1146
1147        // Verify fields are set correctly
1148        assert_eq!(builder.name, "test-branch");
1149        assert_eq!(builder.mode, Mode::Push);
1150        assert!(builder.forge.is_none());
1151        assert!(builder.labels.is_none());
1152        assert!(builder.reviewers.is_none());
1153    }
1154
1155    #[test]
1156    fn test_publish_builder_chaining() {
1157        use breezyshim::controldir::create_standalone_workingtree;
1158        use breezyshim::controldir::ControlDirFormat;
1159
1160        let td = tempfile::tempdir().unwrap();
1161        let tree = create_standalone_workingtree(td.path(), &ControlDirFormat::default()).unwrap();
1162        let local_branch = tree.branch();
1163        let main_branch = tree.branch();
1164
1165        // Test method chaining
1166        let builder =
1167            PublishBuilder::new(&local_branch, &main_branch, "test-branch", Mode::Propose)
1168                .labels(vec!["bug".to_string(), "feature".to_string()])
1169                .reviewers(vec!["user1".to_string(), "user2".to_string()])
1170                .allow_create_proposal(false)
1171                .overwrite_existing(true)
1172                .allow_collaboration(true)
1173                .auto_merge(true)
1174                .work_in_progress(false);
1175
1176        // Verify all fields are set
1177        assert_eq!(
1178            builder.labels,
1179            Some(vec!["bug".to_string(), "feature".to_string()])
1180        );
1181        assert_eq!(
1182            builder.reviewers,
1183            Some(vec!["user1".to_string(), "user2".to_string()])
1184        );
1185        assert_eq!(builder.allow_create_proposal, Some(false));
1186        assert_eq!(builder.overwrite_existing, Some(true));
1187        assert_eq!(builder.allow_collaboration, Some(true));
1188        assert_eq!(builder.auto_merge, Some(true));
1189        assert_eq!(builder.work_in_progress, Some(false));
1190    }
1191
1192    #[test]
1193    fn test_publish_builder_with_tags() {
1194        use breezyshim::controldir::create_standalone_workingtree;
1195        use breezyshim::controldir::ControlDirFormat;
1196        use std::collections::HashMap;
1197
1198        let td = tempfile::tempdir().unwrap();
1199        let tree = create_standalone_workingtree(td.path(), &ControlDirFormat::default()).unwrap();
1200        let local_branch = tree.branch();
1201        let main_branch = tree.branch();
1202
1203        let mut tags = HashMap::new();
1204        tags.insert("v1.0".to_string(), RevisionId::from(b"rev1".to_vec()));
1205        tags.insert("v2.0".to_string(), RevisionId::from(b"rev2".to_vec()));
1206
1207        let builder = PublishBuilder::new(&local_branch, &main_branch, "test-branch", Mode::Push)
1208            .tags(tags.clone());
1209
1210        assert_eq!(builder.tags, Some(tags));
1211    }
1212
1213    #[test]
1214    fn test_empty_proposal_detection() {
1215        use breezyshim::controldir::create_standalone_workingtree;
1216        use breezyshim::controldir::ControlDirFormat;
1217
1218        let td = tempfile::tempdir().unwrap();
1219        let tree = create_standalone_workingtree(td.path(), &ControlDirFormat::default()).unwrap();
1220        let local_branch = tree.branch();
1221        let main_branch = tree.branch();
1222
1223        // Create a scenario where proposal would be empty
1224        // Both branches have the same content
1225
1226        // Test with allow_empty = false (default)
1227        // Using Mode::Push which doesn't require a forge
1228        let result = PublishBuilder::new(&local_branch, &main_branch, "test-branch", Mode::Push)
1229            .publish(
1230                |_fmt, _mp| "Test description".to_string(),
1231                None::<fn(Option<&MergeProposal>) -> Option<String>>,
1232                None::<fn(Option<&MergeProposal>) -> Option<String>>,
1233            );
1234
1235        // Should succeed since Mode::Push doesn't check for empty proposals
1236        // TODO: This test needs to be redesigned to properly test EmptyMergeProposal
1237        // It would require setting up a mock forge that supports Mode::Propose
1238        assert!(result.is_ok());
1239    }
1240
1241    #[test]
1242    fn test_forge_mode_validation() {
1243        use breezyshim::controldir::create_standalone_workingtree;
1244        use breezyshim::controldir::ControlDirFormat;
1245
1246        let td = tempfile::tempdir().unwrap();
1247        let tree = create_standalone_workingtree(td.path(), &ControlDirFormat::default()).unwrap();
1248        let local_branch = tree.branch();
1249        let main_branch = tree.branch();
1250
1251        // Test modes that require forge without providing forge
1252        let modes_requiring_forge = vec![Mode::Propose, Mode::PushDerived, Mode::Bts];
1253
1254        for mode in modes_requiring_forge {
1255            let result = PublishBuilder::new(&local_branch, &main_branch, "test-branch", mode)
1256                .publish(
1257                    |_fmt, _mp| "Test description".to_string(),
1258                    None::<fn(Option<&MergeProposal>) -> Option<String>>,
1259                    None::<fn(Option<&MergeProposal>) -> Option<String>>,
1260                );
1261
1262            // Should fail with UnsupportedForge error
1263            match result {
1264                Err(Error::UnsupportedForge(_)) => {
1265                    // Expected error
1266                }
1267                _ => panic!(
1268                    "Expected UnsupportedForge error for mode {:?}, got: {:?}",
1269                    mode, result
1270                ),
1271            }
1272        }
1273
1274        // Test modes that don't require forge
1275        let modes_not_requiring_forge = vec![Mode::Push, Mode::AttemptPush];
1276
1277        for mode in modes_not_requiring_forge {
1278            // These should not fail due to missing forge
1279            // (they may fail for other reasons in this test setup)
1280            let _result = PublishBuilder::new(&local_branch, &main_branch, "test-branch", mode)
1281                .publish(
1282                    |_fmt, _mp| "Test description".to_string(),
1283                    None::<fn(Option<&MergeProposal>) -> Option<String>>,
1284                    None::<fn(Option<&MergeProposal>) -> Option<String>>,
1285                );
1286        }
1287    }
1288
1289    #[test]
1290    fn test_publish_builder_auto_merge() {
1291        use breezyshim::controldir::create_standalone_workingtree;
1292        use breezyshim::controldir::ControlDirFormat;
1293
1294        let td = tempfile::tempdir().unwrap();
1295        let tree = create_standalone_workingtree(td.path(), &ControlDirFormat::default()).unwrap();
1296        let local_branch = tree.branch();
1297        let main_branch = tree.branch();
1298
1299        // Test default auto_merge is None
1300        let builder =
1301            PublishBuilder::new(&local_branch, &main_branch, "test-branch", Mode::Propose);
1302        assert_eq!(builder.auto_merge, None);
1303
1304        // Test setting auto_merge to true
1305        let builder = builder.auto_merge(true);
1306        assert_eq!(builder.auto_merge, Some(true));
1307
1308        // Test setting auto_merge to false
1309        let builder = builder.auto_merge(false);
1310        assert_eq!(builder.auto_merge, Some(false));
1311    }
1312}