Skip to content

Commit d526389

Browse files
committed
but-api: Integrate branch
1 parent 9cc6300 commit d526389

4 files changed

Lines changed: 428 additions & 7 deletions

File tree

crates/but-api/src/branch.rs

Lines changed: 330 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ use but_ctx::Context;
77
use but_oplog::legacy::{OperationKind, SnapshotDetails, Trailer};
88
use but_rebase::graph_rebase::{Editor, SuccessfulRebase};
99
use but_workspace::branch::{
10-
OnWorkspaceMergeConflict,
10+
InitialBranchIntegration, OnWorkspaceMergeConflict,
1111
apply::{WorkspaceMerge, WorkspaceReferenceNaming},
12+
integrate_branch_upstream::InteractiveIntegration,
1213
};
1314
use tracing::instrument;
1415

@@ -18,11 +19,20 @@ pub struct MoveBranchResult {
1819
pub workspace: WorkspaceState,
1920
}
2021

22+
/// Outcome after integrating a branch with an interactive integration plan.
23+
pub struct IntegrateBranchResult {
24+
/// Workspace state after applying or previewing the integration.
25+
pub workspace: WorkspaceState,
26+
}
27+
2128
/// JSON transport types for branch APIs.
2229
pub mod json {
23-
use serde::Serialize;
30+
use serde::{Deserialize, Serialize};
2431

25-
use crate::branch::MoveBranchResult as InternalMoveBranchResult;
32+
use crate::branch::{
33+
IntegrateBranchResult as InternalIntegrateBranchResult,
34+
MoveBranchResult as InternalMoveBranchResult,
35+
};
2636

2737
/// JSON sibling of [`but_workspace::branch::apply::Outcome`].
2838
#[derive(Debug, Serialize)]
@@ -78,6 +88,253 @@ pub mod json {
7888
})
7989
}
8090
}
91+
92+
#[derive(Debug, Serialize)]
93+
#[cfg_attr(feature = "export-schema", derive(schemars::JsonSchema))]
94+
#[serde(rename_all = "camelCase")]
95+
/// JSON transport type for integrating a branch.
96+
pub struct IntegrateBranchResult {
97+
/// Workspace state after applying or previewing the integration.
98+
pub workspace: crate::json::WorkspaceState,
99+
}
100+
#[cfg(feature = "export-schema")]
101+
but_schemars::register_sdk_type!(IntegrateBranchResult);
102+
103+
impl TryFrom<InternalIntegrateBranchResult> for IntegrateBranchResult {
104+
type Error = anyhow::Error;
105+
106+
fn try_from(value: InternalIntegrateBranchResult) -> Result<Self, Self::Error> {
107+
Ok(Self {
108+
workspace: value.workspace.try_into()?,
109+
})
110+
}
111+
}
112+
113+
/// JSON transport type for a divergence commit row.
114+
#[derive(Debug, Serialize)]
115+
#[cfg_attr(feature = "export-schema", derive(schemars::JsonSchema))]
116+
#[serde(rename_all = "camelCase")]
117+
pub struct IntegrationDivergenceCommit {
118+
/// The commit shown in the graph row.
119+
pub id: crate::json::HexHashString,
120+
/// The first-line subject shown for the commit.
121+
pub subject: String,
122+
/// Human-facing ref labels rendered inline on the commit row.
123+
pub refs: Vec<String>,
124+
}
125+
#[cfg(feature = "export-schema")]
126+
but_schemars::register_sdk_type!(IntegrationDivergenceCommit);
127+
128+
impl From<but_workspace::branch::IntegrationDivergenceCommit> for IntegrationDivergenceCommit {
129+
fn from(value: but_workspace::branch::IntegrationDivergenceCommit) -> Self {
130+
let but_workspace::branch::IntegrationDivergenceCommit { id, subject, refs } = value;
131+
Self {
132+
id: id.into(),
133+
subject,
134+
refs,
135+
}
136+
}
137+
}
138+
139+
/// JSON transport type for current branch/upstream divergence information.
140+
#[derive(Debug, Serialize)]
141+
#[cfg_attr(feature = "export-schema", derive(schemars::JsonSchema))]
142+
#[serde(rename_all = "camelCase")]
143+
pub struct IntegrationDivergenceDisplay {
144+
/// The local branch being integrated.
145+
pub branch_ref_name: crate::json::FullRefName,
146+
/// The upstream branch this local branch integrates with.
147+
pub upstream_ref_name: crate::json::FullRefName,
148+
/// Commits only reachable from the local branch tip down to the shared section.
149+
pub local_only: Vec<IntegrationDivergenceCommit>,
150+
/// Commits only reachable from the upstream branch tip down to the shared section.
151+
pub upstream_only: Vec<IntegrationDivergenceCommit>,
152+
/// Commits shared or matched between local and upstream above the merge-base.
153+
pub matched: Vec<IntegrationDivergenceCommit>,
154+
/// The merge-base row shown once at the bottom.
155+
pub merge_base: IntegrationDivergenceCommit,
156+
}
157+
#[cfg(feature = "export-schema")]
158+
but_schemars::register_sdk_type!(IntegrationDivergenceDisplay);
159+
160+
impl From<but_workspace::branch::IntegrationDivergenceDisplay> for IntegrationDivergenceDisplay {
161+
fn from(value: but_workspace::branch::IntegrationDivergenceDisplay) -> Self {
162+
let but_workspace::branch::IntegrationDivergenceDisplay {
163+
branch_ref_name,
164+
upstream_ref_name,
165+
local_only,
166+
upstream_only,
167+
matched,
168+
merge_base,
169+
} = value;
170+
Self {
171+
branch_ref_name: branch_ref_name.into(),
172+
upstream_ref_name: upstream_ref_name.into(),
173+
local_only: local_only.into_iter().map(Into::into).collect(),
174+
upstream_only: upstream_only.into_iter().map(Into::into).collect(),
175+
matched: matched.into_iter().map(Into::into).collect(),
176+
merge_base: merge_base.into(),
177+
}
178+
}
179+
}
180+
181+
/// JSON transport type for a branch integration step.
182+
#[derive(Debug, Clone, Serialize, Deserialize)]
183+
#[cfg_attr(feature = "export-schema", derive(schemars::JsonSchema))]
184+
#[serde(rename_all = "camelCase", tag = "kind")]
185+
pub enum InteractiveIntegrationStep {
186+
/// Skip a given commit, effectively removing it from the branch.
187+
Skip {
188+
/// The commit to omit from the rewritten branch.
189+
commit_id: crate::json::HexHashString,
190+
},
191+
/// Pick a commit, keeping it in the branch.
192+
Pick {
193+
/// The local commit to keep in the rewritten branch.
194+
commit_id: crate::json::HexHashString,
195+
},
196+
/// Pick an upstream commit.
197+
PickUpstream {
198+
/// The upstream commit to integrate into the branch.
199+
commit_id: crate::json::HexHashString,
200+
},
201+
/// Squash multiple commits into one.
202+
Squash {
203+
/// The ordered commits to squash together.
204+
commits: Vec<crate::json::HexHashString>,
205+
/// Optional replacement message for the squash commit.
206+
message: Option<String>,
207+
},
208+
/// Merge a commit into the previous one.
209+
Merge {
210+
/// The commit whose change range should be merged.
211+
commit_id: crate::json::HexHashString,
212+
},
213+
}
214+
#[cfg(feature = "export-schema")]
215+
but_schemars::register_sdk_type!(InteractiveIntegrationStep);
216+
217+
impl TryFrom<InteractiveIntegrationStep>
218+
for but_workspace::branch::integrate_branch_upstream::InteractiveIntegrationStep
219+
{
220+
type Error = anyhow::Error;
221+
222+
fn try_from(value: InteractiveIntegrationStep) -> Result<Self, Self::Error> {
223+
Ok(match value {
224+
InteractiveIntegrationStep::Skip { commit_id } => Self::Skip {
225+
commit_id: commit_id.try_into()?,
226+
},
227+
InteractiveIntegrationStep::Pick { commit_id } => Self::Pick {
228+
commit_id: commit_id.try_into()?,
229+
},
230+
InteractiveIntegrationStep::PickUpstream { commit_id } => Self::PickUpstream {
231+
commit_id: commit_id.try_into()?,
232+
},
233+
InteractiveIntegrationStep::Squash { commits, message } => Self::Squash {
234+
commits: commits
235+
.into_iter()
236+
.map(TryInto::try_into)
237+
.collect::<Result<_, _>>()?,
238+
message,
239+
},
240+
InteractiveIntegrationStep::Merge { commit_id } => Self::Merge {
241+
commit_id: commit_id.try_into()?,
242+
},
243+
})
244+
}
245+
}
246+
247+
/// JSON transport type describing an interactive branch integration plan.
248+
#[derive(Debug, Clone, Serialize, Deserialize)]
249+
#[cfg_attr(feature = "export-schema", derive(schemars::JsonSchema))]
250+
#[serde(rename_all = "camelCase")]
251+
pub struct InteractiveIntegration {
252+
/// Merge base between the upstream and the local reference.
253+
pub merge_base: crate::json::HexHashString,
254+
/// The ordered integration steps to apply.
255+
pub steps: Vec<InteractiveIntegrationStep>,
256+
}
257+
#[cfg(feature = "export-schema")]
258+
but_schemars::register_sdk_type!(InteractiveIntegration);
259+
260+
impl TryFrom<InteractiveIntegration>
261+
for but_workspace::branch::integrate_branch_upstream::InteractiveIntegration
262+
{
263+
type Error = anyhow::Error;
264+
265+
fn try_from(value: InteractiveIntegration) -> Result<Self, Self::Error> {
266+
let InteractiveIntegration { merge_base, steps } = value;
267+
Ok(Self {
268+
merge_base: merge_base.try_into()?,
269+
steps: steps
270+
.into_iter()
271+
.map(TryInto::try_into)
272+
.collect::<Result<_, _>>()?,
273+
})
274+
}
275+
}
276+
277+
/// JSON transport type for the initial branch integration proposal.
278+
#[derive(Debug, Serialize)]
279+
#[cfg_attr(feature = "export-schema", derive(schemars::JsonSchema))]
280+
#[serde(rename_all = "camelCase")]
281+
pub struct InitialBranchIntegration {
282+
/// The editable execution plan for integrating the branch upstream.
283+
pub integration: InteractiveIntegration,
284+
/// The current divergence between local branch and upstream for display.
285+
pub divergence: IntegrationDivergenceDisplay,
286+
}
287+
#[cfg(feature = "export-schema")]
288+
but_schemars::register_sdk_type!(InitialBranchIntegration);
289+
290+
impl TryFrom<but_workspace::branch::InitialBranchIntegration> for InitialBranchIntegration {
291+
type Error = anyhow::Error;
292+
293+
fn try_from(value: but_workspace::branch::InitialBranchIntegration) -> Result<Self, Self::Error> {
294+
let but_workspace::branch::InitialBranchIntegration {
295+
integration,
296+
divergence,
297+
} = value;
298+
Ok(Self {
299+
integration: InteractiveIntegration {
300+
merge_base: integration.merge_base.into(),
301+
steps: integration
302+
.steps
303+
.into_iter()
304+
.map(|step| match step {
305+
but_workspace::branch::integrate_branch_upstream::InteractiveIntegrationStep::Skip { commit_id } => {
306+
InteractiveIntegrationStep::Skip {
307+
commit_id: commit_id.into(),
308+
}
309+
}
310+
but_workspace::branch::integrate_branch_upstream::InteractiveIntegrationStep::Pick { commit_id } => {
311+
InteractiveIntegrationStep::Pick {
312+
commit_id: commit_id.into(),
313+
}
314+
}
315+
but_workspace::branch::integrate_branch_upstream::InteractiveIntegrationStep::PickUpstream { commit_id } => {
316+
InteractiveIntegrationStep::PickUpstream {
317+
commit_id: commit_id.into(),
318+
}
319+
}
320+
but_workspace::branch::integrate_branch_upstream::InteractiveIntegrationStep::Squash { commits, message } => {
321+
InteractiveIntegrationStep::Squash {
322+
commits: commits.into_iter().map(Into::into).collect(),
323+
message,
324+
}
325+
}
326+
but_workspace::branch::integrate_branch_upstream::InteractiveIntegrationStep::Merge { commit_id } => {
327+
InteractiveIntegrationStep::Merge {
328+
commit_id: commit_id.into(),
329+
}
330+
}
331+
})
332+
.collect(),
333+
},
334+
divergence: divergence.into(),
335+
})
336+
}
337+
}
81338
}
82339

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

