Move dragged commits in one rebase

Let the todo-move primitives take a distance instead of hardcoding a
single row, by iterating the one-row move in memory. Dropping a commit
several rows away thus rewrites the todo file once and, outside of an
interactive rebase, runs a single rebase rather than one per row.
This commit is contained in:
Stefan Haller 2026-07-23 09:17:18 +02:00
parent b85483ecc0
commit e756511042
5 changed files with 99 additions and 47 deletions

View file

@ -263,12 +263,14 @@ func (self *MoveFixupCommitDownInstruction) run(common *common.Common) error {
}
type MoveTodosUpInstruction struct {
Hashes []string
Hashes []string
Distance int
}
func NewMoveTodosUpInstruction(hashes []string) Instruction {
func NewMoveTodosUpInstruction(hashes []string, distance int) Instruction {
return &MoveTodosUpInstruction{
Hashes: hashes,
Hashes: hashes,
Distance: distance,
}
}
@ -288,17 +290,19 @@ func (self *MoveTodosUpInstruction) run(common *common.Common) error {
})
return handleInteractiveRebase(common, func(path string) error {
return utils.MoveTodosUp(path, todosToMove, false, getCommentChar())
return utils.MoveTodos(path, todosToMove, false, -self.Distance, getCommentChar())
})
}
type MoveTodosDownInstruction struct {
Hashes []string
Hashes []string
Distance int
}
func NewMoveTodosDownInstruction(hashes []string) Instruction {
func NewMoveTodosDownInstruction(hashes []string, distance int) Instruction {
return &MoveTodosDownInstruction{
Hashes: hashes,
Hashes: hashes,
Distance: distance,
}
}
@ -318,7 +322,7 @@ func (self *MoveTodosDownInstruction) run(common *common.Common) error {
})
return handleInteractiveRebase(common, func(path string) error {
return utils.MoveTodosDown(path, todosToMove, false, getCommentChar())
return utils.MoveTodos(path, todosToMove, false, self.Distance, getCommentChar())
})
}

View file

@ -112,29 +112,30 @@ func (self *RebaseCommands) GenericAmend(commits []*models.Commit, start, end in
}
func (self *RebaseCommands) MoveCommitsDown(commits []*models.Commit, startIdx int, endIdx int) error {
baseHashOrRoot := getBaseHashOrRoot(commits, endIdx+2)
hashes := lo.Map(commits[startIdx:endIdx+1], func(commit *models.Commit, _ int) string {
return commit.Hash()
})
return self.PrepareInteractiveRebaseCommand(PrepareInteractiveRebaseCommandOpts{
baseHashOrRoot: baseHashOrRoot,
instruction: daemon.NewMoveTodosDownInstruction(hashes),
overrideEditor: true,
}).Run()
return self.MoveCommits(commits, startIdx, endIdx, 1)
}
func (self *RebaseCommands) MoveCommitsUp(commits []*models.Commit, startIdx int, endIdx int) error {
baseHashOrRoot := getBaseHashOrRoot(commits, endIdx+1)
return self.MoveCommits(commits, startIdx, endIdx, -1)
}
func (self *RebaseCommands) MoveCommits(commits []*models.Commit, startIdx int, endIdx int, offset int) error {
baseHashOrRoot := getBaseHashOrRoot(commits, endIdx+max(offset, 0)+1)
hashes := lo.Map(commits[startIdx:endIdx+1], func(commit *models.Commit, _ int) string {
return commit.Hash()
})
var instruction daemon.Instruction
if offset > 0 {
instruction = daemon.NewMoveTodosDownInstruction(hashes, offset)
} else {
instruction = daemon.NewMoveTodosUpInstruction(hashes, -offset)
}
return self.PrepareInteractiveRebaseCommand(PrepareInteractiveRebaseCommandOpts{
baseHashOrRoot: baseHashOrRoot,
instruction: daemon.NewMoveTodosUpInstruction(hashes),
instruction: instruction,
overrideEditor: true,
}).Run()
}
@ -369,21 +370,20 @@ func (self *RebaseCommands) DeleteUpdateRefTodos(commits []*models.Commit) error
}
func (self *RebaseCommands) MoveTodosDown(commits []*models.Commit) error {
fileName := filepath.Join(self.repoPaths.WorktreeGitDirPath(), "rebase-merge/git-rebase-todo")
todosToMove := lo.Map(commits, func(commit *models.Commit, _ int) utils.Todo {
return todoFromCommit(commit)
})
return utils.MoveTodosDown(fileName, todosToMove, true, self.config.GetCoreCommentChar())
return self.MoveTodos(commits, 1)
}
func (self *RebaseCommands) MoveTodosUp(commits []*models.Commit) error {
return self.MoveTodos(commits, -1)
}
func (self *RebaseCommands) MoveTodos(commits []*models.Commit, offset int) error {
fileName := filepath.Join(self.repoPaths.WorktreeGitDirPath(), "rebase-merge/git-rebase-todo")
todosToMove := lo.Map(commits, func(commit *models.Commit, _ int) utils.Todo {
return todoFromCommit(commit)
})
return utils.MoveTodosUp(fileName, todosToMove, true, self.config.GetCoreCommentChar())
return utils.MoveTodos(fileName, todosToMove, true, offset, self.config.GetCoreCommentChar())
}
// SquashAllAboveFixupCommits squashes all fixup! commits above the given one

View file

