stax 0.96.3

Fast stacked Git branches and PRs
Documentation
#![allow(clippy::result_large_err)]

use super::branch_name::BranchNameResult;
use super::operation::report_operation;
use super::{
    BranchNameContext, BranchNameError, OperationError, OperationErrorDetails, OperationErrorKind,
    OperationEvent, OperationOutcome, OperationProgress, OperationReceipt, OperationReporter,
    OperationRequest, OperationResult, OperationSideEffects, OperationStage, RepositorySession,
    format_branch_name,
};
use crate::application::repository::MutationTargets;
use crate::engine::BranchMetadata;
use crate::git::GitRepo;
use anyhow::Context;

impl RepositorySession {
    pub fn create_empty_branch(
        &self,
        name: &str,
        parent: &str,
        reporter: &mut dyn OperationReporter,
    ) -> OperationResult {
        let request = OperationRequest::CreateBranch {
            name: name.to_owned(),
            parent: parent.to_owned(),
        };
        report_operation(request.clone(), reporter, |reporter| {
            self.create_empty_branch_unframed(&request, name, parent, reporter)
        })
    }

    pub(crate) fn create_empty_branch_with_formatted_name(
        &self,
        result: BranchNameResult,
        parent: &str,
        reporter: &mut dyn OperationReporter,
    ) -> OperationResult {
        let request = OperationRequest::CreateBranch {
            name: result.name.clone(),
            parent: parent.to_owned(),
        };
        report_operation(request.clone(), reporter, |reporter| {
            self.with_mutation(
                &request,
                MutationTargets::branches([parent.to_string()]),
                || create_empty_branch_inner(self, &request, result, parent, reporter),
            )
        })
    }

    pub(super) fn create_empty_branch_unframed(
        &self,
        request: &OperationRequest,
        name: &str,
        parent: &str,
        reporter: &mut dyn OperationReporter,
    ) -> OperationResult {
        let result = format_branch_name(name, &BranchNameContext::literal())
            .map_err(|error| map_branch_name_error(request, error))?;
        self.with_mutation(
            request,
            MutationTargets::branches([parent.to_string()]),
            || create_empty_branch_inner(self, request, result, parent, reporter),
        )
    }
}

fn create_empty_branch_inner(
    session: &RepositorySession,
    request: &OperationRequest,
    result: BranchNameResult,
    parent: &str,
    reporter: &mut dyn OperationReporter,
) -> OperationResult {
    let branch = result.name;
    let repo = session.open_repo().map_err(|error| {
        OperationError::from_source(
            request.clone(),
            OperationErrorKind::RepositoryUnavailable,
            OperationErrorDetails::None,
            "Could not open the repository",
            "Check the repository path and retry",
            &error,
            None,
            OperationSideEffects::None,
        )
    })?;
    let parent_oid = repo.branch_commit(parent).map_err(|error| {
        OperationError::from_source(
            request.clone(),
            OperationErrorKind::InvalidInput,
            OperationErrorDetails::Branch {
                branch: parent.to_string(),
            },
            format!("Parent branch '{parent}' does not exist locally"),
            "Choose an existing local parent branch and retry",
            &error,
            None,
            OperationSideEffects::None,
        )
    })?;
    reject_branch_conflict(request, &repo, &branch)?;

    reporter.report(OperationEvent::Progress(OperationProgress {
        stage: OperationStage::CreatingBranch,
        completed: 0,
        total: Some(1),
        branch: Some(branch.clone()),
        message: format!("Creating branch {branch}"),
    }));

    repo.create_branch_at_commit(&branch, &parent_oid)
        .map_err(|error| {
            OperationError::from_source(
                request.clone(),
                OperationErrorKind::LocalGit,
                OperationErrorDetails::Branch {
                    branch: branch.clone(),
                },
                format!("Could not create branch '{branch}'"),
                "Resolve the Git error and retry",
                &error,
                None,
                OperationSideEffects::None,
            )
        })?;

    let metadata = BranchMetadata::new(parent, &parent_oid);
    if let Err(error) = metadata.write(repo.inner(), &branch) {
        return Err(rollback_created_branch_error(
            request,
            &repo,
            &branch,
            false,
            error.context("metadata write failed after branch creation"),
        ));
    }

    if let Err(error) = repo.checkout(&branch) {
        return Err(rollback_created_branch_error(
            request,
            &repo,
            &branch,
            true,
            error.context("checkout failed after branch creation"),
        ));
    }

    Ok(OperationReceipt {
        request: request.clone(),
        summary: format!("Created branch {branch}"),
        affected_branches: vec![branch.clone()],
        outcome: OperationOutcome::BranchCreated {
            branch,
            parent: parent.to_string(),
        },
        transaction: None,
        warnings: result.warnings,
        side_effects: OperationSideEffects::RepositoryChanged,
    })
}

