Skip to content

Commit 0af83bd

Browse files
authored
Merge pull request #11065 from gitbutlerapp/building-but-absorb
building-but-absorb
2 parents 5e16f25 + 55832c1 commit 0af83bd

File tree

7 files changed

+359
-8
lines changed

7 files changed

+359
-8
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/but/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ but-graph.workspace = true
4141
but-workspace.workspace = true
4242
but-settings.workspace = true
4343
but-hunk-assignment.workspace = true
44+
but-hunk-dependency.workspace = true
4445
but-claude.workspace = true
4546
but-cursor.workspace = true
4647
but-tools.workspace = true

crates/but/src/absorb/mod.rs

Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,334 @@
1+
use std::collections::BTreeMap;
2+
3+
use bstr::{BString, ByteSlice};
4+
use but_api::{diff, hex_hash::HexHash, virtual_branches};
5+
use but_hunk_assignment::HunkAssignment;
6+
use but_hunk_dependency::ui::HunkDependencies;
7+
use but_settings::AppSettings;
8+
use but_workspace::DiffSpec;
9+
use gitbutler_command_context::CommandContext;
10+
use gitbutler_project::Project;
11+
12+
use crate::{id::CliId, rub::parse_sources};
13+
14+
/// Amends changes into the appropriate commits where they belong.
15+
///
16+
/// The semantic for finding "the appropriate commit" is as follows
17+
/// - Changes are amended into the topmost commit of the leftmost (first) lane (branch)
18+
/// - If a change is assigned to a particular lane (branch), it will be amended into a commit there
19+
/// - If there are no commits in this branch, a new commit is created
20+
/// - If a change has a dependency to a particular commit, it will be amended into that particular commit
21+
///
22+
/// Optionally an identifier to an Uncommitted File or a Branch (stack) may be provided.
23+
///
24+
/// If an Uncommitted File id is provided, absorb will be peformed for just that file
25+
/// If a Branch (stack) id is provided, absorb will be performed for all changes assigned to that stack
26+
/// If no source is provided, absorb is performed for all uncommitted changes
27+
pub(crate) fn handle(project: &Project, _json: bool, source: Option<&str>) -> anyhow::Result<()> {
28+
let ctx = &mut CommandContext::open(project, AppSettings::load_from_default_path_creating()?)?;
29+
let source: Option<CliId> = source
30+
.and_then(|s| parse_sources(ctx, s).ok())
31+
.and_then(|s| {
32+
s.into_iter().find(|s| {
33+
matches!(s, CliId::UncommittedFile { .. }) || matches!(s, CliId::Branch { .. })
34+
})
35+
});
36+
37+
// Get all worktree changes, assignments, and dependencies
38+
let worktree_changes = diff::changes_in_worktree(project.id)?;
39+
let assignments = worktree_changes.assignments;
40+
let dependencies = worktree_changes.dependencies;
41+
42+
if let Some(source) = source {
43+
match source {
44+
CliId::UncommittedFile { path, assignment } => {
45+
// Absorb this particular file
46+
absorb_file(project, ctx, &path, assignment, &assignments, &dependencies)?;
47+
}
48+
CliId::Branch { name } => {
49+
// Absorb everything that is assigned to this lane
50+
absorb_branch(project, ctx, &name, &assignments, &dependencies)?;
51+
}
52+
_ => {
53+
anyhow::bail!("Invalid source: expected an uncommitted file or branch");
54+
}
55+
}
56+
} else {
57+
// Try to absorb everything uncommitted
58+
absorb_all(project, ctx, &assignments, &dependencies)?;
59+
}
60+
Ok(())
61+
}
62+
63+
/// Absorb a single file into the appropriate commit
64+
fn absorb_file(
65+
project: &Project,
66+
_ctx: &mut CommandContext,
67+
path: &str,
68+
_assignment: Option<but_workspace::StackId>,
69+
assignments: &[HunkAssignment],
70+
dependencies: &Option<HunkDependencies>,
71+
) -> anyhow::Result<()> {
72+
// Filter assignments to just this file
73+
let file_assignments: Vec<_> = assignments
74+
.iter()
75+
.filter(|a| a.path == path)
76+
.cloned()
77+
.collect();
78+
79+
if file_assignments.is_empty() {
80+
anyhow::bail!("No uncommitted changes found for file: {}", path);
81+
}
82+
83+
// Group changes by their target commit
84+
let changes_by_commit =
85+
group_changes_by_target_commit(project.id, &file_assignments, dependencies)?;
86+
87+
// Apply each group to its target commit
88+
for ((stack_id, commit_id), file_hunks) in changes_by_commit {
89+
let diff_specs = convert_assignments_to_diff_specs(&file_hunks)?;
90+
amend_commit(project, stack_id, commit_id, diff_specs)?;
91+
}
92+
93+
Ok(())
94+
}
95+
96+
/// Absorb all files assigned to a specific branch/stack
97+
fn absorb_branch(
98+
project: &Project,
99+
_ctx: &mut CommandContext,
100+
branch_name: &str,
101+
assignments: &[HunkAssignment],
102+
dependencies: &Option<HunkDependencies>,
103+
) -> anyhow::Result<()> {
104+
// Get the stack ID for this branch
105+
let stacks = but_api::workspace::stacks(project.id, None)?;
106+
107+
// Find the stack that contains this branch
108+
let stack = stacks
109+
.iter()
110+
.find(|s| {
111+
s.heads
112+
.iter()
113+
.any(|h| h.name.to_str().map(|n| n == branch_name).unwrap_or(false))
114+
})
115+
.ok_or_else(|| anyhow::anyhow!("Branch not found: {}", branch_name))?;
116+
117+
let stack_id = stack.id.ok_or_else(|| anyhow::anyhow!("Stack has no ID"))?;
118+
119+
// Filter assignments to just this stack
120+
let stack_assignments: Vec<_> = assignments
121+
.iter()
122+
.filter(|a| a.stack_id == Some(stack_id))
123+
.cloned()
124+
.collect();
125+
126+
if stack_assignments.is_empty() {
127+
anyhow::bail!("No uncommitted changes assigned to branch: {}", branch_name);
128+
}
129+
130+
// Group changes by their target commit
131+
let changes_by_commit =
132+
group_changes_by_target_commit(project.id, &stack_assignments, dependencies)?;
133+
134+
// Apply each group to its target commit
135+
for ((target_stack_id, commit_id), hunks) in changes_by_commit {
136+
let diff_specs = convert_assignments_to_diff_specs(&hunks)?;
137+
amend_commit(project, target_stack_id, commit_id, diff_specs)?;
138+
}
139+
140+
Ok(())
141+
}
142+
143+
/// Absorb all uncommitted changes
144+
fn absorb_all(
145+
project: &Project,
146+
_ctx: &mut CommandContext,
147+
assignments: &[HunkAssignment],
148+
dependencies: &Option<HunkDependencies>,
149+
) -> anyhow::Result<()> {
150+
if assignments.is_empty() {
151+
println!("No uncommitted changes to absorb");
152+
return Ok(());
153+
}
154+
155+
// Group all changes by their target commit
156+
let changes_by_commit = group_changes_by_target_commit(project.id, assignments, dependencies)?;
157+
158+
// Apply each group to its target commit
159+
for ((stack_id, commit_id), hunks) in changes_by_commit {
160+
let diff_specs = convert_assignments_to_diff_specs(&hunks)?;
161+
amend_commit(project, stack_id, commit_id, diff_specs)?;
162+
}
163+
164+
Ok(())
165+
}
166+
167+
/// Group changes by their target commit based on dependencies and assignments
168+
fn group_changes_by_target_commit(
169+
project_id: gitbutler_project::ProjectId,
170+
assignments: &[HunkAssignment],
171+
dependencies: &Option<HunkDependencies>,
172+
) -> anyhow::Result<BTreeMap<(but_workspace::StackId, gix::ObjectId), Vec<HunkAssignment>>> {
173+
let mut changes_by_commit: BTreeMap<
174+
(but_workspace::StackId, gix::ObjectId),
175+
Vec<HunkAssignment>,
176+
> = BTreeMap::new();
177+
178+
// Process each assignment
179+
for assignment in assignments {
180+
// Determine the target commit for this assignment
181+
let (stack_id, commit_id) = determine_target_commit(project_id, assignment, dependencies)?;
182+
183+
changes_by_commit
184+
.entry((stack_id, commit_id))
185+
.or_default()
186+
.push(assignment.clone());
187+
}
188+
189+
Ok(changes_by_commit)
190+
}
191+
192+
/// Determine the target commit for an assignment based on dependencies and assignments
193+
fn determine_target_commit(
194+
project_id: gitbutler_project::ProjectId,
195+
assignment: &HunkAssignment,
196+
dependencies: &Option<HunkDependencies>,
197+
) -> anyhow::Result<(but_workspace::StackId, gix::ObjectId)> {
198+
// Priority 1: Check if there's a dependency lock for this hunk
199+
if let Some(deps) = dependencies
200+
&& let Some(_hunk_id) = assignment.id
201+
{
202+
// Find the dependency for this hunk
203+
for (path, _hunk, locks) in &deps.diffs {
204+
// Match by path and hunk content
205+
if path == &assignment.path {
206+
// If there's a lock (dependency), use the topmost commit
207+
if let Some(lock) = locks.first() {
208+
return Ok((lock.stack_id, lock.commit_id));
209+
}
210+
}
211+
}
212+
}
213+
214+
// Priority 2: Use the assignment's stack ID if available
215+
if let Some(stack_id) = assignment.stack_id {
216+
// We need to find the topmost commit in this stack
217+
let stack_details = but_api::workspace::stack_details(project_id, Some(stack_id))?;
218+
219+
// Find the topmost commit in the first branch
220+
if let Some(branch) = stack_details.branch_details.first()
221+
&& let Some(commit) = branch.commits.first()
222+
{
223+
return Ok((stack_id, commit.id));
224+
}
225+
226+
// If there are no commits in the stack, create a blank commit first
227+
virtual_branches::insert_blank_commit(project_id, stack_id, None, -1)?;
228+
229+
// Now fetch the stack details again to get the newly created commit
230+
let stack_details = but_api::workspace::stack_details(project_id, Some(stack_id))?;
231+
if let Some(branch) = stack_details.branch_details.first()
232+
&& let Some(commit) = branch.commits.first()
233+
{
234+
return Ok((stack_id, commit.id));
235+
}
236+
237+
anyhow::bail!("Failed to create blank commit in stack: {:?}", stack_id);
238+
}
239+
240+
// Priority 3: If no assignment, find the topmost commit of the leftmost lane
241+
let stacks = but_api::workspace::stacks(project_id, None)?;
242+
if let Some(stack) = stacks.first()
243+
&& let Some(stack_id) = stack.id
244+
{
245+
let stack_details = but_api::workspace::stack_details(project_id, Some(stack_id))?;
246+
247+
if let Some(branch) = stack_details.branch_details.first()
248+
&& let Some(commit) = branch.commits.first()
249+
{
250+
return Ok((stack_id, commit.id));
251+
}
252+
253+
// If the first stack has no commits, create a blank commit first
254+
virtual_branches::insert_blank_commit(project_id, stack_id, None, -1)?;
255+
256+
// Now fetch the stack details again to get the newly created commit
257+
let stack_details = but_api::workspace::stack_details(project_id, Some(stack_id))?;
258+
if let Some(branch) = stack_details.branch_details.first()
259+
&& let Some(commit) = branch.commits.first()
260+
{
261+
return Ok((stack_id, commit.id));
262+
}
263+
264+
anyhow::bail!("Failed to create blank commit in leftmost stack");
265+
}
266+
267+
anyhow::bail!(
268+
"Unable to determine target commit for unassigned change: {}",
269+
assignment.path
270+
);
271+
}
272+
273+
/// Convert HunkAssignments to DiffSpecs
274+
fn convert_assignments_to_diff_specs(
275+
assignments: &[HunkAssignment],
276+
) -> anyhow::Result<Vec<DiffSpec>> {
277+
let mut specs_by_path: BTreeMap<BString, Vec<HunkAssignment>> = BTreeMap::new();
278+
279+
// Group assignments by file path
280+
for assignment in assignments {
281+
specs_by_path
282+
.entry(assignment.path_bytes.clone())
283+
.or_default()
284+
.push(assignment.clone());
285+
}
286+
287+
// Convert to DiffSpecs
288+
let mut diff_specs = Vec::new();
289+
for (path, hunks) in specs_by_path {
290+
let mut hunk_headers = Vec::new();
291+
for hunk in hunks {
292+
if let Some(header) = hunk.hunk_header {
293+
hunk_headers.push(header);
294+
}
295+
}
296+
297+
diff_specs.push(DiffSpec {
298+
previous_path: None, // TODO: Handle renames
299+
path: path.clone(),
300+
hunk_headers,
301+
});
302+
}
303+
304+
Ok(diff_specs)
305+
}
306+
307+
/// Amend a commit with the given changes
308+
fn amend_commit(
309+
project: &Project,
310+
stack_id: but_workspace::StackId,
311+
commit_id: gix::ObjectId,
312+
diff_specs: Vec<DiffSpec>,
313+
) -> anyhow::Result<()> {
314+
// Convert commit_id to HexHash
315+
let hex_hash = HexHash::from(commit_id);
316+
317+
let outcome = but_api::workspace::amend_commit_from_worktree_changes(
318+
project.id, stack_id, hex_hash, diff_specs,
319+
)?;
320+
321+
if !outcome.paths_to_rejected_changes.is_empty() {
322+
eprintln!(
323+
"Warning: Failed to absorb {} file(s)",
324+
outcome.paths_to_rejected_changes.len()
325+
);
326+
}
327+
328+
println!(
329+
"Absorbed changes into commit {}",
330+
&commit_id.to_hex().to_string()[..7]
331+
);
332+
333+
Ok(())
334+
}

