-
Notifications
You must be signed in to change notification settings - Fork 731
Implement reword_commit as an example of rebase engine usage #11452
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
8db0551 to
b6edf2b
Compare
0317278 to
e8cb47a
Compare
b6edf2b to
2d9fbda
Compare
e8cb47a to
885a370
Compare
227d540 to
e17d39c
Compare
885a370 to
d644db3
Compare
e17d39c to
0157fac
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR implements a new reword_commit operation as a demonstration of the new graph-based rebase engine. The implementation provides a simpler, more composable API for rewriting Git history compared to the legacy approach.
Key Changes:
- Introduces a new rebase engine API with an
Editorabstraction that operates on an in-memory repository - Implements the
rewordoperation using this new engine, which rewrites commit messages and rebases dependent commits - Adds feature flag support to allow gradual rollout, with both old and new implementations available
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| crates/but-workspace/src/commit/reword.rs | Core implementation of the reword operation using the new graph-based rebase engine |
| crates/but-workspace/src/commit/mod.rs | Exports the reword function from the commit module |
| crates/but-workspace/src/lib.rs | Makes the commit module public for external use |
| crates/but-workspace/tests/workspace/commit/reword.rs | Comprehensive tests for rewording head, middle, and base commits |
| crates/but-workspace/tests/workspace/commit/mod.rs | Module declaration for reword tests |
| crates/but-workspace/tests/fixtures/scenario/reword-three-commits.sh | Test fixture script that sets up a three-commit scenario with branches |
| crates/but-rebase/src/graph_rebase/materialize.rs | Adds MaterializeOutcome struct to return commit mapping information |
| crates/but-api/src/commit.rs | API layer providing both oplog-enabled and oplog-disabled versions of reword_commit |
| crates/but-api/src/lib.rs | Adds commit module to the but-api crate |
| crates/gitbutler-tauri/src/main.rs | Registers the new reword_commit Tauri command |
| apps/desktop/src/lib/stacks/stackService.svelte.ts | Frontend integration with feature flag to switch between old and new implementations |
| apps/desktop/src/lib/config/uiFeatureFlags.ts | Adds useNewRebaseEngine feature flag |
| apps/desktop/src/components/profileSettings/ExperimentalSettings.svelte | UI toggle for the new rebase engine feature flag |
crates/but-api/src/commit.rs
Outdated
| but_workspace::commit::reword(&graph, &repo, commit_id, message.as_bstr()) | ||
| } | ||
|
|
||
| /// Apply `existing_branch` to the workspace in the repository that `ctx` refers to, or create the workspace with default name. |
Copilot
AI
Dec 5, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Copy-paste error in documentation: This docstring describes "Apply existing_branch to the workspace..." which is unrelated to rewording commits. This appears to be copied from another function and not updated.
The documentation should describe what reword_commit actually does, for example:
/// Rewords a commit message and updates the oplog.
///
/// Returns the ID of the newly renamed commit
| /// Apply `existing_branch` to the workspace in the repository that `ctx` refers to, or create the workspace with default name. | |
| /// Rewords a commit message and updates the oplog. |
| Ok(()) | ||
| } | ||
|
|
||
| /// Set a var ignoring the unsafty |
Copilot
AI
Dec 5, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Spelling error: "unsafty" should be "unsafety".
| /// Set a var ignoring the unsafty | |
| /// Set a var ignoring the unsafety |
| reword(&graph, &repo, id.detach(), b"New name".into())?; | ||
|
|
||
| // We end up with two divergent histories here. This is to be expected if we | ||
| // rewrite the very bottom commit in a repoository. |
Copilot
AI
Dec 5, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Spelling error: "repoository" should be "repository".
| // rewrite the very bottom commit in a repoository. | |
| // rewrite the very bottom commit in a repository. |
a0c54a1 to
498b7d4
Compare
|
Thanks for the write up! I plan to give this a proper read through as soon as I am back on Sunday. Just casually reading, completely disregarding the implementation, but just looking at the API as a future user of the editor, a few things stood out to me. Nothing is meant to be prescriptive, just ideas and food for thought.
|
|
Hey! All great feedback.
In the new world, the Graph is kind of the main source of truth, so it felt reasonable to say "take the graph and make it into something we can change". I don't mind the
Yes, something like this: gitbutler/crates/but-workspace/src/commit/insert_blank_commit.rs Lines 40 to 43 in bfdee46
Whether it should be I've already changed it to return an Err if it can't find it (compared to the PR description), so they now have cooresponding
I've thought quite a lot about this. One of the things that you and I talked about with the old rebase engine is we don't want things like re-treeing commits or things like that to be a part of the rebase engine. I agree that that we don't want to conflate the manipulation of commits with the history rewrite itself. That said, one of the pains I perceived when working with the old rebase engine was how it always felt like I needed to figure out "what is the most canonical way to fetch a commit for editing & then re-commit it". That is why I added these methods for reading a commit into an editable form & writing them to the odb again - so there is a clear standard way of doing it. They don't change the rebase steps in any way. It's also still up to the user of the Editor to actually manipulate things like the commit's trees.
This function behaves as the same The materialise then writes all the commits into the ODB, updates the references, and performs a checkout. The reason it's not Having this two step means you can either do |
Byron
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I really like what I see and think this will work. And while writing it, I also think I should probably come up with an idea of why it wouldn't work, but I am not quite there yet.
Everything that was brought up (and what I remember) I consider a "tunable", not a "fundamental", so making the API more pleasant to use for particular use cases should be very possible, also to the point where selectors are handled for you. For instance, Editor::replace(s: impl TryIntoSelector, p: Step) could be implemented so it accepts a selector and a gix::ObjectId natively, it would just work either way.
Something I was also thinking about is how ultimately, the editor would want to edit RefMetadata and maybe even items in the but-db. As this may change, it's worth reminding ourselves that plumbing functions should receive exactly what they need, so they'd take an editor. Then the but-api can have a way to create an editor from a Context directly, which has everything the Editor could ever want.
Here is what I think the current system doesn't capture: When we edit something, we always do so in a workspace. This also means that if we insert a commit, it is expected to be visible in the workspace after the edit. Right now, it's totally possible that insertions made, for instance, that won't show up in the workspace. For reword, this should never be an issue, so it's not a problem we can discuss here, but for insertions it would be. Now, it's entirely acceptable that that the caller double-checks this, even though I think it would be nice if the rebase engine could do it, if reasonable at all (which I don't know).
On the caller side, such a sanity check would look like this.
editor.insert(my_new_commit_selector, the_commit, Above);
let outcome = editor.rebase();
let ws = outcome.to_workspace();
// get the commit ID that was actually written somehow - this is just an example
let new_commit_id = outcome.map_commit(my_new_commit_selector);
if !ws.contains(new_commit_id) {
bail!("BUG: We really expected this commit to be visible, but it is not.")
}The above is the kind of defensive code that sometimes is encountered today when creating a new reference, and the algorithm fails to properly drive workspace metadata to make the reference appear. This can always happen, as those who manipulate our metadata and the but-graph traversal + projection need to agree. And there certainly are bugs that will take time to fully eradicate, either during development of new mutations, or even in production.
| graph: &but_graph::Graph, | ||
| repo: &gix::Repository, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As this is plumbing, it would take exactly what it needs, the editor.
| fn reword_middle_commit() -> Result<()> { | ||
| assure_stable_env(); | ||
| let (_tmp, graph, repo, mut _meta, _description) = | ||
| writable_scenario("reword-three-commits", |_| {})?; | ||
| insta::assert_snapshot!(visualize_commit_graph_all(&repo)?, @r" | ||
| * c9f444c (HEAD -> three) commit three | ||
| * 16fd221 (origin/two, two) commit two | ||
| * 8b426d0 (one) commit one | ||
| "); | ||
|
|
||
| let head_tree = repo.head_tree_id()?; | ||
| let id = repo.rev_parse_single("two")?; | ||
| reword(&graph, &repo, id.detach(), b"New name".into())?; | ||
|
|
||
| insta::assert_snapshot!(visualize_commit_graph_all(&repo)?, @r" | ||
| * 086ad49 (HEAD -> three) commit three | ||
| * d9cea5b (two) New name | ||
| | * 16fd221 (origin/two) commit two | ||
| |/ | ||
| * 8b426d0 (one) commit one | ||
| "); | ||
|
|
||
| assert_eq!(head_tree, repo.head_tree_id()?); | ||
|
|
||
| Ok(()) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The cool thing now is that in theory, we would not have to check every case as we know the only thing that needs testing is the actual reword, and that is trivial.
All the other cases we know are working as they have been tested in the rebase engine.
d5ac6b3 to
9a87a8e
Compare
498b7d4 to
af226d8
Compare
9a87a8e to
f956a7f
Compare
af226d8 to
8d45217
Compare
8d45217 to
9af308e
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated 4 comments.
| outcome.materialize()?; | ||
|
|
||
| Ok(new_id) |
Copilot
AI
Dec 15, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The function returns new_id from line 26, but doesn't use the MaterializeOutcome's commit_mapping to verify or retrieve the final commit ID. While this should work correctly for a simple reword operation, it would be more robust to use the outcome's commit_mapping to find the final ID of the rewritten commit. This would make the code more maintainable and less prone to bugs if the rebase logic changes in the future.
| outcome.materialize()?; | |
| Ok(new_id) | |
| let materialize_outcome = outcome.materialize()?; | |
| // Use the commit_mapping to get the final commit ID | |
| let final_id = materialize_outcome | |
| .commit_mapping | |
| .get(&new_id) | |
| .copied() | |
| .ok_or_else(|| anyhow::anyhow!("Final commit ID not found in commit_mapping"))?; | |
| Ok(final_id) |
| pub fn reword_commit_only( | ||
| ctx: &but_ctx::Context, | ||
| commit_id: gix::ObjectId, | ||
| message: BString, | ||
| ) -> anyhow::Result<gix::ObjectId> { | ||
| let mut guard = ctx.exclusive_worktree_access(); | ||
| let (repo, _, graph) = ctx.graph_and_meta_mut_and_repo_from_head(guard.write_permission())?; | ||
|
|
||
| but_workspace::commit::reword(&graph, &repo, commit_id, message.as_bstr()) | ||
| } |
Copilot
AI
Dec 15, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The function doesn't validate that the message is non-empty. The old implementation (referenced in the PR description) checked for empty messages with if message.is_empty() { bail!("commit message can not be empty") }. Consider adding similar validation to prevent creating commits with empty messages, which could cause confusion or issues downstream.
| get updateCommitMessage() { | ||
| return this.api.endpoints.updateCommitMessage.useMutation(); | ||
| if (get(useNewRebaseEngine)) { | ||
| return this.api.endpoints.updateCommitMessage.useMutation(); | ||
| } else { | ||
| return this.api.endpoints.legacyUpdateCommitMessage.useMutation(); | ||
| } | ||
| } |
Copilot
AI
Dec 15, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Parameter mismatch between the two API endpoints. The updateCommitMessage endpoint (new engine) expects {projectId, commitId, message} while legacyUpdateCommitMessage expects {projectId, stackId, commitId, message}. However, the caller in CommitView.svelte always passes stackId. When the new rebase engine is enabled, the caller will pass an extra stackId parameter that the API doesn't expect. This could cause TypeScript type errors or unexpected behavior. Consider either: (1) making the new API also accept stackId for consistency even if it doesn't use it, or (2) updating the caller to conditionally pass parameters based on the feature flag.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@Caleb-T-Owens This seems quite critical - does it have a point?
| /// the new name. | ||
| /// | ||
| /// Returns the ID of the newly renamed commit |
Copilot
AI
Dec 15, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The documentation mentions "rewrite a commit and any relevant history so it uses the new name" but commits don't have names - they have messages. Consider changing "new name" to "new message" for accuracy.
| /// the new name. | |
| /// | |
| /// Returns the ID of the newly renamed commit | |
| /// the new message. | |
| /// | |
| /// Returns the ID of the newly reworded commit |
9af308e to
211caac
Compare
In this PR I'm exploring what the first rendition of the new rebase engine might be to look like.
I'm doing this by implementing possibly the simplest operation we've got, and then I'll be scaling it up to some more complex operations, testing as I go along.
I want this to be an open RFC. This is a shed to be biked. A water cooler to be over cooled.
A break down of the
rewordfunction inreword.rsI'm going to interleave code followed by text to explain what each part is doing. If it doesn't make sense, then we probably need to switch up some portion of the interface. Please share!
For the signature of this "plumbing" function, it takes just the graph & repo which are the essentials now.
We don't need to take in any references or stack_ids, just getting the
targetcommit is enough to identify what we want to change.I can build the editor from the graph. It takes in a repo which it actually clones and turns into an in-memory repository. For any commits we rewrite in our business logic we want to use this. I've experimented a little bit with adding some functions to make the commit rewrites we want to do a little bit easier & handle things like signing correctly.
This line gets a reference or "selector" to where the target commit sits inside the editors internal graph structure.
Currently for a selector, we can either replace the thing it points to with another "step" kind, or we can insert a new "step" either below or above it.
I'm strongly considering changing
select_xxxto return a result & then havetry_select_xxxversions if the caller really wants to haveOptionsThe three lines here are concerned actually performing the manipulation where we change the commit's message.
In our app there a handful of ways of getting a commit object we can play around with and how to write a commit, with or without signing.
I really want there to be a single common way we do this with the rebase engine, so I added
find_commitandwrite_commit.find_commitis important to use anyway because if we're looking up a commit, then it should be from inside the editor's in memory repository in case anything relevant has been written there doing a 2 step rebase (example coming soon...). I considered having this take a selector or having this actually return the selector, but it seemed initially like more faff than benefit.Find commit is returning a
but_core::Commitwhich seems like an easy thing to modify, and has some useful methods around our specific headers so it seemed like a good type to be the mutable "thing" we edit.write_committhen takes the commit and writes it into the editor's in-memory repo. It takes aDateModewhich enables us to describe if and how to update committers and authors. I liked this because it means we don't have do the faff of getting the signature and then creating a timestamp in this function's body.This replace call doesn't perform any rebases itself. You can call
replaceandinsertand it's just manipulating an in-memory data structure, without any expensive operations.editor.rebase()is performing the actual rebase - without checking out any of the results. On a success it's giving us thisoutcomeobject which on it's own is opaque for now. Currently the only thing you can do with it is call.materialize()which actually checks out any changes. As use cases come up methods can be added to it which can describe the effects of the rebase. IE: did it cause any commits to become conflicted, ect... . Further, you will be able to create a new editor out of it for 2 step rebases.On a failure, this is currently returning an
anyhowerror, but I'm strongly considering doing something withthiserrorso error variants can be more easily matched over with detail..rebase()should only succeed if it the outcome can be checked out following the users preferences. Rules like disallowing pushed commits to be rebased should result in a failure variant..materialize()actually updates the references, any relevant metadata, and performs a safe checkout for us.The function in full:
The old world version of this implementation.
There are a few cases that currently aren't covered in the rebase engine, but by the time I'm done they should be functionally identical.
This is part 2 of 3 in a stack made with GitButler: