Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Conversation

@Caleb-T-Owens
Copy link
Contributor

@Caleb-T-Owens Caleb-T-Owens commented Dec 4, 2025

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 reword function in reword.rs

I'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!

pub fn reword(
    graph: &but_graph::Graph,
    repo: &gix::Repository,
    target: gix::ObjectId,
    new_message: &BStr,
) -> Result<()> {

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 target commit is enough to identify what we want to change.

    let mut editor = graph.to_editor(repo)?;

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.

    let target_selector = editor
        .select_commit(target)
        .context("Failed to find target commit in editor")?;

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_xxx to return a result & then have try_select_xxx versions if the caller really wants to have Options

    let mut commit = editor.find_commit(target)?;
    commit.message = new_message.to_owned();
    let new_id = editor.write_commit(commit, DateMode::CommitterUpdateAuthorKeep)?;

The 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_commit and write_commit.

find_commit is 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::Commit which 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_commit then takes the commit and writes it into the editor's in-memory repo. It takes a DateMode which 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.

    editor.replace(&target_selector, Step::new_pick(new_id));

This replace call doesn't perform any rebases itself. You can call replace and insert and it's just manipulating an in-memory data structure, without any expensive operations.

    let outcome = editor.rebase()?;

editor.rebase() is performing the actual rebase - without checking out any of the results. On a success it's giving us this outcome object 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 anyhow error, but I'm strongly considering doing something with thiserror so 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.

    outcome.materialize()?;

    Ok(())
}

.materialize() actually updates the references, any relevant metadata, and performs a safe checkout for us.

The function in full:

pub fn reword(
    graph: &but_graph::Graph,
    repo: &gix::Repository,
    target: gix::ObjectId,
    new_message: &BStr,
) -> Result<()> {
    let mut editor = graph.to_editor(repo)?;
    let target_selector = editor
        .select_commit(target)
        .context("Failed to find target commit in editor")?;

    let mut commit = editor.find_commit(target)?;
    commit.message = new_message.to_owned();
    let new_id = editor.write_commit(commit, DateMode::CommitterUpdateAuthorKeep)?;

    editor.replace(&target_selector, Step::new_pick(new_id));

    let outcome = editor.rebase()?;
    outcome.materialize()?;

    Ok(())
}

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.

// changes a commit message for commit_oid, rebases everything above it, updates branch head if successful
pub(crate) fn update_commit_message(
    ctx: &Context,
    stack_id: StackId,
    commit_id: git2::Oid,
    message: &str,
) -> Result<git2::Oid> {
    if message.is_empty() {
        bail!("commit message can not be empty");
    }
    let vb_state = ctx.legacy_project.virtual_branches();
    let default_target = vb_state.get_default_target()?;
    let gix_repo = ctx.repo.get()?;

    let mut stack = vb_state.get_stack_in_workspace(stack_id)?;
    let branch_commit_oids = ctx.git2_repo.get()?.l(
        stack.head_oid(&gix_repo)?.to_git2(),
        LogUntil::Commit(default_target.sha),
        false,
    )?;

    if !branch_commit_oids.contains(&commit_id) {
        bail!("commit {commit_id} not in the branch");
    }

    let pushed_commit_oids = stack.upstream_head.map_or_else(
        || Ok(vec![]),
        |upstream_head| {
            ctx.git2_repo
                .get()?
                .l(upstream_head, LogUntil::Commit(default_target.sha), false)
        },
    )?;

    if pushed_commit_oids.contains(&commit_id) && !stack.allow_rebasing {
        // updating the message of a pushed commit will cause a force push that is not allowed
        bail!("force push not allowed");
    }

    let mut steps = stack.as_rebase_steps(ctx, &gix_repo)?;
    // Update the commit message
    for step in steps.iter_mut() {
        if let RebaseStep::Pick {
            commit_id: id,
            new_message,
        } = step
            && *id == commit_id.to_gix()
        {
            *new_message = Some(message.into());
        }
    }
    let merge_base = stack.merge_base(ctx)?;
    let mut rebase = but_rebase::Rebase::new(&gix_repo, Some(merge_base), None)?;
    rebase.rebase_noops(false);
    rebase.steps(steps)?;
    let output = rebase.rebase()?;

    let new_head = output.top_commit.to_git2();
    stack.set_stack_head(&vb_state, &gix_repo, new_head, None)?;
    stack.set_heads_from_rebase_output(ctx, output.references)?;

    crate::integration::update_workspace_commit(&vb_state, ctx, false)
        .context("failed to update gitbutler workspace")?;

    output
        .commit_mapping
        .iter()
        .find_map(|(_base, old, new)| (*old == commit_id.to_gix()).then_some(new.to_git2()))
        .ok_or(anyhow!(
            "Failed to find the updated commit id after rebasing"
        ))
}

