Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
312 changes: 306 additions & 6 deletions crates/but-api/src/branch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ use but_ctx::Context;
use but_oplog::legacy::{OperationKind, SnapshotDetails, Trailer};
use but_rebase::graph_rebase::{Editor, SuccessfulRebase};
use but_workspace::branch::{
OnWorkspaceMergeConflict,
InitialBranchIntegration, OnWorkspaceMergeConflict,
apply::{WorkspaceMerge, WorkspaceReferenceNaming},
integrate_branch_upstream::InteractiveIntegration,
};
use tracing::instrument;

Expand All @@ -18,11 +19,20 @@ pub struct MoveBranchResult {
pub workspace: WorkspaceState,
}

/// Outcome after integrating a branch with an interactive integration plan.
pub struct IntegrateBranchResult {
/// Workspace state after applying or previewing the integration.
pub workspace: WorkspaceState,
}

/// JSON transport types for branch APIs.
pub mod json {
use serde::Serialize;
use serde::{Deserialize, Serialize};

use crate::branch::MoveBranchResult as InternalMoveBranchResult;
use crate::branch::{
IntegrateBranchResult as InternalIntegrateBranchResult,
MoveBranchResult as InternalMoveBranchResult,
};

/// JSON sibling of [`but_workspace::branch::apply::Outcome`].
#[derive(Debug, Serialize)]
Expand Down Expand Up @@ -78,6 +88,229 @@ pub mod json {
})
}
}

#[derive(Debug, Serialize)]
#[cfg_attr(feature = "export-schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "camelCase")]
/// JSON transport type for integrating a branch.
pub struct IntegrateBranchResult {
/// Workspace state after applying or previewing the integration.
pub workspace: crate::json::WorkspaceState,
}
#[cfg(feature = "export-schema")]
but_schemars::register_sdk_type!(IntegrateBranchResult);

impl TryFrom<InternalIntegrateBranchResult> for IntegrateBranchResult {
type Error = anyhow::Error;

fn try_from(value: InternalIntegrateBranchResult) -> Result<Self, Self::Error> {
Ok(Self {
workspace: value.workspace.try_into()?,
})
}
}

/// JSON transport type for a divergence commit row.
#[derive(Debug, Serialize)]
#[cfg_attr(feature = "export-schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "camelCase")]
pub struct IntegrationDivergenceCommit {
/// The commit shown in the graph row.
pub id: crate::json::HexHashString,
/// The first-line subject shown for the commit.
pub subject: String,
/// Human-facing ref labels rendered inline on the commit row.
pub refs: Vec<String>,
}
#[cfg(feature = "export-schema")]
but_schemars::register_sdk_type!(IntegrationDivergenceCommit);

impl From<but_workspace::branch::IntegrationDivergenceCommit> for IntegrationDivergenceCommit {
fn from(value: but_workspace::branch::IntegrationDivergenceCommit) -> Self {
let but_workspace::branch::IntegrationDivergenceCommit { id, subject, refs } = value;
Self {
id: id.into(),
subject,
refs,
}
}
}

/// JSON transport type for current branch/upstream divergence information.
#[derive(Debug, Serialize)]
#[cfg_attr(feature = "export-schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "camelCase")]
pub struct IntegrationDivergenceDisplay {
/// The local branch being integrated.
pub branch_ref_name: crate::json::FullRefName,
/// The upstream branch this local branch integrates with.
pub upstream_ref_name: crate::json::FullRefName,
/// Commits only reachable from the local branch tip down to the shared section.
pub local_only: Vec<IntegrationDivergenceCommit>,
/// Commits only reachable from the upstream branch tip down to the shared section.
pub upstream_only: Vec<IntegrationDivergenceCommit>,
/// Commits shared or matched between local and upstream above the merge-base.
pub matched: Vec<IntegrationDivergenceCommit>,
/// The merge-base row shown once at the bottom.
pub merge_base: IntegrationDivergenceCommit,
}
#[cfg(feature = "export-schema")]
but_schemars::register_sdk_type!(IntegrationDivergenceDisplay);

impl From<but_workspace::branch::IntegrationDivergenceDisplay> for IntegrationDivergenceDisplay {
fn from(value: but_workspace::branch::IntegrationDivergenceDisplay) -> Self {
let but_workspace::branch::IntegrationDivergenceDisplay {
branch_ref_name,
upstream_ref_name,
local_only,
upstream_only,
matched,
merge_base,
} = value;
Self {
branch_ref_name: branch_ref_name.into(),
upstream_ref_name: upstream_ref_name.into(),
local_only: local_only.into_iter().map(Into::into).collect(),
upstream_only: upstream_only.into_iter().map(Into::into).collect(),
matched: matched.into_iter().map(Into::into).collect(),
merge_base: merge_base.into(),
}
}
}

