Skip to content

Commit da452c6

Browse files
committed
Absorb uncommitted hunks into target commits
1 parent 15a089a commit da452c6

File tree

3 files changed

+295
-7
lines changed

3 files changed

+295
-7
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: 293 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
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;
17
use but_settings::AppSettings;
8+
use but_workspace::DiffSpec;
29
use gitbutler_command_context::CommandContext;
310
use gitbutler_project::Project;
411

@@ -26,23 +33,302 @@ pub(crate) fn handle(project: &Project, _json: bool, source: Option<&str>) -> an
2633
matches!(s, CliId::UncommittedFile { .. }) || matches!(s, CliId::Branch { .. })
2734
})
2835
});
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+
2942
if let Some(source) = source {
3043
match source {
31-
CliId::UncommittedFile {
32-
path: _,
33-
assignment: _,
34-
} => {
44+
CliId::UncommittedFile { path, assignment } => {
3545
// Absorb this particular file
46+
absorb_file(project, ctx, &path, assignment, &assignments, &dependencies)?;
3647
}
37-
CliId::Branch { name: _ } => {
48+
CliId::Branch { name } => {
3849
// Absorb everything that is assigned to this lane
50+
absorb_branch(project, ctx, &name, &assignments, &dependencies)?;
3951
}
4052
_ => {
41-
// Invalid source - error out
53+
anyhow::bail!("Invalid source: expected an uncommitted file or branch");
4254
}
4355
}
4456
} else {
45-
// Try to absorb everhting uncommitted
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);
46128
}
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+
47333
Ok(())
48334
}

0 commit comments

Comments
 (0)