Skip to content

Commit 4b9ad68

Browse files
committed
fix: scope absorb plan and lock indicators to visible assignments
The TreeChanges absorption target filtered assignments by stack ID, excluding unassigned hunks (stack_id = None). This prevented dependency-lock routing for those hunks, sending them to the default stack instead of the correct commit. Fix the backend filter to include hunks that are unassigned or assigned to the acting stack, excluding only hunks assigned to other stacks. The lock icon on files in the worktree list was also computed from all dependency entries regardless of which hunks were visible. A file could show a lock icon in the wrong lane because a hunk in a different lane had a dependency. Fix by filtering dependency entries using range overlap against the assignments visible in the current lane: unassigned assignments when stackId is absent, or stack-assigned when stackId is present. Add unit tests for filterDependenciesByAssignments covering stack vs unassigned filtering, overlap edge cases, and 0-line hunk handling.
1 parent fa8bd18 commit 4b9ad68

5 files changed

Lines changed: 200 additions & 9 deletions

File tree

apps/desktop/src/components/files/FileListItems.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@
8484
8585
const filePaths = $derived(controller.changes.map((change) => change.path));
8686
const fileDependenciesQuery = $derived(
87-
showLockedIndicator ? dependencyService.filesDependencies(projectId, filePaths) : null,
87+
showLockedIndicator ? dependencyService.filesDependencies(projectId, filePaths, stackId) : null,
8888
);
8989
const fileDependencies = $derived(fileDependenciesQuery?.result.data || []);
9090
</script>

apps/desktop/src/lib/dependencies/dependencyService.svelte.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
aggregateFileDependencies,
3+
filterDependenciesByAssignments,
34
type FileDependencies,
45
type HunkDependencies,
56
} from "$lib/hunks/dependencies";
@@ -37,16 +38,17 @@ export default class DependencyService {
3738
);
3839
}
3940