This is part 2 of 3 in a stack made with GitButler:

@vercel
Copy link

vercel bot commented Dec 4, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Review Updated (UTC)
gitbutler-web Ignored Ignored Preview Dec 15, 2025 10:03am

@Caleb-T-Owens Caleb-T-Owens mentioned this pull request Dec 4, 2025
@github-actions github-actions bot added rust Pull requests that update Rust code @gitbutler/desktop labels Dec 4, 2025
@Caleb-T-Owens Caleb-T-Owens force-pushed the reimplement-commit-reword branch from 8db0551 to b6edf2b Compare December 4, 2025 13:35
@Caleb-T-Owens Caleb-T-Owens force-pushed the reimplement-commit-reword branch from b6edf2b to 2d9fbda Compare December 4, 2025 13:53
@Caleb-T-Owens Caleb-T-Owens force-pushed the reimplement-commit-reword branch 2 times, most recently from 227d540 to e17d39c Compare December 4, 2025 14:46
@Caleb-T-Owens Caleb-T-Owens force-pushed the reimplement-commit-reword branch from e17d39c to 0157fac Compare December 5, 2025 13:06
@Byron Byron requested a review from Copilot December 5, 2025 14:47
Copy link
Contributor

Copilot AI left a 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 Editor abstraction that operates on an in-memory repository
  • Implements the reword operation 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

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.
Copy link

Copilot AI Dec 5, 2025

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
Suggested change
/// 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.

Copilot uses AI. Check for mistakes.
Ok(())
}

/// Set a var ignoring the unsafty
Copy link

Copilot AI Dec 5, 2025

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".

Suggested change
/// Set a var ignoring the unsafty
/// Set a var ignoring the unsafety

Copilot uses AI. Check for mistakes.
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.
Copy link

Copilot AI Dec 5, 2025

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".

Suggested change
// rewrite the very bottom commit in a repoository.
// rewrite the very bottom commit in a repository.

Copilot uses AI. Check for mistakes.
@Caleb-T-Owens Caleb-T-Owens force-pushed the reimplement-commit-reword branch 3 times, most recently from a0c54a1 to 498b7d4 Compare December 5, 2025 16:12
@krlvi
Copy link
Member

krlvi commented Dec 5, 2025

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.

graph.to_editor(repo)
Totally random: would it not make sense to express this more like Editor::create(graph, repo)? In theory this could also have been implemented on the repo, but we choose the graph as the 'main' object. But I wonder why?

editor.select_commit(target)
Would we be able to select references as well in the future? I wonder if it would make sense to have an editor.select which takes a typed input instead

editor.find_commit and editor.write_commit
For some reason, semantically it feels weird that this is the editor's responsibility. Would it make sense to either make the special in-memory repo that the editor uses available either when it's constructed or as a method?

editor.rebase()
I am wondering if "rebase" is the right top level verb here. Because how would this look like for reference modification type of operations?

outcome.materialize()
Another thought - why have two steps editor.rebase() which gives an outcome and then outcome.materialize().
What if it was just one function which takes a "dry run" boolean as an argument? This way, a user can run it with dry run, and if they are happy with the result, they run it a second time for real. Does this make sense?
My main thinking here is how to simplify the API

@Caleb-T-Owens
Copy link
Contributor Author

Hey! All great feedback.