crates/but/src/args.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,12 @@ For examples see `but rub --help`."
186186
#[clap(value_enum)]
187187
shell: Option<clap_complete::Shell>,
188188
},
189+
Absorb {
190+
/// If the CliID is an uncommitted change - the change will be absorbed
191+
/// If the CliID is a stack - anything assigned to the stack will be absorbed accordingly
192+
/// If not provided, everything that is uncommitted will be absorbed
193+
source: Option<String>,
194+
},
189195
}
190196

191197
#[derive(Debug, Clone, Copy, clap::ValueEnum, Default)]

crates/but/src/lib.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ use colored::Colorize;
1010
use gix::date::time::CustomFormat;
1111
use metrics::{Event, Metrics, Props, metrics_if_configured};
1212

13+
mod absorb;
1314
mod base;
1415
mod branch;
1516
mod command;
@@ -327,6 +328,12 @@ async fn match_subcommand(
327328
metrics_if_configured(app_settings, CommandName::Snapshot, props(start, &result)).ok();
328329
result
329330
}
331+
Subcommands::Absorb { source } => {
332+
let project = get_or_init_project(&args.current_dir)?;
333+
let result = absorb::handle(&project, args.json, source.as_deref());
334+
metrics_if_configured(app_settings, CommandName::Snapshot, props(start, &result)).ok();
335+
result
336+
}
330337
Subcommands::Init { repo } => init::repo(&args.current_dir, args.json, *repo)
331338
.context("Failed to initialize GitButler project."),
332339
Subcommands::Forge(forge::integration::Platform { cmd }) => {

crates/but/src/rub/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ fn ids(
166166
Ok((sources, target_result[0].clone()))
167167
}
168168

169-
fn parse_sources(ctx: &mut CommandContext, source: &str) -> anyhow::Result<Vec<CliId>> {
169+
pub(crate) fn parse_sources(ctx: &mut CommandContext, source: &str) -> anyhow::Result<Vec<CliId>> {
170170
// Check if it's a range (contains '-')
171171
if source.contains('-') {
172172
parse_range(ctx, source)

0 commit comments

Comments
 (0)