447+
/// Get the initial upstream integration script for `branch`.
448+
#[but_api(napi, try_from = json::InitialBranchIntegration)]
449+
#[instrument(err(Debug))]
450+
pub fn get_initial_branch_integration(
451+
ctx: &Context,
452+
branch: &gix::refs::FullNameRef,
453+
) -> anyhow::Result<InitialBranchIntegration> {
454+
let repo = ctx.repo.get()?;
455+
but_workspace::branch::get_initial_integration_steps_for_branch(branch, &repo)
456+
}
457+
458+
/// Apply `integration` to `branch`.
459+
///
460+
/// This acquires exclusive worktree access from `ctx`, applies the integration
461+
/// steps to the branch, and records an oplog snapshot on success. When
462+
/// `dry_run` is enabled, the returned workspace previews the integration
463+
/// result and no oplog entry is persisted.
464+
#[but_api(napi, try_from = json::IntegrateBranchResult)]
465+
#[instrument(err(Debug))]
466+
pub fn apply_branch_integration(
467+
ctx: &mut but_ctx::Context,
468+
branch: &gix::refs::FullNameRef,
469+
integration: json::InteractiveIntegration,
470+
dry_run: DryRun,
471+
) -> anyhow::Result<IntegrateBranchResult> {
472+
let integration = integration.try_into()?;
473+
let mut guard = ctx.exclusive_worktree_access();
474+
apply_branch_integration_with_perm(ctx, branch, integration, dry_run, guard.write_permission())
475+
}
476+
477+
/// Apply `integration` to `branch` under caller-held exclusive repository access.
478+
///
479+
/// It prepares a best-effort oplog snapshot, runs the interactive branch
480+
/// integration, and commits the snapshot only if the operation succeeds. The
481+
/// returned [`IntegrateBranchResult`] contains the post-operation workspace
482+
/// view. When `dry_run` is enabled, it returns a preview of the resulting
483+
/// workspace state and skips oplog persistence.
484+
pub fn apply_branch_integration_with_perm(
485+
ctx: &mut but_ctx::Context,
486+
branch: &gix::refs::FullNameRef,
487+
integration: InteractiveIntegration,
488+
dry_run: DryRun,
489+
perm: &mut RepoExclusive,
490+
) -> anyhow::Result<IntegrateBranchResult> {
491+
branch_mutation_with_snapshot(
492+
ctx,
493+
perm,
494+
OperationKind::GenericBranchUpdate,
495+
dry_run,
496+
|ctx, perm| {
497+
let mut meta = ctx.meta()?;
498+
let (repo, mut ws, _) = ctx.workspace_mut_and_db_with_perm(perm)?;
499+
let rebase = but_workspace::branch::integrate_branch_with_steps(
500+
branch,
501+
integration,
502+
&mut ws,
503+
&mut meta,
504+
&repo,
505+
)?;
506+
507+
Ok(IntegrateBranchResult {
508+
workspace: WorkspaceState::from_successful_rebase(rebase, &repo, dry_run)?,
509+
})
510+
},
511+
)
512+
}
513+
190514
/// Moves a branch using the behavior described by [`move_branch_with_perm()`].
191515
///
192516
/// This acquires exclusive worktree access from `ctx`, moves `subject_branch`
@@ -299,15 +623,15 @@ pub fn tear_off_branch_with_perm(
299623
)
300624
}
301625

