Skip to content

Commit 42f0742

Browse files
committed
Cherry apply status
1 parent 1dc94a0 commit 42f0742

File tree

10 files changed

+203
-5
lines changed

10 files changed

+203
-5
lines changed

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ but-claude = { path = "crates/but-claude" }
9090
but-cursor = { path = "crates/but-cursor" }
9191
but-broadcaster = { path = "crates/but-broadcaster" }
9292
but-gerrit = { path = "crates/but-gerrit" }
93+
but-cherry-apply = { path = "crates/but-cherry-apply" }
9394
git2-hooks = { version = "0.5.0" }
9495
itertools = "0.14.0"
9596
dirs = "6.0.0"

apps/desktop/src/lib/bootstrap/deps.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { BACKEND } from '$lib/backend';
1111
import ClipboardService, { CLIPBOARD_SERVICE } from '$lib/backend/clipboard';
1212
import BaseBranchService, { BASE_BRANCH_SERVICE } from '$lib/baseBranch/baseBranchService.svelte';
1313
import { BranchService, BRANCH_SERVICE } from '$lib/branches/branchService.svelte';
14+
import { CherryApplyService, CHERRY_APPLY_SERVICE } from '$lib/cherryApply/cherryApplyService';
1415
import CLIManager, { CLI_MANAGER } from '$lib/cli/cli';
1516
import { CLAUDE_CODE_SERVICE, ClaudeCodeService } from '$lib/codegen/claude';
1617
import { AppSettings, APP_SETTINGS } from '$lib/config/appSettings';
@@ -184,6 +185,7 @@ export function initDependencies(args: {
184185
const gitService = new GitService(backend, clientState.backendApi);
185186
const baseBranchService = new BaseBranchService(clientState.backendApi);
186187
const branchService = new BranchService(clientState['backendApi']);
188+
const cherryApplyService = new CherryApplyService(clientState.backendApi);
187189
const remotesService = new RemotesService(backend);
188190
const hooksService = new HooksService(backend);
189191

@@ -314,6 +316,7 @@ export function initDependencies(args: {
314316
[BACKEND, backend],
315317
[BASE_BRANCH_SERVICE, baseBranchService],
316318
[BRANCH_SERVICE, branchService],
319+
[CHERRY_APPLY_SERVICE, cherryApplyService],
317320
[CLAUDE_CODE_SERVICE, claudeCodeService],
318321
[CLIENT_STATE, clientState],
319322
[CLIPBOARD_SERVICE, clipboardService],
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { InjectionToken } from '@gitbutler/core/context';
2+
import type { BackendApi } from '$lib/state/clientState.svelte';
3+
4+
export type CherryApplyStatus =
5+
| {
6+
type: 'causesWorkspaceConflict';
7+
}
8+
| {
9+
type: 'lockedToStack';
10+
subject: string;
11+
}
12+
| {
13+
type: 'applicableToAnyStack';
14+
}
15+
| {
16+
type: 'noStacks';
17+
};
18+
19+
export const CHERRY_APPLY_SERVICE = new InjectionToken<CherryApplyService>('CherryApplyService');
20+
21+
export class CherryApplyService {
22+
private api: ReturnType<typeof injectEndpoints>;
23+
24+
constructor(backendApi: BackendApi) {
25+
this.api = injectEndpoints(backendApi);
26+
}
27+
28+
get status() {
29+
return this.api.endpoints.cherryApplyStatus.useQuery;
30+
}
31+
}
32+
33+
function injectEndpoints(backendApi: BackendApi) {
34+
return backendApi.injectEndpoints({
35+
endpoints: (build) => ({
36+
cherryApplyStatus: build.query<CherryApplyStatus, { projectId: string; subject: string }>(
37+
{
38+
extraOptions: { command: 'cherry_apply_status' },
39+
query: (args) => args
40+
}
41+
)
42+
})
43+
});
44+
}

crates/but-api/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ gitbutler-sync.workspace = true
4444
but-graph.workspace = true
4545
but-claude.workspace = true
4646
but-broadcaster.workspace = true
47+
but-cherry-apply.workspace = true
4748
gitbutler-oplog.workspace = true
4849
but-hunk-dependency.workspace = true
4950
serde-error = "0.1.3"
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
use crate::error::Error;
2+
use but_api_macros::api_cmd;
3+
use but_cherry_apply::{CherryApplyStatus, cherry_apply_status};
4+
use but_settings::AppSettings;
5+
use gitbutler_command_context::CommandContext;
6+
use gitbutler_oxidize::OidExt;
7+
use gitbutler_project::{Project, ProjectId};
8+
use tracing::instrument;
9+
10+
#[api_cmd]
11+
#[tauri::command(async)]
12+
#[instrument(err(Debug))]
13+
pub fn cherry_apply_status(project_id: ProjectId, subject: String) -> Result<CherryApplyStatus, Error> {
14+
let project = gitbutler_project::get(project_id)?;
15+
let ctx = CommandContext::open(&project, AppSettings::load_from_default_path_creating()?)?;
16+
let subject_oid = gix::ObjectId::from_hex(subject.as_bytes())
17+
.map_err(|e| anyhow::anyhow!("Invalid commit ID: {}", e))?;
18+
19+
but_cherry_apply::cherry_apply_status(&ctx, subject_oid).map_err(Into::into)
20+
}

crates/but-api/src/commands/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
pub mod askpass;
2+
pub mod cherry_apply;
23
pub mod claude;
34
pub mod cli;
45
pub mod config;

crates/but-cherry-apply/Cargo.toml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
[package]
2+
name = "but-cherry-apply"
3+
version = "0.0.0"
4+
edition = "2024"
5+
authors = ["GitButler <[email protected]>"]
6+
publish = false
7+
rust-version = "1.89"
8+
[lib]
9+
doctest = false
10+
11+
[dependencies]
12+
anyhow.workspace = true
13+
gix.workspace = true
14+
but-workspace.workspace = true
15+
but-rebase.workspace = true
16+
but-graph.workspace = true
17+
gitbutler-oxidize.workspace = true
18+
gitbutler-branch-actions.workspace = true
19+
gitbutler-command-context.workspace = true
20+
serde.workspace = true

crates/but-cherry-apply/src/lib.rs

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
//! Cherry Apply - Applying individual commits into the workspace.
2+
//!
3+
//! For now this doesn't consider the single branch mode, but it hopfully
4+
//! shouldn't be too much of a strech to adapt it to work.
5+
//!
6+
//! We want to have two steps:
7+
//! - cherry_apply_status: Returns a list of stack IDs where a given commit can
8+
//! be applied to
9+
//! - cherry_apply: Executes the apply
10+
//!
11+
//! ## Getting the status
12+
//!
13+
//! - list out the applied stacks with stacks_v3
14+
//! - simulate cherry picking the desired commit on to each of the stacks
15+
//! - if the cherry pick results in a conflict with one of the stacks, it MUST
16+
//! be applied there
17+
//! - if the cherry pick results in conflicts with multiple stacks, it can't
18+
//! be applied since it will cause a workspace conflict.
19+
//! There is the chance that this looks like this because the commit is
20+
//! instead conflicting your workspace's base, but this is hard to
21+
//! disambiguate accurately.
22+
//!
23+
//! - otherwise, it can be applied anywhere
24+
25+
use anyhow::{Context, Result};
26+
use but_graph::VirtualBranchesTomlMetadata;
27+
use but_workspace::{StackId, StacksFilter, stacks_v3};
28+
use gitbutler_command_context::CommandContext;
29+
use gitbutler_oxidize::GixRepositoryExt;
30+
use gix::{ObjectId, Repository};
31+
use serde::Serialize;
32+
33+
#[derive(Debug, Clone, Serialize)]
34+
#[serde(tag = "type", content = "subject", rename_all = "camelCase")]
35+
pub enum CherryApplyStatus {
36+
CausesWorkspaceConflict,
37+
/// This also means that when it gets applied to the stack, it will be in a conflicted state
38+
LockedToStack(StackId),
39+
ApplicableToAnyStack,
40+
NoStacks,
41+
}
42+
43+
pub fn cherry_apply_status(ctx: &CommandContext, subject: ObjectId) -> Result<CherryApplyStatus> {
44+
let repo = ctx.gix_repo()?;
45+
let project = ctx.project();
46+
let meta =
47+
VirtualBranchesTomlMetadata::from_path(project.gb_dir().join("virtual_branches.toml"))?;
48+
let stacks = stacks_v3(&repo, &meta, StacksFilter::InWorkspace, None)?;
49+
50+
if stacks.is_empty() {
51+
return Ok(CherryApplyStatus::NoStacks);
52+
}
53+
54+
let mut locked_stack = None;
55+
for stack in stacks {
56+
let tip = stack
57+
.heads
58+
.first()
59+
.context("Stacks always have a head")?
60+
.tip;
61+
if cherry_pick_conflicts(&repo, subject, tip)? {
62+
if locked_stack.is_some() {
63+
// Locked stack has already been set to another stack. Now there
64+
// are at least two stacks that it should be locked to, so we
65+
// can return early.
66+
return Ok(CherryApplyStatus::CausesWorkspaceConflict);
67+
} else {
68+
locked_stack = Some(
69+
stack
70+
.id
71+
.context("Currently cherry-apply only works with stacks that have ids")?,
72+
);
73+
}
74+
}
75+
}
76+
77+
if let Some(stack) = locked_stack {
78+
Ok(CherryApplyStatus::LockedToStack(stack))
79+
} else {
80+
Ok(CherryApplyStatus::ApplicableToAnyStack)
81+
}
82+
}
83+
84+
// Can a given commit be cleanly cherry picked onto another commit
85+
fn cherry_pick_conflicts(repo: &Repository, from: ObjectId, onto: ObjectId) -> Result<bool> {
86+
let from = repo.find_commit(from)?;
87+
let onto = repo.find_commit(onto)?;
88+
let base = from
89+
.parent_ids()
90+
.next()
91+
.context("The commit to be cherry picked must have a parent")?
92+
.object()?
93+
.into_commit();
94+
95+
let (merge_options_fail_fast, conflict_kind) = repo.merge_options_no_rewrites_fail_fast()?;
96+
let result = repo.merge_trees(
97+
base.tree_id()?,
98+
from.tree_id()?,
99+
onto.tree_id()?,
100+
repo.default_merge_labels(),
101+
merge_options_fail_fast,
102+
)?;
103+
104+
Ok(result.has_unresolved_conflicts(conflict_kind))
105+
}

crates/but-server/src/lib.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@ use axum::{
1212
use but_api::{
1313
App, NoParams,
1414
commands::{
15-
askpass, claude, cli, config, diff, forge, git, github, modes, open, projects as iprojects,
16-
remotes, repo, rules, secret, settings, stack, undo, users, virtual_branches, workspace,
17-
zip,
15+
askpass, cherry_apply, claude, cli, config, diff, forge, git, github, modes, open,
16+
projects as iprojects, remotes, repo, rules, secret, settings, stack, undo, users,
17+
virtual_branches, workspace, zip,
1818
},
1919
error::ToError as _,
2020
};
@@ -162,6 +162,8 @@ async fn handle_command(
162162
"changes_in_branch" => diff::changes_in_branch_cmd(request.params),
163163
"changes_in_worktree" => diff::changes_in_worktree_cmd(request.params),
164164
"assign_hunk" => diff::assign_hunk_cmd(request.params),
165+
// Cherry apply commands
166+
"cherry_apply_status" => cherry_apply::cherry_apply_status_cmd(request.params),
165167
// Workspace commands
166168
"stacks" => workspace::stacks_cmd(request.params),
167169
"head_info" => workspace::head_info_cmd(request.params),

crates/gitbutler-tauri/src/main.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ use std::sync::Arc;
1515

1616
use but_api::App;
1717
use but_api::{
18-
cli, config, diff, forge, git, modes, open, remotes, repo, rules, secret, stack, undo, users,
19-
virtual_branches, workspace,
18+
cherry_apply, cli, config, diff, forge, git, modes, open, remotes, repo, rules, secret, stack,
19+
undo, users, virtual_branches, workspace,
2020
};
2121
use but_broadcaster::Broadcaster;
2222
use but_settings::AppSettingsWithDiskSync;
@@ -238,6 +238,7 @@ fn main() {
238238
repo::pre_commit_hook_diffspecs,
239239
repo::post_commit_hook,
240240
repo::message_hook,
241+
cherry_apply::cherry_apply_status,
241242
virtual_branches::create_virtual_branch,
242243
virtual_branches::delete_local_branch,
243244
virtual_branches::get_base_branch_data,

0 commit comments

Comments
 (0)