Resume a rebase that a lock error stopped partway through

If another git process takes index.lock while a rebase is replaying
commits, git stops the rebase and reschedules the todo that failed. The
git command runner then ran the whole "git rebase --interactive" command
again. This failed because the rebase was already in progress, and the
user was left with a rebase that stopped halfway.

Continue the rebase with "git rebase --continue" instead; this runs the
rescheduled todo again. Only do this if the todo files show that git
rescheduled it. In some cases git doesn't, for example if the lock stops
a pick after it has applied the changes but before it has committed
them. Continuing is not safe in that case, so give up and show the
error.

If the lock stops the command before the rebase starts, git cleans up
after itself, and we run the command again as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Stefan Haller
2026-11-08 14:54:41 +01:00
co-authored by Claude Opus 5.5
parent 21bbcc957a
commit fc5ac8fc63
4 changed files with 212 additions and 17 deletions
@@ -0,0 +1,92 @@
package git_commands
import (
"os"
"path/filepath"
"strings"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/utils"
)
// resumeRebaseAfterLockError sets the lock-error recovery (see
// CmdObj.RecoverFromLockErrorWith) of cmdObj, a command that starts or
// continues a rebase in the worktree whose git dir is gitDir. Call it right
// before running the command, since it records the state of the rebase at
// that time.
//
// If a lock stops the command before it changes the state of the rebase, we
// run the command again. This includes a command that hasn't started the
// rebase yet, since git cleans up after itself in that case. If a lock stops
// the rebase partway through, git normally reschedules the todo that failed,
// and the command made by newContinueCmdObj ("git rebase --continue") runs it
// again. In some cases git doesn't reschedule it, for example if the lock
// stops a pick after it has applied the changes but before it has committed
// them. Continuing could then lose the commit, so we give up and leave the
// rebase to the user.
func resumeRebaseAfterLockError(
cmdObj *oscommands.CmdObj,
gitDir string,
commentChar byte,
newContinueCmdObj func() *oscommands.CmdObj,
) *oscommands.CmdObj {
var recoverFrom func(cmdObj *oscommands.CmdObj, stateBefore string) *oscommands.CmdObj
recoverFrom = func(cmdObj *oscommands.CmdObj, stateBefore string) *oscommands.CmdObj {
return cmdObj.RecoverFromLockErrorWith(func() *oscommands.CmdObj {
state := rebaseState(gitDir)
if state == stateBefore {
return cmdObj
}
if !wasLastRebaseTodoRescheduled(gitDir, commentChar) {
return nil
}
return recoverFrom(newContinueCmdObj(), state)
})
}
return recoverFrom(cmdObj, rebaseState(gitDir))
}
// rebaseState returns a snapshot of the rebase that is in progress in gitDir,
// made of the files in which git records how far it has got. It's empty if no
// rebase is in progress.
func rebaseState(gitDir string) string {
var state strings.Builder
for _, dir := range []string{"rebase-merge", "rebase-apply"} {
if dirExists(filepath.Join(gitDir, dir)) {
state.WriteString(dir + "\n")
}
}
for _, file := range []string{"rebase-merge/done", "rebase-merge/git-rebase-todo", "rebase-apply/next"} {
if content, err := os.ReadFile(filepath.Join(gitDir, file)); err == nil {
state.WriteString(file + "\n")
state.Write(content)
}
}
return state.String()
}
// wasLastRebaseTodoRescheduled tells whether git put the todo that it executed
// last back into the todo list of the rebase that is in progress in gitDir.
// It's false if no rebase is in progress, or if the rebase doesn't use the
// rebase-merge directory; that kind of rebase never reschedules anything.
func wasLastRebaseTodoRescheduled(gitDir string, commentChar byte) bool {
doneTodos, err := utils.ReadRebaseTodoFile(filepath.Join(gitDir, "rebase-merge", "done"), commentChar)
if err != nil {
return false
}
todos, err := utils.ReadRebaseTodoFile(filepath.Join(gitDir, "rebase-merge", "git-rebase-todo"), commentChar)
if err != nil {
return false
}
return utils.LastDoneTodoWasRescheduled(doneTodos, todos)
}
func dirExists(path string) bool {
info, err := os.Stat(path)
return err == nil && info.IsDir()
}
@@ -0,0 +1,108 @@
package git_commands
import (
"os"
"path/filepath"
"testing"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/stretchr/testify/assert"
)
func TestResumeRebaseAfterLockError(t *testing.T) {
newCmdObj := func(args ...string) *oscommands.CmdObj {
return oscommands.NewDummyCmdObjBuilder(nil).New(args)
}
writeRebaseMergeFiles := func(gitDir string, done string, todo string) {
dir := filepath.Join(gitDir, "rebase-merge")
assert.NoError(t, os.MkdirAll(dir, 0o755))
assert.NoError(t, os.WriteFile(filepath.Join(dir, "done"), []byte(done), 0o644))
assert.NoError(t, os.WriteFile(filepath.Join(dir, "git-rebase-todo"), []byte(todo), 0o644))
}
// Sets up the recovery for cmdObj, then calls runUntilLockError to do what
// the command did before the lock stopped it, and returns the command that
// the recovery wants to run next.
recoverFromLockError := func(gitDir string, cmdObj *oscommands.CmdObj, runUntilLockError func()) *oscommands.CmdObj {
resumeRebaseAfterLockError(cmdObj, gitDir, '#', func() *oscommands.CmdObj {
return newCmdObj("git", "rebase", "--continue")
})
runUntilLockError()
return cmdObj.GetLockErrorRecovery()()
}
t.Run("runs the command again if it didn't start the rebase", func(t *testing.T) {
cmdObj := newCmdObj("git", "rebase", "--interactive", "main")
assert.Same(t, cmdObj, recoverFromLockError(t.TempDir(), cmdObj, func() {}))
})
t.Run("runs the command again if it didn't change the rebase", func(t *testing.T) {
gitDir := t.TempDir()
writeRebaseMergeFiles(gitDir,
"pick 1234567 one\n",
"pick 2345678 two\n")
cmdObj := newCmdObj("git", "rebase", "--continue")
assert.Same(t, cmdObj, recoverFromLockError(gitDir, cmdObj, func() {}))
})
t.Run("continues the rebase if git rescheduled the todo that failed", func(t *testing.T) {
gitDir := t.TempDir()
next := recoverFromLockError(gitDir, newCmdObj("git", "rebase", "--interactive", "main"), func() {
writeRebaseMergeFiles(gitDir,
"pick 1234567 one\npick 2345678 two\n",
"pick 2345678 two\npick 3456789 three\n")
})
assert.Equal(t, "git rebase --continue", next.ToString())
// If continuing fails before it changes the rebase, we run it again
assert.Same(t, next, next.GetLockErrorRecovery()())
// If continuing fails after it has changed the rebase, and git
// rescheduled the todo that failed, we continue again
writeRebaseMergeFiles(gitDir,
"pick 1234567 one\npick 2345678 two\npick 2345678 two\npick 3456789 three\n",
"pick 3456789 three\n")
nextAfterContinuing := next.GetLockErrorRecovery()()
assert.Equal(t, "git rebase --continue", nextAfterContinuing.ToString())
assert.NotSame(t, next, nextAfterContinuing)
// If the rebase is gone after continuing failed, we give up
assert.NoError(t, os.RemoveAll(filepath.Join(gitDir, "rebase-merge")))
assert.Nil(t, nextAfterContinuing.GetLockErrorRecovery()())
})
t.Run("continues the rebase if an older git version rescheduled the todo that failed", func(t *testing.T) {
gitDir := t.TempDir()
next := recoverFromLockError(gitDir, newCmdObj("git", "rebase", "--interactive", "main"), func() {
writeRebaseMergeFiles(gitDir,
"pick 1234567 one\npick 2345678 two\npick 1234567 one\n",
"pick 2345678 two\npick 3456789 three\n")
})
assert.Equal(t, "git rebase --continue", next.ToString())
})
t.Run("gives up if git didn't reschedule the todo that failed", func(t *testing.T) {
gitDir := t.TempDir()
assert.Nil(t, recoverFromLockError(gitDir, newCmdObj("git", "rebase", "--interactive", "main"), func() {
writeRebaseMergeFiles(gitDir,
"pick 1234567 one\npick 2345678 two\n",
"pick 3456789 three\n")
}))
})
t.Run("gives up if a rebase without a rebase-merge directory is in progress", func(t *testing.T) {
gitDir := t.TempDir()
assert.Nil(t, recoverFromLockError(gitDir, newCmdObj("git", "pull"), func() {
assert.NoError(t, os.MkdirAll(filepath.Join(gitDir, "rebase-apply"), 0o755))
}))
})
}
+12 -2
View File
@@ -256,7 +256,7 @@ func (self *RebaseCommands) PrepareInteractiveRebaseCommand(opts PrepareInteract
cmdObj.AddEnvVars("GIT_EDITOR=" + ex)
}
return cmdObj
return self.resumeAfterLockError(cmdObj)
}
// GitRebaseEditTodo runs "git rebase --edit-todo", saving the given todosFileContent to the file
@@ -408,7 +408,7 @@ func (self *RebaseCommands) SquashAllAboveFixupCommits(commit *models.Commit) er
Arg("--interactive", "--rebase-merges", "--autostash", "--autosquash", hashOrRoot).
ToArgv()
return skipEditor(self.cmd.New(cmdArgs)).Run()
return self.resumeAfterLockError(skipEditor(self.cmd.New(cmdArgs))).Run()
}
// BeginInteractiveRebaseForCommit starts an interactive rebase to edit the current
@@ -513,6 +513,16 @@ func (self *RebaseCommands) GenericMergeOrRebaseAction(commandType string, comma
return nil
}
// resumeAfterLockError makes cmdObj, a command that starts a rebase, resume
// the rebase if a lock error stops it partway through; see
// resumeRebaseAfterLockError.
func (self *RebaseCommands) resumeAfterLockError(cmdObj *oscommands.CmdObj) *oscommands.CmdObj {
return resumeRebaseAfterLockError(cmdObj, self.repoPaths.WorktreeGitDirPath(), self.config.GetCoreCommentChar(),
func() *oscommands.CmdObj {
return skipEditor(self.GenericMergeOrRebaseActionCmdObj("rebase", "continue"))
})
}
// skipEditor makes git use lazygit as the editor for the given command; lazygit
// then exits right away, so git uses the file as it is.
func skipEditor(cmdObj *oscommands.CmdObj) *oscommands.CmdObj {
@@ -34,26 +34,11 @@ var LockErrorPartwayThroughRebase = NewIntegrationTest(NewIntegrationTestArgs{
Content(Contains("Are you sure you want to drop the selected commit(s)?")).
Confirm()
}).
/* EXPECTED:
IsFocused().
Lines(
Contains("commit-04"),
Contains("commit-03"),
Contains("commit-01"),
)
ACTUAL: */
Tap(func() {
t.ExpectPopup().Alert().
Title(Equals("Error")).
Content(Contains("It seems that there is already a rebase-merge directory")).
Confirm()
}).
Lines(
Contains("─── Pending rebase todos"),
Contains("pick").Contains("commit-04"),
Contains("─── Commits"),
Contains("commit-03"),
Contains("commit-01"),
)
},
})