Skip to content

Commit 4971b3d

Browse files
committed
but-api: Integrate branch
1 parent 78b47a7 commit 4971b3d

5 files changed

Lines changed: 396 additions & 23 deletions

File tree

crates/but-api/src/branch.rs

Lines changed: 306 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,229 @@ 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+
/// Pick a commit, keeping it in the branch.
187+
Pick {
188+
/// The local commit to keep in the rewritten branch.
189+
commit_id: crate::json::HexHashString,
190+
},
191+
/// Squash multiple commits into one.
192+
Squash {
193+
/// The ordered commits to squash together.
194+
commits: Vec<crate::json::HexHashString>,
195+
/// Optional replacement message for the squash commit.
196+
message: Option<String>,
197+
},
198+
/// Merge a commit into the previous one.
199+
Merge {
200+
/// The commit whose change range should be merged.
201+
commit_id: crate::json::HexHashString,
202+
},
203+
}
204+
#[cfg(feature = "export-schema")]
205+
but_schemars::register_sdk_type!(InteractiveIntegrationStep);
206+
207+
impl TryFrom<InteractiveIntegrationStep>
208+
for but_workspace::branch::integrate_branch_upstream::InteractiveIntegrationStep
209+
{
210+
type Error = anyhow::Error;
211+
212+
fn try_from(value: InteractiveIntegrationStep) -> Result<Self, Self::Error> {
213+
Ok(match value {
214+
InteractiveIntegrationStep::Pick { commit_id } => Self::Pick {
215+
commit_id: commit_id.try_into()?,
216+
},
217+
InteractiveIntegrationStep::Squash { commits, message } => Self::Squash {
218+
commits: commits
219+
.into_iter()
220+
.map(TryInto::try_into)
221+
.collect::<Result<_, _>>()?,
222+
message,
223+
},
224+
InteractiveIntegrationStep::Merge { commit_id } => Self::Merge {
225+
commit_id: commit_id.try_into()?,
226+
},
227+
})
228+
}
229+
}
230+
231+
/// JSON transport type describing an interactive branch integration plan.
232+
#[derive(Debug, Clone, Serialize, Deserialize)]
233+
#[cfg_attr(feature = "export-schema", derive(schemars::JsonSchema))]
234+
#[serde(rename_all = "camelCase")]
235+
pub struct InteractiveIntegration {
236+
/// Merge base between the upstream and the local reference.
237+
pub merge_base: crate::json::HexHashString,
238+
/// The ordered integration steps to apply.
239+
pub steps: Vec<InteractiveIntegrationStep>,
240+
}
241+
#[cfg(feature = "export-schema")]
242+
but_schemars::register_sdk_type!(InteractiveIntegration);
243+
244+
impl TryFrom<InteractiveIntegration>
245+
for but_workspace::branch::integrate_branch_upstream::InteractiveIntegration
246+
{
247+
type Error = anyhow::Error;
248+
249+
fn try_from(value: InteractiveIntegration) -> Result<Self, Self::Error> {
250+
let InteractiveIntegration { merge_base, steps } = value;
251+
Ok(Self {
252+
merge_base: merge_base.try_into()?,
253+
steps: steps
254+
.into_iter()
255+
.map(TryInto::try_into)
256+
.collect::<Result<_, _>>()?,
257+
})
258+
}
259+
}
260+
261+
/// JSON transport type for the initial branch integration proposal.
262+
#[derive(Debug, Serialize)]
263+
#[cfg_attr(feature = "export-schema", derive(schemars::JsonSchema))]
264+
#[serde(rename_all = "camelCase")]
265+
pub struct InitialBranchIntegration {
266+
/// The editable execution plan for integrating the branch upstream.
267+
pub integration: InteractiveIntegration,
268+
/// The current divergence between local branch and upstream for display.
269+
pub divergence: IntegrationDivergenceDisplay,
270+
}
271+
#[cfg(feature = "export-schema")]
272+
but_schemars::register_sdk_type!(InitialBranchIntegration);
273+
274+
impl TryFrom<but_workspace::branch::InitialBranchIntegration> for InitialBranchIntegration {
275+
type Error = anyhow::Error;
276+
277+
fn try_from(
278+
value: but_workspace::branch::InitialBranchIntegration,
279+
) -> Result<Self, Self::Error> {
280+
let but_workspace::branch::InitialBranchIntegration {
281+
integration,
282+
divergence,
283+
} = value;
284+
Ok(Self {
285+
integration: InteractiveIntegration {
286+
merge_base: integration.merge_base.into(),
287+
steps: integration
288+
.steps
289+
.into_iter()
290+
.map(|step| match step {
291+
but_workspace::branch::integrate_branch_upstream::InteractiveIntegrationStep::Pick { commit_id } => {
292+
InteractiveIntegrationStep::Pick {
293+
commit_id: commit_id.into(),
294+
}
295+
}
296+
but_workspace::branch::integrate_branch_upstream::InteractiveIntegrationStep::Squash { commits, message } => {
297+
InteractiveIntegrationStep::Squash {
298+
commits: commits.into_iter().map(Into::into).collect(),
299+
message,
300+
}
301+
}
302+
but_workspace::branch::integrate_branch_upstream::InteractiveIntegrationStep::Merge { commit_id } => {
303+
InteractiveIntegrationStep::Merge {
304+
commit_id: commit_id.into(),
305+
}
306+
}
307+
})
308+
.collect(),
309+
},
310+
divergence: divergence.into(),
311+
})
312+
}
313+
}
81314
}
82315

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