@ -743,13 +743,7 @@ func (self *LocalCommitsController) moveUp(selectedCommits []*models.Commit, sta
func (self *LocalCommitsController) move(selectedCommits []*models.Commit, startIdx int, endIdx int, offset int) error {
if self.isRebasing() {
var err error
if offset > 0 {
err = self.c.Git().Rebase.MoveTodosDown(selectedCommits)
} else {
err = self.c.Git().Rebase.MoveTodosUp(selectedCommits)
}
if err != nil {
if err := self.c.Git().Rebase.MoveTodos(selectedCommits, offset); err != nil {
return err
}
self.context().MoveSelection(offset)
@ -767,14 +761,12 @@ func (self *LocalCommitsController) move(selectedCommits []*models.Commit, start
commits := self.c.Model().Commits
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.MovingStatus, func(gocui.Task) error {
var err error
if offset > 0 {
self.c.LogAction(self.c.Tr.Actions.MoveCommitDown)
err = self.c.Git().Rebase.MoveCommitsDown(commits, startIdx, endIdx)
} else {
self.c.LogAction(self.c.Tr.Actions.MoveCommitUp)
err = self.c.Git().Rebase.MoveCommitsUp(commits, startIdx, endIdx)
}
err := self.c.Git().Rebase.MoveCommits(commits, startIdx, endIdx, offset)
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions(
err, types.RefreshOptions{
BatchUIUpdates: true,

View file

@ -144,27 +144,40 @@ func deleteTodos(todos []todo.Todo, todosToDelete []Todo) ([]todo.Todo, error) {
}
func MoveTodosDown(fileName string, todosToMove []Todo, isInRebase bool, commentChar byte) error {
return MoveTodos(fileName, todosToMove, isInRebase, 1, commentChar)
}
func MoveTodosUp(fileName string, todosToMove []Todo, isInRebase bool, commentChar byte) error {
return MoveTodos(fileName, todosToMove, isInRebase, -1, commentChar)
}
func MoveTodos(fileName string, todosToMove []Todo, isInRebase bool, offset int, commentChar byte) error {
todos, err := ReadRebaseTodoFile(fileName, commentChar)
if err != nil {
return err
}
rearrangedTodos, err := moveTodosDown(todos, todosToMove, isInRebase)
rearrangedTodos, err := moveTodos(todos, todosToMove, isInRebase, offset)
if err != nil {
return err
}
return WriteRebaseTodoFile(fileName, rearrangedTodos, commentChar)
}
func MoveTodosUp(fileName string, todosToMove []Todo, isInRebase bool, commentChar byte) error {
todos, err := ReadRebaseTodoFile(fileName, commentChar)
if err != nil {
return err
func moveTodos(todos []todo.Todo, todosToMove []Todo, isInRebase bool, offset int) ([]todo.Todo, error) {
moveOneRow := moveTodosUp
if offset > 0 {
moveOneRow = moveTodosDown
}
rearrangedTodos, err := moveTodosUp(todos, todosToMove, isInRebase)
if err != nil {
return err
for range max(offset, -offset) {
var err error
todos, err = moveOneRow(todos, slices.Clone(todosToMove), isInRebase)
if err != nil {
return nil, err
}
}
return WriteRebaseTodoFile(fileName, rearrangedTodos, commentChar)
return todos, nil
}
func moveTodoDown(todos []todo.Todo, todoToMove Todo, isInRebase bool) ([]todo.Todo, error) {

View file

@ -3,12 +3,55 @@ package utils
import (
"errors"
"fmt"
"slices"
"testing"
"github.com/stefanhaller/git-todo-parser/todo"
"github.com/stretchr/testify/assert"
)
func TestMoveTodos(t *testing.T) {
todos := []todo.Todo{
{Command: todo.Pick, Commit: "a"},
{Command: todo.Pick, Commit: "b"},
{Command: todo.Label, Label: "hidden"},
{Command: todo.Pick, Commit: "c"},
{Command: todo.Pick, Commit: "d"},
{Command: todo.Pick, Commit: "e"},
{Command: todo.Pick, Commit: "f"},
}
t.Run("moves a range up multiple rendered rows", func(t *testing.T) {
actual, err := moveTodos(slices.Clone(todos), []Todo{{Hash: "d"}, {Hash: "c"}}, false, -2)
assert.NoError(t, err)
assert.Equal(t, []todo.Todo{
{Command: todo.Pick, Commit: "a"},
{Command: todo.Pick, Commit: "b"},
{Command: todo.Label, Label: "hidden"},
{Command: todo.Pick, Commit: "e"},
{Command: todo.Pick, Commit: "f"},
{Command: todo.Pick, Commit: "c"},
{Command: todo.Pick, Commit: "d"},
}, actual)
})
t.Run("moves a range down multiple rendered rows", func(t *testing.T) {
actual, err := moveTodos(slices.Clone(todos), []Todo{{Hash: "e"}, {Hash: "d"}}, false, 2)
assert.NoError(t, err)
assert.Equal(t, []todo.Todo{
{Command: todo.Pick, Commit: "a"},
{Command: todo.Pick, Commit: "d"},
{Command: todo.Pick, Commit: "e"},
{Command: todo.Pick, Commit: "b"},
{Command: todo.Label, Label: "hidden"},
{Command: todo.Pick, Commit: "c"},
{Command: todo.Pick, Commit: "f"},
}, actual)
})
}
func TestRebaseCommands_moveTodoDown(t *testing.T) {
type scenario struct {
testName string