Skip to content

Commit bfd2314

Browse files
author
Kiril Videlov
committed
refactor: get hunk locks from HunkDependencies instead of HunkAssignment in absorb
1 parent 8a6f862 commit bfd2314

2 files changed

Lines changed: 121 additions & 23 deletions

File tree

crates/but-api/src/legacy/absorb.rs

Lines changed: 106 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ use but_hunk_assignment::{
1111
AbsorptionReason, AbsorptionTarget, CommitAbsorption, CommitMap, FileAbsorption,
1212
GroupedChanges, HunkAssignment, convert_assignments_to_diff_specs,
1313
};
14-
use but_hunk_dependency::ui::{HunkLock, HunkLockTarget};
14+
use but_hunk_dependency::ui::{
15+
HunkDependencies, HunkLock, HunkLockTarget,
16+
hunk_dependencies_for_workspace_changes_by_worktree_dir,
17+
};
1518
use but_rebase::graph_rebase::mutate::{InsertSide, RelativeTo};
1619
use but_workspace::ui::StackDetails;
1720
use gitbutler_oplog::{
@@ -97,13 +100,14 @@ pub fn absorption_plan(
97100
ctx: &mut Context,
98101
target: AbsorptionTarget,
99102
) -> anyhow::Result<Vec<CommitAbsorption>> {
100-
let assignments = match target {
103+
let (assignments, dependencies) = match target {
101104
AbsorptionTarget::Branch { branch_name } => {
102105
// Get all worktree changes, assignments, and dependencies
103106
// TODO: Ideally, there's a simpler way of getting the worktree changes without passing the context to it.
104107
// At this time, the context is passed pretty deep into the function.
105108
let worktree_changes = changes_in_worktree(ctx)?;
106109
let all_assignments = worktree_changes.assignments;
110+
let dependencies = worktree_changes.dependencies;
107111

108112
// Get the stack ID for this branch
109113
let stacks = crate::legacy::workspace::stacks(ctx, None)?;
@@ -131,7 +135,7 @@ pub fn absorption_plan(
131135
anyhow::bail!("No uncommitted changes assigned to branch: {branch_name}");
132136
}
133137

134-
stack_assignments
138+
(stack_assignments, dependencies)
135139
}
136140
AbsorptionTarget::TreeChanges {
137141
changes,
@@ -140,6 +144,7 @@ pub fn absorption_plan(
140144
// Get all worktree changes, assignments, and dependencies
141145
let worktree_changes = changes_in_worktree(ctx)?;
142146
let all_assignments = worktree_changes.assignments;
147+
let dependencies = worktree_changes.dependencies;
143148

144149
// Filter assignments to just this stack
145150
let stack_assignments: Vec<_> = all_assignments
@@ -155,23 +160,34 @@ pub fn absorption_plan(
155160
anyhow::bail!("No uncommitted changes assigned to stack: {assigned_stack_id:?}");
156161
}
157162

158-
stack_assignments
163+
(stack_assignments, dependencies)
164+
}
165+
AbsorptionTarget::HunkAssignments { assignments } => {
166+
// Compute hunk dependencies only for this target since changes_in_worktree isn't called
167+
let (_read_guard, repo, ws, _db) = ctx.workspace_and_db()?;
168+
let dependencies =
169+
hunk_dependencies_for_workspace_changes_by_worktree_dir(&repo, &ws, None).ok();
170+
drop((_read_guard, repo, ws, _db));
171+
(assignments, dependencies)
159172
}
160-
AbsorptionTarget::HunkAssignments { assignments } => assignments,
161173
AbsorptionTarget::All => {
162174
// Get all worktree changes, assignments, and dependencies
163175
// TODO: Ideally, there's a simpler way of getting the worktree changes without passing the context to it.
164176
// At this time, the context is passed pretty deep into the function.
165177
let worktree_changes = changes_in_worktree(ctx)?;
166-
worktree_changes.assignments
178+
(worktree_changes.assignments, worktree_changes.dependencies)
167179
}
168180
};
169181

170182
let mut guard = ctx.exclusive_worktree_access();
171183

172184
// Group all changes by their target commit
173-
let changes_by_commit =
174-
group_changes_by_target_commit(ctx, &assignments, guard.write_permission())?;
185+
let changes_by_commit = group_changes_by_target_commit(
186+
ctx,
187+
&assignments,
188+
dependencies.as_ref(),
189+
guard.write_permission(),
190+
)?;
175191

176192
// Prepare commit absorptions for display
177193
let commit_absorptions = prepare_commit_absorptions(ctx, changes_by_commit)?;
@@ -183,17 +199,30 @@ pub fn absorption_plan(
183199
fn group_changes_by_target_commit(
184200
ctx: &mut Context,
185201
assignments: &[HunkAssignment],
202+
dependencies: Option<&HunkDependencies>,
186203
perm: &mut RepoExclusive,
187204
) -> anyhow::Result<GroupedChanges> {
188205
let mut changes_by_commit: GroupedChanges = BTreeMap::new();
189206

190207
let mut stack_details_cache = HashMap::<StackId, StackDetails>::new();
191208

209+
// Build an index for O(1) lock lookups per assignment
210+
let lock_index = dependencies.map(build_lock_index);
211+
192212
// Process each assignment
193213
for assignment in assignments {
194214
// Determine the target commit for this assignment
195-
let (stack_id, commit_id, reason) =
196-
determine_target_commit(ctx, assignment, &mut stack_details_cache, perm)?;
215+
let locks = lock_index
216+
.as_ref()
217+
.map(|idx| locks_for_assignment(idx, assignment))
218+
.filter(|l| !l.is_empty());
219+
let (stack_id, commit_id, reason) = determine_target_commit(
220+
ctx,
221+
assignment,
222+
locks.as_deref(),
223+
&mut stack_details_cache,
224+
perm,
225+
)?;
197226

198227
let entry = changes_by_commit
199228
.entry((stack_id, commit_id))
@@ -209,6 +238,71 @@ fn group_changes_by_target_commit(
209238
Ok(changes_by_commit)
210239
}
211240

241+
/// Per-file entries of `(DiffHunk, locks)` for range-based lock matching.
242+
type LockIndex = HashMap<String, Vec<(but_core::unified_diff::DiffHunk, Vec<HunkLock>)>>;
243+
244+
/// Build a lookup index from hunk dependencies, grouped by file path.
245+
///
246+
/// Each entry retains the original `DiffHunk` range so that lookups can match
247+
/// by range overlap rather than exact header equality. This is necessary because
248+
/// dependency hunks are computed with 0 context lines while assignment hunks use
249+
/// the user's `context_lines` setting, so their headers differ.
250+
fn build_lock_index(dependencies: &HunkDependencies) -> LockIndex {
251+
let mut index = LockIndex::new();
252+
for (path, diff_hunk, locks) in &dependencies.diffs {
253+
index
254+
.entry(path.clone())
255+
.or_default()
256+
.push((diff_hunk.clone(), locks.clone()));
257+
}
258+
index
259+
}
260+
261+
/// Check whether two line ranges overlap.
262+
/// Ranges are `[start, start + lines)` (1-based start, length in lines).
263+
/// A range with 0 lines (pure insertion/deletion) is treated as a point at `start`.
264+
fn ranges_overlap(start_a: u32, lines_a: u32, start_b: u32, lines_b: u32) -> bool {
265+
let end_a = start_a + lines_a.max(1);
266+
let end_b = start_b + lines_b.max(1);
267+
start_a < end_b && start_b < end_a
268+
}
269+
270+
/// Look up the dependency locks for an assignment by finding dependency hunks
271+
/// whose ranges overlap with the assignment's hunk header.
272+
///
273+
/// When the assignment has no hunk header (binary/too-large diffs), all locks
274+
/// for the file are returned as a fallback.
275+
fn locks_for_assignment(index: &LockIndex, assignment: &HunkAssignment) -> Vec<HunkLock> {
276+
let Some(file_entries) = index.get(&assignment.path) else {
277+
return Vec::new();
278+
};
279+
280+
match assignment.hunk_header {
281+
Some(hunk_header) => {
282+
let mut locks = Vec::new();
283+
for (dep_hunk, dep_locks) in file_entries {
284+
// Match on the new-file side: assignment hunks describe worktree
285+
// state (new), and dependency hunks record which committed ranges
286+
// they depend on.
287+
if ranges_overlap(
288+
dep_hunk.new_start,
289+
dep_hunk.new_lines,
290+
hunk_header.new_start,
291+
hunk_header.new_lines,
292+
) {
293+
locks.extend(dep_locks.iter().cloned());
294+
}
295+
}
296+
locks
297+
}
298+
// No hunk header (binary/too-large) — return all locks for the file.
299+
None => file_entries
300+
.iter()
301+
.flat_map(|(_, locks)| locks.iter().cloned())
302+
.collect(),
303+
}
304+
}
305+
212306
// Find the lock that is highest in the application order (child-most commit)
213307
fn find_top_most_lock<'a>(
214308
locks: &'a [HunkLock],
@@ -252,6 +346,7 @@ fn find_top_most_lock<'a>(
252346
fn determine_target_commit(
253347
ctx: &mut Context,
254348
assignment: &HunkAssignment,
349+
locks: Option<&[HunkLock]>,
255350
stack_details_cache: &mut HashMap<StackId, StackDetails>,
256351
perm: &mut RepoExclusive,
257352
) -> anyhow::Result<(
@@ -260,7 +355,7 @@ fn determine_target_commit(
260355
AbsorptionReason,
261356
)> {
262357
// Priority 1: Check if there's a dependency lock for this hunk
263-
if let Some(locks) = &assignment.hunk_locks {
358+
if let Some(locks) = locks {
264359
if let Some(lock) = find_top_most_lock(locks, ctx, stack_details_cache) {
265360
if let HunkLockTarget::Stack(stack_id) = lock.target {
266361
return Ok((stack_id, lock.commit_id, AbsorptionReason::HunkDependency));

crates/but/tests/but/command/absorb.rs

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ fn uncommitted_file() -> anyhow::Result<()> {
3737
Found 1 changed file to absorb:
3838
3939
Absorbed to commit: f4ea7f8 a.txt
40-
(last commit in the primary lane)
40+
(files locked to commit due to hunk range overlap)
4141
a.txt @1,4 +1,4
4242
a.txt @6,4 +6,4
4343
@@ -116,7 +116,7 @@ k0 a.txt│
116116
Found 1 changed file to absorb:
117117
118118
Absorbed to commit: f4ea7f8 a.txt
119-
(last commit in the primary lane)
119+
(files locked to commit due to hunk range overlap)
120120
a.txt @1,4 +1,4
121121
122122
@@ -323,9 +323,12 @@ Hint: run `but diff` to see uncommitted changes and `but stage <file>` to stage
323323
.stdout_eq(snapbox::str![[r#"
324324
Found 1 changed file to absorb:
325325
326-
Absorbed to commit: a7aa4ef partial change to a.txt 3
327-
(last commit in the primary lane)
326+
Absorbed to commit: 889385c partial change to a.txt 2
327+
(files locked to commit due to hunk range overlap)
328328
a.txt @1,4 +1,4
329+
330+
Absorbed to commit: a7aa4ef partial change to a.txt 3
331+
(files locked to commit due to hunk range overlap)
329332
a.txt @6,4 +6,4
330333
331334
@@ -343,10 +346,10 @@ Hint: you can run `but undo` to undo these changes
343346
┊ no changes
344347
345348
┊╭┄g0 [A]
346-
┊● a309adb partial change to a.txt 3
347-
┊│ a3:nk M a.txt
348-
┊● 889385c partial change to a.txt 2
349-
┊│ 88:nk M a.txt
349+
┊● 4822140 partial change to a.txt 3
350+
┊│ 48:nk M a.txt
351+
┊● 4593422 partial change to a.txt 2
352+
┊│ 45:nk M a.txt
350353
┊● 8dc39e0 partial change to a.txt 1
351354
┊│ 8d:nk M a.txt
352355
┊● f4ea7f8 a.txt
@@ -415,7 +418,7 @@ fn uncommitted_file_new() -> anyhow::Result<()> {
415418
Found 1 changed file to absorb:
416419
417420
Created on top of commit: f4ea7f8 a.txt
418-
(last commit in the primary lane)
421+
(files locked to commit due to hunk range overlap)
419422
a.txt @1,4 +1,4
420423
a.txt @6,4 +6,4
421424
@@ -533,7 +536,7 @@ k0 a.txt│
533536
Found 1 changed file to absorb:
534537
535538
Created on top of commit: f4ea7f8 a.txt
536-
(last commit in the primary lane)
539+
(files locked to commit due to hunk range overlap)
537540
a.txt @1,4 +1,4
538541
539542
@@ -636,7 +639,7 @@ fn dry_run_new_shows_plan_without_changes() -> anyhow::Result<()> {
636639
Found 1 changed file to absorb:
637640
638641
Created on top of commit: f4ea7f8 a.txt
639-
(last commit in the primary lane)
642+
(files locked to commit due to hunk range overlap)
640643
a.txt @1,4 +1,4
641644
a.txt @6,4 +6,4
642645
@@ -707,7 +710,7 @@ fn dry_run_shows_plan_without_changes() -> anyhow::Result<()> {
707710
Found 1 changed file to absorb:
708711
709712
Absorbed to commit: f4ea7f8 a.txt
710-
(last commit in the primary lane)
713+
(files locked to commit due to hunk range overlap)
711714
a.txt @1,4 +1,4
712715
a.txt @6,4 +6,4
713716

0 commit comments

Comments
 (0)