/// JSON transport type for a branch integration step.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "export-schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum InteractiveIntegrationStep {
/// Pick a commit, keeping it in the branch.
Pick {
/// The local commit to keep in the rewritten branch.
commit_id: crate::json::HexHashString,
},
/// Squash multiple commits into one.
Squash {
/// The ordered commits to squash together.
commits: Vec<crate::json::HexHashString>,
/// Optional replacement message for the squash commit.
message: Option<String>,
},
/// Merge a commit into the previous one.
Merge {
/// The commit whose change range should be merged.
commit_id: crate::json::HexHashString,
},
}
#[cfg(feature = "export-schema")]
but_schemars::register_sdk_type!(InteractiveIntegrationStep);

impl TryFrom<InteractiveIntegrationStep>
for but_workspace::branch::integrate_branch_upstream::InteractiveIntegrationStep
{
type Error = anyhow::Error;

fn try_from(value: InteractiveIntegrationStep) -> Result<Self, Self::Error> {
Ok(match value {
InteractiveIntegrationStep::Pick { commit_id } => Self::Pick {
commit_id: commit_id.try_into()?,
},
InteractiveIntegrationStep::Squash { commits, message } => Self::Squash {
commits: commits
.into_iter()
.map(TryInto::try_into)
.collect::<Result<_, _>>()?,
message,
},
InteractiveIntegrationStep::Merge { commit_id } => Self::Merge {
commit_id: commit_id.try_into()?,
},
})
}
}

/// JSON transport type describing an interactive branch integration plan.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "export-schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "camelCase")]
pub struct InteractiveIntegration {
/// Merge base between the upstream and the local reference.
pub merge_base: crate::json::HexHashString,
/// The ordered integration steps to apply.
pub steps: Vec<InteractiveIntegrationStep>,
}
#[cfg(feature = "export-schema")]
but_schemars::register_sdk_type!(InteractiveIntegration);

impl TryFrom<InteractiveIntegration>
for but_workspace::branch::integrate_branch_upstream::InteractiveIntegration
{
type Error = anyhow::Error;

fn try_from(value: InteractiveIntegration) -> Result<Self, Self::Error> {
let InteractiveIntegration { merge_base, steps } = value;
Ok(Self {
merge_base: merge_base.try_into()?,
steps: steps
.into_iter()
.map(TryInto::try_into)
.collect::<Result<_, _>>()?,
})
}
}

/// JSON transport type for the initial branch integration proposal.
#[derive(Debug, Serialize)]
#[cfg_attr(feature = "export-schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "camelCase")]
pub struct InitialBranchIntegration {
/// The editable execution plan for integrating the branch upstream.
pub integration: InteractiveIntegration,
/// The current divergence between local branch and upstream for display.
pub divergence: IntegrationDivergenceDisplay,
}
#[cfg(feature = "export-schema")]
but_schemars::register_sdk_type!(InitialBranchIntegration);

impl TryFrom<but_workspace::branch::InitialBranchIntegration> for InitialBranchIntegration {
type Error = anyhow::Error;

fn try_from(
value: but_workspace::branch::InitialBranchIntegration,
) -> Result<Self, Self::Error> {
let but_workspace::branch::InitialBranchIntegration {
integration,
divergence,
} = value;
Ok(Self {
integration: InteractiveIntegration {
merge_base: integration.merge_base.into(),
steps: integration
.steps
.into_iter()
.map(|step| match step {
but_workspace::branch::integrate_branch_upstream::InteractiveIntegrationStep::Pick { commit_id } => {
InteractiveIntegrationStep::Pick {
commit_id: commit_id.into(),
}
}
but_workspace::branch::integrate_branch_upstream::InteractiveIntegrationStep::Squash { commits, message } => {
InteractiveIntegrationStep::Squash {
commits: commits.into_iter().map(Into::into).collect(),
message,
}
}
but_workspace::branch::integrate_branch_upstream::InteractiveIntegrationStep::Merge { commit_id } => {
InteractiveIntegrationStep::Merge {
commit_id: commit_id.into(),
}
}
})
.collect(),
},
divergence: divergence.into(),
})
}
}
}

/// Applies a branch using the behavior described by [`apply_only_with_perm()`].
Expand Down Expand Up @@ -187,6 +420,73 @@ pub fn branch_diff(ctx: &Context, branch: String) -> anyhow::Result<TreeChanges>
but_workspace::ui::diff::changes_in_branch(&repo, &ws, branch.name())
}