423+
/// Get the initial upstream integration script for `branch`.
424+
#[but_api(napi, try_from = json::InitialBranchIntegration)]
425+
#[instrument(err(Debug))]
426+
pub fn get_initial_branch_integration(
427+
ctx: &Context,
428+
branch: &gix::refs::FullNameRef,
429+
) -> anyhow::Result<InitialBranchIntegration> {
430+
let repo = ctx.repo.get()?;
431+
but_workspace::branch::get_initial_integration_steps_for_branch(branch, &repo)
432+
}
433+
434+
/// Apply `integration` to `branch`.
435+
///
436+
/// This acquires exclusive worktree access from `ctx`, applies the integration
437+
/// steps to the branch, and records an oplog snapshot on success. When
438+
/// `dry_run` is enabled, the returned workspace previews the integration
439+
/// result and no oplog entry is persisted.
440+
#[but_api(napi, try_from = json::IntegrateBranchResult)]
441+
#[instrument(err(Debug))]
442+
pub fn apply_branch_integration(
443+
ctx: &mut but_ctx::Context,
444+
branch: &gix::refs::FullNameRef,
445+
integration: json::InteractiveIntegration,
446+
dry_run: DryRun,
447+
) -> anyhow::Result<IntegrateBranchResult> {
448+
let integration = integration.try_into()?;
449+
let mut guard = ctx.exclusive_worktree_access();
450+
apply_branch_integration_with_perm(ctx, branch, integration, dry_run, guard.write_permission())
451+
}
452+
453+
/// Apply `integration` to `branch` under caller-held exclusive repository access.
454+
///
455+
/// It prepares a best-effort oplog snapshot, runs the interactive branch
456+
/// integration, and commits the snapshot only if the operation succeeds. The
457+
/// returned [`IntegrateBranchResult`] contains the post-operation workspace
458+
/// view. When `dry_run` is enabled, it returns a preview of the resulting
459+
/// workspace state and skips oplog persistence.
460+
pub fn apply_branch_integration_with_perm(
461+
ctx: &mut but_ctx::Context,
462+
branch: &gix::refs::FullNameRef,
463+
integration: InteractiveIntegration,
464+
dry_run: DryRun,
465+
perm: &mut RepoExclusive,
466+
) -> anyhow::Result<IntegrateBranchResult> {
467+
branch_mutation_with_snapshot(
468+
ctx,
469+
perm,
470+
OperationKind::GenericBranchUpdate,
471+
dry_run,
472+
|ctx, perm| {
473+
let mut meta = ctx.meta()?;
474+
let (repo, mut ws, _) = ctx.workspace_mut_and_db_with_perm(perm)?;
475+
let rebase = but_workspace::branch::integrate_branch_with_steps(
476+
branch,
477+
integration,
478+
&mut ws,
479+
&mut meta,
480+
&repo,
481+
)?;
482+
483+
Ok(IntegrateBranchResult {
484+
workspace: WorkspaceState::from_successful_rebase(rebase, &repo, dry_run)?,
485+
})
486+
},
487+
)
488+
}
489+
190490
/// Moves a branch using the behavior described by [`move_branch_with_perm()`].
191491
///
192492
/// This acquires exclusive worktree access from `ctx`, moves `subject_branch`
@@ -299,15 +599,15 @@ pub fn tear_off_branch_with_perm(
299599
)
300600
}
301601

302-
fn branch_mutation_with_snapshot<F>(
602+
fn branch_mutation_with_snapshot<T, F>(
303603
ctx: &mut but_ctx::Context,
304604
perm: &mut RepoExclusive,
305605
operation_kind: OperationKind,
306606
dry_run: DryRun,
307607
operation: F,
308-
) -> anyhow::Result<MoveBranchResult>
608+
) -> anyhow::Result<T>
309609
where
310-
F: FnOnce(&mut but_ctx::Context, &mut RepoExclusive) -> anyhow::Result<MoveBranchResult>,
610+
F: FnOnce(&mut but_ctx::Context, &mut RepoExclusive) -> anyhow::Result<T>,
311611
{
312612
let maybe_oplog_entry = but_oplog::UnmaterializedOplogSnapshot::from_details_with_perm(
313613
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))]

crates/gitbutler-branch-actions/src/branch_upstream_integration.rs

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,6 @@ pub enum InteractiveIntegrationStep {
2727
#[serde(with = "but_serde::object_id", rename = "commitId")]
2828
commit_id: ObjectId,
2929
},
30-
PickUpstream {
31-
id: Uuid,
32-
#[serde(with = "but_serde::object_id", rename = "commitId")]
33-
commit_id: ObjectId,
34-
#[serde(with = "but_serde::object_id", rename = "upstreamCommitId")]
35-
upstream_commit_id: ObjectId,
36-
},
3730
Squash {
3831
id: Uuid,
3932
#[serde(with = "but_serde::object_id_vec", rename = "commits")]
@@ -189,15 +182,6 @@ fn integration_steps_to_rebase_steps(
189182
new_message: None,
190183
});
191184
}
192-
InteractiveIntegrationStep::PickUpstream {
193-
upstream_commit_id: upstream_commit,
194-
..
195-
} => {
196-
rebase_steps.push(RebaseStep::Pick {
197-
commit_id: upstream_commit.to_owned(),
198-
new_message: None,
199-
});
200-
}
201185
InteractiveIntegrationStep::Skip { .. } => {
202186
// Skip steps are simply not added to the rebase steps
203187
}

0 commit comments

Comments
 (0)