40-
filesDependencies(projectId: string, filePaths: string[]) {
41+
filesDependencies(projectId: string, filePaths: string[], stackId?: string) {
4142
return this.worktreeService.worktreeChanges.useQuery(
4243
{ projectId },
4344
{
44-
transform: ({ dependencies }) => {
45+
transform: ({ dependencies, hunkAssignments }) => {
4546
if (!dependencies) {
4647
return [];
4748
}
4849

49-
const e = toEntityAdapter(dependencies);
50+
const filtered = filterDependenciesByAssignments(dependencies, hunkAssignments, stackId);
51+
const e = toEntityAdapter(filtered);
5052
return fileDependencySelectors.selectByIds(e.fileDependencies, filePaths);
5153
},
5254
},
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { filterDependenciesByAssignments } from "$lib/hunks/dependencies";
2+
import { describe, expect, test } from "vitest";
3+
import type { HunkDependencies } from "$lib/hunks/dependencies";
4+
import type { HunkAssignment } from "$lib/hunks/hunk";
5+
6+
function makeDeps(
7+
entries: Array<{ path: string; newStart: number; newLines: number }>,
8+
): HunkDependencies {
9+
return {
10+
diffs: entries.map(({ path, newStart, newLines }) => [
11+
path,
12+
{ oldStart: 1, oldLines: 1, newStart, newLines, diff: "" },
13+
[{ target: { type: "stack", subject: "stack-1" }, commitId: "abc" }],
14+
]),
15+
errors: [],
16+
};
17+
}
18+
19+
function makeAssignment(
20+
path: string,
21+
stackId: string | null,
22+
newStart: number,
23+
newLines: number,
24+
): HunkAssignment {
25+
return {
26+
id: null,
27+
path,
28+
pathBytes: [],
29+
stackId,
30+
hunkHeader: { oldStart: 1, oldLines: 1, newStart, newLines },
31+
lineNumsAdded: null,
32+
lineNumsRemoved: null,
33+
};
34+
}
35+
36+
describe("filterDependenciesByAssignments", () => {
37+
describe("unassigned view (stackId = undefined)", () => {
38+
test("keeps dependency overlapping an unassigned hunk", () => {
39+
const deps = makeDeps([{ path: "a.ts", newStart: 10, newLines: 5 }]);
40+
const assignments = [makeAssignment("a.ts", null, 10, 5)];
41+
const result = filterDependenciesByAssignments(deps, assignments, undefined);
42+
expect(result.diffs).toHaveLength(1);
43+
});
44+
45+
test("drops dependency overlapping a stack-assigned hunk", () => {
46+
const deps = makeDeps([{ path: "a.ts", newStart: 10, newLines: 5 }]);
47+
const assignments = [makeAssignment("a.ts", "stack-1", 10, 5)];
48+
const result = filterDependenciesByAssignments(deps, assignments, undefined);
49+
expect(result.diffs).toHaveLength(0);
50+
});
51+
52+
test("keeps only the entry overlapping the unassigned hunk when mixed", () => {
53+
const deps = makeDeps([
54+
{ path: "a.ts", newStart: 10, newLines: 5 }, // overlaps unassigned hunk
55+
{ path: "a.ts", newStart: 50, newLines: 5 }, // overlaps stack-assigned hunk
56+
]);
57+
const assignments = [
58+
makeAssignment("a.ts", null, 10, 5),
59+
makeAssignment("a.ts", "stack-1", 50, 5),
60+
];
61+
const result = filterDependenciesByAssignments(deps, assignments, undefined);
62+
expect(result.diffs).toHaveLength(1);
63+
expect(result.diffs[0]![1].newStart).toBe(10);
64+
});
65+
});
66+
67+
describe("stack lane view (stackId = 'stack-1')", () => {
68+
test("keeps dependency overlapping a hunk assigned to this stack", () => {
69+
const deps = makeDeps([{ path: "a.ts", newStart: 10, newLines: 5 }]);
70+
const assignments = [makeAssignment("a.ts", "stack-1", 10, 5)];
71+
const result = filterDependenciesByAssignments(deps, assignments, "stack-1");
72+
expect(result.diffs).toHaveLength(1);
73+
});
74+
75+
test("drops dependency overlapping an unassigned hunk", () => {
76+
const deps = makeDeps([{ path: "a.ts", newStart: 10, newLines: 5 }]);
77+
const assignments = [makeAssignment("a.ts", null, 10, 5)];
78+
const result = filterDependenciesByAssignments(deps, assignments, "stack-1");
79+
expect(result.diffs).toHaveLength(0);
80+
});
81+
82+
test("drops dependency overlapping a hunk from a different stack", () => {
83+
const deps = makeDeps([{ path: "a.ts", newStart: 10, newLines: 5 }]);
84+
const assignments = [makeAssignment("a.ts", "stack-2", 10, 5)];
85+
const result = filterDependenciesByAssignments(deps, assignments, "stack-1");
86+
expect(result.diffs).toHaveLength(0);
87+
});
88+
});
89+
90+
describe("range overlap", () => {
91+
test("overlapping ranges match", () => {
92+
const deps = makeDeps([{ path: "a.ts", newStart: 10, newLines: 5 }]); // [10, 15)
93+
const assignments = [makeAssignment("a.ts", null, 12, 5)]; // [12, 17)
94+
const result = filterDependenciesByAssignments(deps, assignments, undefined);
95+
expect(result.diffs).toHaveLength(1);
96+
});
97+
98+
test("adjacent but non-overlapping ranges do not match", () => {
99+
const deps = makeDeps([{ path: "a.ts", newStart: 10, newLines: 5 }]); // [10, 15)
100+
const assignments = [makeAssignment("a.ts", null, 15, 5)]; // [15, 20)
101+
const result = filterDependenciesByAssignments(deps, assignments, undefined);
102+
expect(result.diffs).toHaveLength(0);
103+
});
104+
105+
test("0-line dependency hunk treated as single-line point", () => {
106+
const deps = makeDeps([{ path: "a.ts", newStart: 10, newLines: 0 }]); // treated as [10, 11)
107+
const assignments = [makeAssignment("a.ts", null, 10, 5)]; // [10, 15)
108+
const result = filterDependenciesByAssignments(deps, assignments, undefined);
109+
expect(result.diffs).toHaveLength(1);
110+
});
111+
112+
test("0-line assignment hunk treated as single-line point", () => {
113+
const deps = makeDeps([{ path: "a.ts", newStart: 10, newLines: 5 }]); // [10, 15)
114+
const assignments = [makeAssignment("a.ts", null, 10, 0)]; // treated as [10, 11)
115+
const result = filterDependenciesByAssignments(deps, assignments, undefined);
116+
expect(result.diffs).toHaveLength(1);
117+
});
118+
119+
test("no match when dependency is entirely before assignment", () => {
120+
const deps = makeDeps([{ path: "a.ts", newStart: 5, newLines: 3 }]); // [5, 8)
121+
const assignments = [makeAssignment("a.ts", null, 10, 5)]; // [10, 15)
122+
const result = filterDependenciesByAssignments(deps, assignments, undefined);
123+
expect(result.diffs).toHaveLength(0);
124+
});
125+
});
126+
127+
describe("path matching", () => {
128+
test("only matches entries with the same path", () => {
129+
const deps = makeDeps([{ path: "a.ts", newStart: 10, newLines: 5 }]);
130+
const assignments = [makeAssignment("b.ts", null, 10, 5)];
131+
const result = filterDependenciesByAssignments(deps, assignments, undefined);
132+
expect(result.diffs).toHaveLength(0);
133+
});
134+
});
135+
136+
describe("preserves errors", () => {
137+
test("errors field is passed through unchanged", () => {
138+
const deps: HunkDependencies = {
139+
diffs: [],
140+
errors: [{ errorMessage: "oops", stackId: "s", commitId: "c", path: "a.ts" }],
141+
};
142+
const result = filterDependenciesByAssignments(deps, [], undefined);
143+
expect(result.errors).toEqual(deps.errors);
144+
});
145+
});
146+
});

apps/desktop/src/lib/hunks/dependencies.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { DiffHunk } from "$lib/hunks/hunk";
1+
import type { DiffHunk, HunkAssignment } from "$lib/hunks/hunk";
22

33
export type DependencyError = {
44
description: string;
@@ -92,6 +92,48 @@ export type FileDependencies = {
9292
*/
9393
dependencies: HunkLocks[];
9494
};
95+
/**
96+
* Check whether two line ranges overlap.
97+
* Ranges are `[start, start + lines)` (1-based start, length in lines).
98+
* A range with 0 lines (pure insertion/deletion) is treated as a point at `start`.
99+
*/
100+
function rangesOverlap(startA: number, linesA: number, startB: number, linesB: number): boolean {
101+
const endA = startA + Math.max(linesA, 1);
102+
const endB = startB + Math.max(linesB, 1);
103+
return startA < endB && startB < endA;
104+
}
105+
106+
/**
107+
* Filters dependency entries to only those whose hunk ranges overlap with
108+
* assignments visible in the current view.
109+
*
110+
* When `stackId` is provided, includes only dependencies overlapping assignments
111+
* assigned to that stack (matching the stack lane's visible hunks).
112+
* When `stackId` is undefined, includes only dependencies overlapping
113+
* unassigned assignments (matching the unassigned lane).
114+
*/
115+
export function filterDependenciesByAssignments(
116+
dependencies: HunkDependencies,
117+
assignments: HunkAssignment[],
118+
stackId: string | undefined,
119+
): HunkDependencies {
120+
const filtered = dependencies.diffs.filter(([depPath, depHunk]) => {
121+
return assignments.some(
122+
(a) =>
123+
a.path === depPath &&
124+
a.hunkHeader !== null &&
125+
a.stackId === (stackId ?? null) &&
126+
rangesOverlap(
127+
depHunk.newStart,
128+
depHunk.newLines,
129+
a.hunkHeader.newStart,
130+
a.hunkHeader.newLines,
131+
),
132+
);
133+
});
134+
return { diffs: filtered, errors: dependencies.errors };
135+
}
136+
95137
/**
96138
* Aggregates file dependencies from a collection of hunk dependencies.
97139
*

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -177,18 +177,19 @@ pub fn absorption_plan_with_perm(
177177
let all_assignments = worktree_changes.assignments;
178178
let dependencies = worktree_changes.dependencies;
179179

180-
// Filter assignments to just this stack
180+
// Include hunks that are unassigned or assigned to the acting stack,
181+
// so that dependency locks can route unassigned hunks correctly.
181182
let stack_assignments: Vec<_> = all_assignments
182183
.iter()
183184
.filter(|a| {
184-
a.stack_id == assigned_stack_id
185-
&& changes.iter().any(|c| c.path_bytes == a.path_bytes)
185+
changes.iter().any(|c| c.path_bytes == a.path_bytes)
186+
&& (a.stack_id.is_none() || a.stack_id == assigned_stack_id)
186187
})
187188
.cloned()
188189
.collect();
189190

190191
if stack_assignments.is_empty() {
191-
anyhow::bail!("No uncommitted changes assigned to stack: {assigned_stack_id:?}");
192+
anyhow::bail!("No uncommitted changes found for the selected files");
192193
}
193194

194195
(stack_assignments, dependencies)

0 commit comments

Comments
 (0)