fn reject_branch_conflict(
    request: &OperationRequest,
    repo: &GitRepo,
    branch: &str,
) -> Result<(), OperationError> {
    let existing = repo.list_branches().map_err(|error| {
        OperationError::from_source(
            request.clone(),
            OperationErrorKind::LocalGit,
            OperationErrorDetails::None,
            "Could not inspect local branches",
            "Resolve the Git error and retry",
            &error,
            None,
            OperationSideEffects::None,
        )
    })?;
    if let Some(conflict) = existing.iter().find(|existing| {
        existing.as_str() == branch
            || branch.starts_with(&format!("{existing}/"))
            || existing.starts_with(&format!("{branch}/"))
    }) {
        return Err(operation_error(
            request,
            OperationErrorKind::InvalidInput,
            OperationErrorDetails::Branch {
                branch: branch.to_string(),
            },
            format!("Branch '{branch}' conflicts with existing branch '{conflict}'"),
            "Choose a different branch name and retry",
            "local branch ref namespace conflict",
            OperationSideEffects::None,
        ));
    }
    Ok(())
}

fn rollback_created_branch_error(
    request: &OperationRequest,
    repo: &GitRepo,
    branch: &str,
    delete_metadata: bool,
    original: anyhow::Error,
) -> OperationError {
    match rollback_created_branch(repo, branch, delete_metadata) {
        Ok(()) => OperationError::from_source(
            request.clone(),
            OperationErrorKind::LocalGit,
            OperationErrorDetails::Branch {
                branch: branch.to_string(),
            },
            format!("Could not create branch '{branch}'"),
            "Resolve the Git error and retry",
            &original,
            None,
            OperationSideEffects::None,
        ),
        Err(rollback) => {
            let diagnostic =
                format!("original error:\n{original:#}\n\nrollback error:\n{rollback:#}");
            operation_error(
                request,
                OperationErrorKind::LocalGit,
                OperationErrorDetails::Branch {
                    branch: branch.to_string(),
                },
                format!("Could not create branch '{branch}'"),
                "Resolve the Git error, inspect the repository, and retry",
                diagnostic,
                OperationSideEffects::RepositoryChanged,
            )
        }
    }
}

fn rollback_created_branch(
    repo: &GitRepo,
    branch: &str,
    delete_metadata: bool,
) -> anyhow::Result<()> {
    if delete_metadata {
        BranchMetadata::delete(repo.inner(), branch)
            .with_context(|| format!("failed to delete metadata for '{branch}'"))?;
    }
    repo.delete_branch(branch, true)
        .with_context(|| format!("failed to delete branch '{branch}'"))?;
    Ok(())
}

fn map_branch_name_error(request: &OperationRequest, error: BranchNameError) -> OperationError {
    let diagnostic = format!("{error:?}");
    let (primary, action) = match &error {
        BranchNameError::Empty => (
            "Branch name is required".to_string(),
            "Choose a non-empty branch name and retry".to_string(),
        ),
        BranchNameError::MissingMessagePlaceholder { .. } => (
            "Branch name format is invalid".to_string(),
            "Include {message} in the branch name format and retry".to_string(),
        ),
        BranchNameError::InvalidRef { candidate } => (
            format!("Branch name '{candidate}' is not a valid Git ref"),
            "Choose a valid branch name and retry".to_string(),
        ),
    };
    operation_error(
        request,
        OperationErrorKind::InvalidInput,
        OperationErrorDetails::None,
        primary,
        action,
        diagnostic,
        OperationSideEffects::None,
    )
}

fn operation_error(
    request: &OperationRequest,
    kind: OperationErrorKind,
    details: OperationErrorDetails,
    primary: impl Into<String>,
    action: impl Into<String>,
    diagnostic_chain: impl Into<String>,
    side_effects: OperationSideEffects,
) -> OperationError {
    OperationError {
        request: request.clone(),
        kind,
        details,
        primary: primary.into(),
        action: action.into(),
        diagnostic_chain: diagnostic_chain.into(),
        receipt: None,
        side_effects,
    }
}