302-
fn branch_mutation_with_snapshot<F>(
626+
fn branch_mutation_with_snapshot<T, F>(
303627
ctx: &mut but_ctx::Context,
304628
perm: &mut RepoExclusive,
305629
operation_kind: OperationKind,
306630
dry_run: DryRun,
307631
operation: F,
308-
) -> anyhow::Result<MoveBranchResult>
632+
) -> anyhow::Result<T>
309633
where
310-
F: FnOnce(&mut but_ctx::Context, &mut RepoExclusive) -> anyhow::Result<MoveBranchResult>,
634+
F: FnOnce(&mut but_ctx::Context, &mut RepoExclusive) -> anyhow::Result<T>,
311635
{
312636
let maybe_oplog_entry = but_oplog::UnmaterializedOplogSnapshot::from_details_with_perm(
313637
ctx,

crates/but-api/src/json.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,9 @@ mod hex_hash {
131131
}
132132
pub use hex_hash::{HexHash, HexHashString};
133133

134+
#[cfg(feature = "export-schema")]
135+
but_schemars::register_sdk_type!(HexHashString);
136+
134137
/// Shared JSON transport type for mutation workspace results.
135138
#[derive(Debug, Serialize)]
136139
#[cfg_attr(feature = "export-schema", derive(schemars::JsonSchema))]

0 commit comments

Comments
 (0)