/// Get the initial upstream integration script for `branch`.
#[but_api(napi, try_from = json::InitialBranchIntegration)]
#[instrument(err(Debug))]
pub fn get_initial_branch_integration(
ctx: &Context,
branch: &gix::refs::FullNameRef,
) -> anyhow::Result<InitialBranchIntegration> {
let repo = ctx.repo.get()?;
but_workspace::branch::get_initial_integration_steps_for_branch(branch, &repo)
}

/// Apply `integration` to `branch`.
///
/// This acquires exclusive worktree access from `ctx`, applies the integration
/// steps to the branch, and records an oplog snapshot on success. When
/// `dry_run` is enabled, the returned workspace previews the integration
/// result and no oplog entry is persisted.
#[but_api(napi, try_from = json::IntegrateBranchResult)]
#[instrument(err(Debug))]
pub fn apply_branch_integration(
ctx: &mut but_ctx::Context,
branch: &gix::refs::FullNameRef,
integration: json::InteractiveIntegration,
dry_run: DryRun,
) -> anyhow::Result<IntegrateBranchResult> {
let integration = integration.try_into()?;
let mut guard = ctx.exclusive_worktree_access();
apply_branch_integration_with_perm(ctx, branch, integration, dry_run, guard.write_permission())
}

/// Apply `integration` to `branch` under caller-held exclusive repository access.
///
/// It prepares a best-effort oplog snapshot, runs the interactive branch
/// integration, and commits the snapshot only if the operation succeeds. The
/// returned [`IntegrateBranchResult`] contains the post-operation workspace
/// view. When `dry_run` is enabled, it returns a preview of the resulting
/// workspace state and skips oplog persistence.
pub fn apply_branch_integration_with_perm(
ctx: &mut but_ctx::Context,
branch: &gix::refs::FullNameRef,
integration: InteractiveIntegration,
dry_run: DryRun,
perm: &mut RepoExclusive,
) -> anyhow::Result<IntegrateBranchResult> {
branch_mutation_with_snapshot(
ctx,
perm,
OperationKind::GenericBranchUpdate,
dry_run,
|ctx, perm| {
let mut meta = ctx.meta()?;
let (repo, mut ws, _) = ctx.workspace_mut_and_db_with_perm(perm)?;
let rebase = but_workspace::branch::integrate_branch_with_steps(
branch,
integration,
&mut ws,
&mut meta,
&repo,
)?;

Ok(IntegrateBranchResult {
workspace: WorkspaceState::from_successful_rebase(rebase, &repo, dry_run)?,
})
},
)
}

/// Moves a branch using the behavior described by [`move_branch_with_perm()`].
///
/// This acquires exclusive worktree access from `ctx`, moves `subject_branch`
Expand Down Expand Up @@ -299,15 +599,15 @@ pub fn tear_off_branch_with_perm(
)
}

fn branch_mutation_with_snapshot<F>(
fn branch_mutation_with_snapshot<T, F>(
ctx: &mut but_ctx::Context,
perm: &mut RepoExclusive,
operation_kind: OperationKind,
dry_run: DryRun,
operation: F,
) -> anyhow::Result<MoveBranchResult>
) -> anyhow::Result<T>
where
F: FnOnce(&mut but_ctx::Context, &mut RepoExclusive) -> anyhow::Result<MoveBranchResult>,
F: FnOnce(&mut but_ctx::Context, &mut RepoExclusive) -> anyhow::Result<T>,
{
let maybe_oplog_entry = but_oplog::UnmaterializedOplogSnapshot::from_details_with_perm(
ctx,
Expand Down
3 changes: 3 additions & 0 deletions crates/but-api/src/json.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,9 @@ mod hex_hash {
}
pub use hex_hash::{HexHash, HexHashString};

#[cfg(feature = "export-schema")]
but_schemars::register_sdk_type!(HexHashString);

/// Shared JSON transport type for mutation workspace results.
#[derive(Debug, Serialize)]
#[cfg_attr(feature = "export-schema", derive(schemars::JsonSchema))]
Expand Down
8 changes: 8 additions & 0 deletions crates/but-server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -757,6 +757,14 @@ pub async fn run(config: Config) -> anyhow::Result<()> {
)
.route("/branch_diff", but_post(but_api::branch::branch_diff_cmd))
.route("/move_branch", but_post(but_api::branch::move_branch_cmd))
.route(
"/get_initial_branch_integration",
but_post(but_api::branch::get_initial_branch_integration_cmd),
)
.route(
"/apply_branch_integration",
but_post(but_api::branch::apply_branch_integration_cmd),
)
.route(
"/tear_off_branch",
but_post(but_api::branch::tear_off_branch_cmd),
Expand Down
Loading
Loading