Skip to content

Commit 0773d20

Browse files
Merge pull request #13056 from gitbutlerapp/jt/multcommit
gitbutler-edit-mode: allow commit graph modification
2 parents 5044c95 + 2295414 commit 0773d20

3 files changed

Lines changed: 146 additions & 25 deletions

File tree

crates/but-rebase/src/graph_rebase/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ pub struct Pick {
3131
///
3232
/// If this is Some, the commit WILL NOT be picked onto the parents the
3333
/// graph implies but instead on to the parents listed here.
34-
pub(crate) preserved_parents: Option<Vec<gix::ObjectId>>,
34+
pub preserved_parents: Option<Vec<gix::ObjectId>>,
3535
/// If set to false, a rebase will fail if this commit results in a
3636
/// conflicted state.
3737
pub conflictable: bool,

crates/gitbutler-edit-mode/src/lib.rs

Lines changed: 34 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use but_ctx::{
1111
access::{RepoExclusive, RepoShared},
1212
};
1313
use but_oxidize::{ObjectIdExt as _, OidExt, gix_to_git2_index};
14-
use but_rebase::graph_rebase::{Editor, Step};
14+
use but_rebase::graph_rebase::{Editor, Pick, Step};
1515
use git2::build::CheckoutBuilder;
1616
use gitbutler_cherry_pick::{ConflictedTreeKey, GixRepositoryExt as _, RepositoryExt as _};
1717
use gitbutler_commit::commit_ext::CommitExt;
@@ -258,9 +258,35 @@ pub(crate) fn save_and_return_to_workspace(ctx: &Context, perm: &mut RepoExclusi
258258

259259
let old_workspace = WorkspaceState::create(ctx, perm.read_permission())?;
260260

261+
let head_commit = repo.head_commit()?;
262+
let decoded_head_commit = head_commit.decode()?;
261263
// Write out all the changes, including unstaged changes to a tree for re-committing
262264
#[expect(deprecated)]
263265
let tree_id = repo.create_wd_tree(0)?;
266+
let new_commit_oid = if decoded_head_commit.tree() == tree_id {
267+
head_commit.id
268+
} else {
269+
let commit = gix::objs::Commit::try_from(decoded_head_commit.clone())?;
270+
let extra_headers: Vec<(BString, BString)> = Headers::try_from_commit(&commit)
271+
.map(|commit_headers| {
272+
let headers = Headers {
273+
conflicted: None,
274+
..commit_headers
275+
};
276+
(&headers).into()
277+
})
278+
.unwrap_or_default();
279+
but_rebase::commit::create(
280+
repo,
281+
gix::objs::Commit {
282+
tree: tree_id,
283+
extra_headers,
284+
..commit
285+
},
286+
but_rebase::commit::DateMode::CommitterUpdateAuthorKeep,
287+
true,
288+
)?
289+
};
264290

265291
let workspace_commit = repo
266292
.find_reference(WORKSPACE_BRANCH_REF)?
@@ -274,29 +300,15 @@ pub(crate) fn save_and_return_to_workspace(ctx: &Context, perm: &mut RepoExclusi
274300
)?
275301
.into_workspace()?;
276302
let mut editor = Editor::create(&mut workspace, &mut meta, repo)?;
277-
let (target_selector, commit) = editor.find_selectable_commit(edit_mode_metadata.commit_oid)?;
303+
let (target_selector, _commit) =
304+
editor.find_selectable_commit(edit_mode_metadata.commit_oid)?;
278305

279-
let extra_headers: Vec<(BString, BString)> = Headers::try_from_commit(&commit.inner)
280-
.map(|commit_headers| {
281-
let headers = Headers {
282-
conflicted: None,
283-
..commit_headers
284-
};
285-
(&headers).into()
286-
})
287-
.unwrap_or_default();
288-
let new_commit_oid = but_rebase::commit::create(
289-
repo,
290-
gix::objs::Commit {
291-
tree: tree_id,
292-
extra_headers,
293-
..commit.inner
294-
},
295-
but_rebase::commit::DateMode::CommitterUpdateAuthorKeep,
296-
true,
297-
)?;
306+
let mut pick = Pick::new_pick(new_commit_oid);
307+
// Do not replace new_commit_oid's parents with the parents of
308+
// edit_mode_metadata.commit_oid
309+
pick.preserved_parents = Some(decoded_head_commit.parents().collect());
298310

299-
editor.replace(target_selector, Step::new_pick(new_commit_oid))?;
311+
editor.replace(target_selector, Step::Pick(pick))?;
300312
let outcome = editor.rebase()?;
301313
// HEAD is EDIT_BRANCH_REF and we do not need to re-checkout it (we
302314
// are checking out WORKSPACE_BRANCH_REF after this). As for needing to

crates/gitbutler-edit-mode/tests/edit_mode.rs

Lines changed: 111 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
use anyhow::{Context as _, Result, anyhow};
2-
use bstr::ByteSlice as _;
2+
use bstr::{BString, ByteSlice as _};
33
use but_core::{
44
RefMetadata, RepositoryExt,
55
ref_metadata::{StackId, WorkspaceCommitRelation, WorkspaceStack, WorkspaceStackBranch},
66
};
77
use but_ctx::Context;
88
use but_meta::{VirtualBranchesTomlMetadata, virtual_branches_legacy_types::Target};
9-
use but_testsupport::{gix_testtools, open_repo};
9+
use but_testsupport::{gix_testtools, open_repo, visualize_commit_graph};
1010
use git2::build::CheckoutBuilder;
1111
use gitbutler_edit_mode::commands::{
1212
abort_and_return_to_workspace, enter_edit_mode, save_and_return_to_workspace,
@@ -98,6 +98,115 @@ fn basic_leaving_edit_mode() -> Result<()> {
9898
Ok(())
9999
}
100100

101+
#[test]
102+
fn multiple_commits_created_during_edit_mode() -> Result<()> {
103+
let (mut ctx, _tempdir) = command_ctx("conficted_entries_get_written_when_leaving_edit_mode")?;
104+
let repo = ctx.repo.get()?;
105+
106+
let foobar = repo.head_commit()?.decode()?.parents().next().unwrap();
107+
108+
let worktree_dir = repo.workdir().unwrap().to_owned();
109+
drop(repo);
110+
let stack_id = stack_id(&ctx)?;
111+
enter_edit_mode(&mut ctx, foobar, stack_id)?;
112+
113+
let repo = ctx.repo.get()?;
114+
let commit = gix::objs::Commit::try_from(repo.find_commit(foobar)?.decode()?)?;
115+
let first_id = repo.write_object(gix::objs::Commit {
116+
message: BString::from(b"first commit added"),
117+
parents: [foobar].into(),
118+
..commit.clone()
119+
})?;
120+
let second_id = repo.write_object(gix::objs::Commit {
121+
message: BString::from(b"second commit added"),
122+
parents: [first_id.detach()].into(),
123+
..commit
124+
})?;
125+
repo.edit_reference(gix::refs::transaction::RefEdit {
126+
change: gix::refs::transaction::Change::Update {
127+
log: gix::refs::transaction::LogChange {
128+
mode: gix::refs::transaction::RefLog::AndReference,
129+
force_create_reflog: false,
130+
message: b"arbitrary message".into(),
131+
},
132+
expected: gix::refs::transaction::PreviousValue::Any,
133+
new: gix::refs::Target::Object(second_id.detach()),
134+
},
135+
name: "HEAD".try_into().unwrap(),
136+
deref: true,
137+
})?;
138+
drop(repo);
139+
140+
std::fs::write(worktree_dir.join("file"), "edited during edit mode\n")?;
141+
142+
save_and_return_to_workspace(&mut ctx)?;
143+
144+
let repo = &*ctx.repo.get()?;
145+
insta::assert_snapshot!(visualize_commit_graph(repo, "refs/heads/gitbutler/workspace")?, @r"
146+
* 59ab552 (HEAD -> gitbutler/workspace) GitButler Workspace Commit
147+
* f6d3539 (branchy) second commit added
148+
* d39dd61 first commit added
149+
* 26804c3 foobar
150+
* 7950f06 (origin/main, origin/HEAD, main, gitbutler/target) init
151+
");
152+
// As usual, any uncommitted changes (to "file" in this test) is applied
153+
// onto the HEAD commit at the time of exiting edit mode.
154+
let blob = repo.rev_parse_single(b"HEAD^{/second}:file")?.object()?;
155+
insta::assert_snapshot!(blob.data.as_bstr(), @"edited during edit mode");
156+
// Rebase also happens correctly.
157+
let blob = repo.rev_parse_single(b"HEAD:file")?.object()?;
158+
insta::assert_snapshot!(blob.data.as_bstr(), @"edited during edit mode");
159+
160+
Ok(())
161+
}
162+
163+
#[test]
164+
fn apply_commit_on_itself() -> Result<()> {
165+
let (mut ctx, _tempdir) = command_ctx("conficted_entries_get_written_when_leaving_edit_mode")?;
166+
let repo = ctx.repo.get()?;
167+
168+
let foobar = repo.head_commit()?.decode()?.parents().next().unwrap();
169+
170+
drop(repo);
171+
let stack_id = stack_id(&ctx)?;
172+
enter_edit_mode(&mut ctx, foobar, stack_id)?;
173+
174+
let repo = ctx.repo.get()?;
175+
// Set HEAD to gitbutler/workspace, detached, to see what happens when we
176+
// try to apply itself on itself.
177+
let workspace_commit_id = repo
178+
.find_reference("refs/heads/gitbutler/workspace")?
179+
.peel_to_commit()?
180+
.id;
181+
repo.edit_reference(gix::refs::transaction::RefEdit {
182+
change: gix::refs::transaction::Change::Update {
183+
log: gix::refs::transaction::LogChange {
184+
mode: gix::refs::transaction::RefLog::AndReference,
185+
force_create_reflog: false,
186+
message: b"arbitrary message".into(),
187+
},
188+
expected: gix::refs::transaction::PreviousValue::Any,
189+
new: gix::refs::Target::Object(workspace_commit_id),
190+
},
191+
name: "HEAD".try_into().unwrap(),
192+
deref: true,
193+
})?;
194+
drop(repo);
195+
196+
save_and_return_to_workspace(&mut ctx)?;
197+
198+
let repo = &*ctx.repo.get()?;
199+
// It works.
200+
insta::assert_snapshot!(visualize_commit_graph(repo, "refs/heads/gitbutler/workspace")?, @r"
201+
* 85cd48c (HEAD -> gitbutler/workspace, gitbutler/edit) GitButler Workspace Commit
202+
* 6eb9642 (branchy) GitButler Workspace Commit
203+
* 26804c3 foobar
204+
* 7950f06 (origin/main, origin/HEAD, main, gitbutler/target) init
205+
");
206+
207+
Ok(())
208+
}
209+
101210
// Fixture:
102211
// * xxx (HEAD -> gitbutler/workspace) GitButler Workspace Commit
103212
// * xxx foobar

0 commit comments

Comments
 (0)