Totally random: would it not make sense to express this more like Editor::create(graph, repo)? In theory this could also have been implemented on the repo, but we choose the graph as the 'main' object. But I wonder why?

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 Editor::create constructor at all, and I was already considering something like that since I might need a third argument for some project settings in some form.

editor.select_commit(target)

Yes, something like this:

let target_selector = match relative_to {
RelativeTo::Commit(id) => editor.select_commit(id)?,
RelativeTo::Reference(r) => editor.select_reference(r)?,
};
.

Whether it should be select(SelectionTarget::Commit(id)) or select_commit(id) is a matter of taste. I'm happy to change this up.

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 try_ variants as well.

editor.find_commit and editor.write_commit
For some reason, semantically it feels weird that this is the editor's responsibility. Would it make sense to either make the special in-memory repo that the editor uses available either when it's constructed or as a method?

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.

I am wondering if "rebase" is the right top level verb here. Because how would this look like for reference modification type of operations?

This function behaves as the same rebase() function of the old rebase engine. It does all the work, and in its output it has a list of all the references that need to be updated, added, or removed.

The materialise then writes all the commits into the ODB, updates the references, and performs a checkout.

The reason it's not rebase_and_materialize() (just for a descriptive name), is for operations like squashing commits and moving changes between commits. For these two operations in their existing implementations, they perform some manipulations of commits, then ask the rebase engine to do a rebase, and then they use that to change the steps some more and perform a final rebase & materialise the output.

Having this two step means you can either do .rebase().gimme_nother_editor() or .rebase().materialize() depending on the scenario.

Copy link
Collaborator

@Byron Byron left a 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.

Comment on lines +16 to +17
graph: &but_graph::Graph,
repo: &gix::Repository,
Copy link
Collaborator

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.

Comment on lines +34 to +59
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(())
}
Copy link
Collaborator

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.

@Caleb-T-Owens Caleb-T-Owens force-pushed the reimplement-commit-reword branch from 498b7d4 to af226d8 Compare December 8, 2025 15:51
@Caleb-T-Owens Caleb-T-Owens force-pushed the reimplement-commit-reword branch from af226d8 to 8d45217 Compare December 8, 2025 15:52
Base automatically changed from graph-based-rebasing to master December 15, 2025 09:51
@Caleb-T-Owens Caleb-T-Owens force-pushed the reimplement-commit-reword branch from 8d45217 to 9af308e Compare December 15, 2025 09:53
Copilot AI review requested due to automatic review settings December 15, 2025 09:53
Copy link
Contributor

Copilot AI left a 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.

Comment on lines +31 to +33
outcome.materialize()?;

Ok(new_id)
Copy link

Copilot AI Dec 15, 2025

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.

Suggested change
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)

Copilot uses AI. Check for mistakes.
Comment on lines +11 to +20
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())
}
Copy link

Copilot AI Dec 15, 2025

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.

Copilot uses AI. Check for mistakes.
Comment on lines 634 to 640
get updateCommitMessage() {
return this.api.endpoints.updateCommitMessage.useMutation();
if (get(useNewRebaseEngine)) {
return this.api.endpoints.updateCommitMessage.useMutation();
} else {
return this.api.endpoints.legacyUpdateCommitMessage.useMutation();
}
}
Copy link

Copilot AI Dec 15, 2025

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.

Copilot uses AI. Check for mistakes.
Copy link
Collaborator

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?

Comment on lines +12 to +14
/// the new name.
///
/// Returns the ID of the newly renamed commit
Copy link

Copilot AI Dec 15, 2025

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.

Suggested change
/// the new name.
///
/// Returns the ID of the newly renamed commit
/// the new message.
///
/// Returns the ID of the newly reworded commit

Copilot uses AI. Check for mistakes.
@Caleb-T-Owens Caleb-T-Owens force-pushed the reimplement-commit-reword branch from 9af308e to 211caac Compare December 15, 2025 10:03
@Caleb-T-Owens Caleb-T-Owens merged commit e3a409f into master Dec 15, 2025
23 checks passed
@Caleb-T-Owens Caleb-T-Owens deleted the reimplement-commit-reword branch December 15, 2025 10:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

@gitbutler/desktop rust Pull requests that update Rust code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants