Repository: jesseduffield/lazygit (language: go)

Issue:
Support range select for amending commit attributes
**Is your feature request related to a problem? Please describe.**
Currently when you press 'a' on a commit, you see a menu with options to reset the commit author, amend the commit author, and add a co-author in the commit message.

![image](https://github.com/jesseduffield/lazygit/assets/8456633/9db4aede-9b64-4a35-987f-f689a2ee8221)

But this only works for individual commits. If you want to add a co-author for a range of commits, you'll need to perform the action on the commits one-by-one.

**Describe the solution you'd like**
Support these three actions for a range of commits.

**Additional context**
For reference, here's the PR which added the ability to add a co-author to an individual commit https://github.com/jesseduffield/lazygit/pull/2912

I don't believe we yet have a way of saying that you want to perform a specific action on a range of commits as part of a rebase, so we that may need to be added as part of this work.


Discussion / hints:
I'd like to work on this, if possible :)

Fix patch:
diff --git a/pkg/commands/git_commands/rebase.go b/pkg/commands/git_commands/rebase.go
index 040df0070c8..59c2c6833c0 100644
--- a/pkg/commands/git_commands/rebase.go
+++ b/pkg/commands/git_commands/rebase.go
@@ -67,42 +67,47 @@ func (self *RebaseCommands) RewordCommitInEditor(commits []*models.Commit, index
 	}), nil
 }
 
-func (self *RebaseCommands) ResetCommitAuthor(commits []*models.Commit, index int) error {
-	return self.GenericAmend(commits, index, func() error {
+func (self *RebaseCommands) ResetCommitAuthor(commits []*models.Commit, start, end int) error {
+	return self.GenericAmend(commits, start, end, func(_ *models.Commit) error {
 		return self.commit.ResetAuthor()
 	})
 }
 
-func (self *RebaseCommands) SetCommitAuthor(commits []*models.Commit, index int, value string) error {
-	return self.GenericAmend(commits, index, func() error {
+func (self *RebaseCommands) SetCommitAuthor(commits []*models.Commit, start, end int, value string) error {
+	return self.GenericAmend(commits, start, end, func(_ *models.Commit) error {
 		return self.commit.SetAuthor(value)
 	})
 }
 
-func (self *RebaseCommands) AddCommitCoAuthor(commits []*models.Commit, index int, value string) error {
-	return self.GenericAmend(commits, index, func() error {
-		return self.commit.AddCoAuthor(commits[index].Hash, value)
+func (self *RebaseCommands) AddCommitCoAuthor(commits []*models.Commit, start, end int, value string) error {
+	return self.GenericAmend(commits, start, end, func(commit *models.Commit) error {
+		return self.commit.AddCoAuthor(commit.Hash, value)
 	})
 }
 
-func (self *RebaseCommands) GenericAmend(commits []*models.Commit, index int, f func() error) error {
-	if models.IsHeadCommit(commits, index) {
+func (self *RebaseCommands) GenericAmend(commits []*models.Commit, start, end int, f func(commit *models.Commit) error) error {
+	if start == end && models.IsHeadCommit(commits, start) {
 		// we've selected the top commit so no rebase is required
-		return f()
+		return f(commits[start])
 	}
 
-	err := self.BeginInteractiveRebaseForCommit(commits, index, false)
+	err := self.BeginInteractiveRebaseForCommitRange(commits, start, end, false)
 	if err != nil {
 		return err
 	}
 
-	// now the selected commit should be our head so we'll amend it
-	err = f()
-	if err != nil {
-		return err
+	for commitIndex := end; commitIndex >= start; commitIndex-- {
+		err = f(commits[commitIndex])
+		if err != nil {
+			return err
+		}
+
+		if err := self.ContinueRebase(); err != nil {
+			return err
+		}
 	}
 
-	return self.ContinueRebase()
+	return nil
 }
 
 func (self *RebaseCommands) MoveCommitsDown(commits []*models.Commit, startIdx int, endIdx int) error {
@@ -381,7 +386,13 @@ func (self *RebaseCommands) SquashAllAboveFixupCommits(commit *models.Commit) er
 func (self *RebaseCommands) BeginInteractiveRebaseForCommit(
 	commits []*models.Commit, commitIndex int, keepCommitsThatBecomeEmpty bool,
 ) error {
-	if len(commits)-1 < commitIndex {
+	return self.BeginInteractiveRebaseForCommitRange(commits, commitIndex, commitIndex, keepCommitsThatBecomeEmpty)
+}
+
+func (self *RebaseCommands) BeginInteractiveRebaseForCommitRange(
+	commits []*models.Commit, start, end int, keepCommitsThatBecomeEmpty bool,
+) error {
+	if len(commits)-1 < end {
 		return errors.New("index outside of range of commits")
 	}
 
@@ -392,14 +403,17 @@ func (self *RebaseCommands) BeginInteractiveRebaseForCommit(
 		return errors.New(self.Tr.DisabledForGPG)
 	}
 
-	changes := []daemon.ChangeTodoAction{{
-		Hash:      commits[commitIndex].Hash,
-		NewAction: todo.Edit,
-	}}
+	changes := make([]daemon.ChangeTodoAction, 0, end-start)
+	for commitIndex := end; commitIndex >= start; commitIndex-- {
+		changes = append(changes, daemon.ChangeTodoAction{
+			Hash:      commits[commitIndex].Hash,
+			NewAction: todo.Edit,
+		})
+	}
 	self.os.LogCommand(logTodoChanges(changes), false)
 
 	return self.PrepareInteractiveRebaseCommand(PrepareInteractiveRebaseCommandOpts{
-		baseHashOrRoot:             getBaseHashOrRoot(commits, commitIndex+1),
+		baseHashOrRoot:             getBaseHashOrRoot(commits, end+1),
 		overrideEditor:             true,
 		keepCommitsThatBecomeEmpty: keepCommitsThatBecomeEmpty,
 		instruction:                daemon.NewChangeTodoActionsInstruction(changes),
diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go
index 53a6b205f29..f8717f413b4 100644
--- a/pkg/gui/controllers/local_commits_controller.go
+++ b/pkg/gui/controllers/local_commits_controller.go
@@ -238,8 +238,8 @@ func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) [
 		},
 		{
 			Key:               opts.GetKey(opts.Config.Commits.ResetCommitAuthor),
-			Handler:           self.withItem(self.amendAttribute),
-			GetDisabledReason: self.require(self.singleItemSelected(self.canAmend)),
+			Handler:           self.withItemsRange(self.amendAttribute),
+			GetDisabledReason: self.require(self.itemRangeSelected(self.canAmendRange)),
 			Description:       self.c.Tr.AmendCommitAttribute,
 			Tooltip:           self.c.Tr.AmendCommitAttributeTooltip,
 			OpensMenu:         true,
@@ -371,7 +371,7 @@ func (self *LocalCommitsController) reword(commit *models.Commit) error {
 }
 
 func (self *LocalCommitsController) switchFromCommitMessagePanelToEditor(filepath string) error {
-	if self.isHeadCommit() {
+	if self.isSelectedHeadCommit() {
 		return self.c.RunSubprocessAndRefresh(
 			self.c.Git().Commit.RewordLastCommitInEditorWithMessageFileCmdObj(filepath))
 	}
@@ -408,7 +408,7 @@ func (self *LocalCommitsController) handleReword(summary string, description str
 func (self *LocalCommitsController) doRewordEditor() error {
 	self.c.LogAction(self.c.Tr.Actions.RewordCommit)
 
-	if self.isHeadCommit() {
+	if self.isSelectedHeadCommit() {
 		return self.c.RunSubprocessAndRefresh(self.c.Git().Commit.RewordLastCommitInEditorCmdObj())
 	}
 
@@ -607,7 +607,7 @@ func (self *LocalCommitsController) rewordEnabled(commit *models.Commit) *types.
 
 	// If we are in a rebase, the only action that is allowed for
 	// non-todo commits is rewording the current head commit
-	if self.isRebasing() && !self.isHeadCommit() {
+	if self.isRebasing() && !self.isSelectedHeadCommit() {
 		return &types.DisabledReason{Text: self.c.Tr.AlreadyRebasing}
 	}
 
@@ -665,7 +665,7 @@ func (self *LocalCommitsController) moveUp(selectedCommits []*models.Commit, sta
 }
 
 func (self *LocalCommitsController) amendTo(commit *models.Commit) error {
-	if self.isHeadCommit() {
+	if self.isSelectedHeadCommit() {
 		return self.c.Confirm(types.ConfirmOpts{
 			Title:  self.c.Tr.AmendCommitTitle,
 			Prompt: self.c.Tr.AmendCommitPrompt,
@@ -695,34 +695,39 @@ func (self *LocalCommitsController) amendTo(commit *models.Commit) error {
 	})
 }
 
-func (self *LocalCommitsController) canAmend(commit *models.Commit) *types.DisabledReason {
-	if !self.isHeadCommit() && self.isRebasing() {
+func (self *LocalCommitsController) canAmendRange(commits []*models.Commit, start, end int) *types.DisabledReason {
+	if (start != end || !self.isHeadCommit(start)) && self.isRebasing() {
 		return &types.DisabledReason{Text: self.c.Tr.AlreadyRebasing}
 	}
 
 	return nil
 }
 
-func (self *LocalCommitsController) amendAttribute(commit *models.Commit) error {
+func (self *LocalCommitsController) canAmend(_ *models.Commit) *types.DisabledReason {
+	idx := self.context().GetSelectedLineIdx()
+	return self.canAmendRange(self.c.Model().Commits, idx, idx)
+}
+
+func (self *LocalCommitsController) amendAttribute(commits []*models.Commit, start, end int) error {
 	opts := self.c.KeybindingsOpts()
 	return self.c.Menu(types.CreateMenuOptions{
 		Title: "Amend commit attribute",
 		Items: []*types.MenuItem{
 			{
 				Label:   self.c.Tr.ResetAuthor,
-				OnPress: self.resetAuthor,
+				OnPress: func() error { return self.resetAuthor(start, end) },
 				Key:     opts.GetKey(opts.Config.AmendAttribute.ResetAuthor),
 				Tooltip: self.c.Tr.ResetAuthorTooltip,
 			},
 			{
 				Label:   self.c.Tr.SetAuthor,
-				OnPress: self.setAuthor,
+				OnPress: func() error { return self.setAuthor(start, end) },
 				Key:     opts.GetKey(opts.Config.AmendAttribute.SetAuthor),
 				Tooltip: self.c.Tr.SetAuthorTooltip,
 			},
 			{
 				Label:   self.c.Tr.AddCoAuthor,
-				OnPress: self.addCoAuthor,
+				OnPress: func() error { return self.addCoAuthor(start, end) },
 				Key:     opts.GetKey(opts.Config.AmendAttribute.AddCoAuthor),
 				Tooltip: self.c.Tr.AddCoAuthorTooltip,
 			},
@@ -730,10 +735,10 @@ func (self *LocalCommitsController) amendAttribute(commit *models.Commit) error
 	})
 }
 
-func (self *LocalCommitsController) resetAuthor() error {
+func (self *LocalCommitsController) resetAuthor(start, end int) error {
 	return self.c.WithWaitingStatus(self.c.Tr.AmendingStatus, func(gocui.Task) error {
 		self.c.LogAction(self.c.Tr.Actions.ResetCommitAuthor)
-		if err := self.c.Git().Rebase.ResetCommitAuthor(self.c.Model().Commits, self.context().GetSelectedLineIdx()); err != nil {
+		if err := self.c.Git().Rebase.ResetCommitAuthor(self.c.Model().Commits, start, end); err != nil {
 			return err
 		}
 
@@ -741,14 +746,14 @@ func (self *LocalCommitsController) resetAuthor() error {
 	})
 }
 
-func (self *LocalCommitsController) setAuthor() error {
+func (self *LocalCommitsController) setAuthor(start, end int) error {
 	return self.c.Prompt(types.PromptOpts{
 		Title:               self.c.Tr.SetAuthorPromptTitle,
 		FindSuggestionsFunc: self.c.Helpers().Suggestions.GetAuthorsSuggestionsFunc(),
 		HandleConfirm: func(value string) error {
 			return self.c.WithWaitingStatus(self.c.Tr.AmendingStatus, func(gocui.Task) error {
 				self.c.LogAction(self.c.Tr.Actions.SetCommitAuthor)
-				if err := self.c.Git().Rebase.SetCommitAuthor(self.c.Model().Commits, self.context().GetSelectedLineIdx(), value); err != nil {
+				if err := self.c.Git().Rebase.SetCommitAuthor(self.c.Model().Commits, start, end, value); err != nil {
 					return err
 				}
 
@@ -758,14 +763,14 @@ func (self *LocalCommitsController) setAuthor() error {
 	})
 }
 
-func (self *LocalCommitsController) addCoAuthor() error {
+func (self *LocalCommitsController) addCoAuthor(start, end int) error {
 	return self.c.Prompt(types.PromptOpts{
 		Title:               self.c.Tr.AddCoAuthorPromptTitle,
 		FindSuggestionsFunc: self.c.Helpers().Suggestions.GetAuthorsSuggestionsFunc(),
 		HandleConfirm: func(value string) error {
 			return self.c.WithWaitingStatus(self.c.Tr.AmendingStatus, func(gocui.Task) error {
 				self.c.LogAction(self.c.Tr.Actions.AddCommitCoAuthor)
-				if err := self.c.Git().Rebase.AddCommitCoAuthor(self.c.Model().Commits, self.context().GetSelectedLineIdx(), value); err != nil {
+				if err := self.c.Git().Rebase.AddCommitCoAuthor(self.c.Model().Commits, start, end, value); err != nil {
 					return err
 				}
 				return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
@@ -1188,8 +1193,12 @@ func (self *LocalCommitsController) markAsBaseCommit(commit *models.Commit) erro
 	return self.c.PostRefreshUpdate(self.c.Contexts().LocalCommits)
 }
 
-func (self *LocalCommitsController) isHeadCommit() bool {
-	return models.IsHeadCommit(self.c.Model().Commits, self.context().GetSelectedLineIdx())
+func (self *LocalCommitsController) isHeadCommit(idx int) bool {
+	return models.IsHeadCommit(self.c.Model().Commits, idx)
+}
+
+func (self *LocalCommitsController) isSelectedHeadCommit() bool {
+	return self.isHeadCommit(self.context().GetSelectedLineIdx())
 }
 
 func (self *LocalCommitsController) notMidRebase(message string) func() *types.DisabledReason {


Test patch:
diff --git a/pkg/integration/tests/commit/add_co_author_range.go b/pkg/integration/tests/commit/add_co_author_range.go
new file mode 100644
index 00000000000..9452c1ded9e
--- /dev/null
+++ b/pkg/integration/tests/commit/add_co_author_range.go
@@ -0,0 +1,105 @@
+package commit
+
+import (
+	"github.com/jesseduffield/lazygit/pkg/config"
+	. "github.com/jesseduffield/lazygit/pkg/integration/components"
+)
+
+var AddCoAuthorRange = NewIntegrationTest(NewIntegrationTestArgs{
+	Description:  "Add co-author on a range of commits",
+	ExtraCmdArgs: []string{},
+	Skip:         false,
+	SetupConfig:  func(config *config.AppConfig) {},
+	SetupRepo: func(shell *Shell) {
+		shell.EmptyCommit("fourth commit")
+		shell.EmptyCommit("third commit")
+		shell.EmptyCommit("second commit")
+		shell.EmptyCommit("first commit")
+	},
+	Run: func(t *TestDriver, keys config.KeybindingConfig) {
+		t.Views().Commits().
+			Focus().
+			Lines(
+				Contains("first commit").IsSelected(),
+				Contains("second commit"),
+				Contains("third commit"),
+				Contains("fourth commit"),
+			).
+			SelectNextItem().
+			Press(keys.Universal.ToggleRangeSelect).
+			SelectNextItem().
+			Lines(
+				Contains("first commit"),
+				Contains("second commit").IsSelected(),
+				Contains("third commit").IsSelected(),
+				Contains("fourth commit"),
+			).
+			Press(keys.Commits.ResetCommitAuthor).
+			Tap(func() {
+				t.ExpectPopup().Menu().
+					Title(Equals("Amend commit attribute")).
+					Select(Contains("Add co-author")).
+					Confirm()
+
+				t.ExpectPopup().Prompt().
+					Title(Contains("Add co-author")).
+					Type("John Smith <jsmith@gmail.com>").
+					Confirm()
+			}).
+			// exit range selection mode
+			PressEscape().
+			SelectNextItem()
+
+		t.Views().Main().Content(
+			Contains("fourth commit").
+				DoesNotContain("Co-authored-by: John Smith <jsmith@gmail.com>"),
+		)
+
+		t.Views().Commits().
+			IsFocused().
+			SelectPreviousItem().
+			Lines(
+				Contains("first commit"),
+				Contains("second commit"),
+				Contains("third commit").IsSelected(),
+				Contains("fourth commit"),
+			)
+
+		t.Views().Main().ContainsLines(
+			Equals("    third commit"),
+			Equals("    "),
+			Equals("    Co-authored-by: John Smith <jsmith@gmail.com>"),
+		)
+
+		t.Views().Commits().
+			IsFocused().
+			SelectPreviousItem().
+			Lines(
+				Contains("first commit"),
+				Contains("second commit").IsSelected(),
+				Contains("third commit"),
+				Contains("fourth commit"),
+			)
+
+		t.Views().Main().ContainsLines(
+			Equals("    second commit"),
+			Equals("    "),
+			Equals("    Co-authored-by: John Smith <jsmith@gmail.com>"),
+		)
+
+		t.Views().Commits().
+			IsFocused().
+			SelectPreviousItem().
+			Lines(
+				Contains("first commit").IsSelected(),
+				Contains("second commit"),
+				Contains("third commit"),
+				Contains("fourth commit"),
+			)
+
+		t.Views().Main().Content(
+			Contains("first commit").
+				DoesNotContain("Co-authored-by: John Smith <jsmith@gmail.com>"),
+		)
+	},
+})
diff --git a/pkg/integration/tests/commit/reset_author_range.go b/pkg/integration/tests/commit/reset_author_range.go
new file mode 100644
index 00000000000..9d7c764cf59
--- /dev/null
+++ b/pkg/integration/tests/commit/reset_author_range.go
@@ -0,0 +1,52 @@
+package commit
+
+import (
+	"github.com/jesseduffield/lazygit/pkg/config"
+	. "github.com/jesseduffield/lazygit/pkg/integration/components"
+)
+
+var ResetAuthorRange = NewIntegrationTest(NewIntegrationTestArgs{
+	Description:  "Reset author on a range of commits",
+	ExtraCmdArgs: []string{},
+	Skip:         false,
+	SetupConfig:  func(config *config.AppConfig) {},
+	SetupRepo: func(shell *Shell) {
+		shell.SetConfig("user.email", "Bill@example.com")
+		shell.SetConfig("user.name", "Bill Smith")
+
+		shell.EmptyCommit("fourth")
+		shell.EmptyCommit("third")
+		shell.EmptyCommit("second")
+		shell.EmptyCommit("first")
+
+		shell.SetConfig("user.email", "John@example.com")
+		shell.SetConfig("user.name", "John Smith")
+	},
+	Run: func(t *TestDriver, keys config.KeybindingConfig) {
+		t.Views().Commits().
+			Focus().
+			Lines(
+				Contains("BS").Contains("first").IsSelected(),
+				Contains("BS").Contains("second"),
+				Contains("BS").Contains("third"),
+				Contains("BS").Contains("fourth"),
+			).
+			SelectNextItem().
+			Press(keys.Universal.ToggleRangeSelect).
+			SelectNextItem().
+			Press(keys.Commits.ResetCommitAuthor).
+			Tap(func() {
+				t.ExpectPopup().Menu().
+					Title(Equals("Amend commit attribute")).
+					Select(Contains("Reset author")).
+					Confirm()
+			}).
+			PressEscape().
+			Lines(
+				Contains("BS").Contains("first"),
+				Contains("JS").Contains("second"),
+				Contains("JS").Contains("third").IsSelected(),
+				Contains("BS").Contains("fourth"),
+			)
+	},
+})
diff --git a/pkg/integration/tests/commit/set_author_range.go b/pkg/integration/tests/commit/set_author_range.go
new file mode 100644
index 00000000000..e366ba05ed7
--- /dev/null
+++ b/pkg/integration/tests/commit/set_author_range.go
@@ -0,0 +1,57 @@
+package commit
+
+import (
+	"github.com/jesseduffield/lazygit/pkg/config"
+	. "github.com/jesseduffield/lazygit/pkg/integration/components"
+)
+
+var SetAuthorRange = NewIntegrationTest(NewIntegrationTestArgs{
+	Description:  "Set author on a range of commits",
+	ExtraCmdArgs: []string{},
+	Skip:         false,
+	SetupConfig:  func(config *config.AppConfig) {},
+	SetupRepo: func(shell *Shell) {
+		shell.SetConfig("user.email", "Bill@example.com")
+		shell.SetConfig("user.name", "Bill Smith")
+
+		shell.EmptyCommit("fourth")
+		shell.EmptyCommit("third")
+		shell.EmptyCommit("second")
+		shell.EmptyCommit("first")
+	},
+	Run: func(t *TestDriver, keys config.KeybindingConfig) {
+		t.Views().Commits().
+			Focus().
+			Lines(
+				Contains("BS").Contains("first").IsSelected(),
+				Contains("BS").Contains("second"),
+				Contains("BS").Contains("third"),
+				Contains("BS").Contains("fourth"),
+			)
+
+		t.Views().Commits().
+			Focus().
+			SelectNextItem().
+			Press(keys.Universal.ToggleRangeSelect).
+			SelectNextItem().
+			Press(keys.Commits.ResetCommitAuthor).
+			Tap(func() {
+				t.ExpectPopup().Menu().
+					Title(Equals("Amend commit attribute")).
+					Select(Contains(" Set author")). // adding space at start to distinguish from 'reset author'
+					Confirm()
+
+				t.ExpectPopup().Prompt().
+					Title(Contains("Set author")).
+					Type("John Smith <John@example.com>").
+					Confirm()
+			}).
+			PressEscape().
+			Lines(
+				Contains("BS").Contains("first"),
+				Contains("JS").Contains("second"),
+				Contains("JS").Contains("third").IsSelected(),
+				Contains("BS").Contains("fourth"),
+			)
+	},
+})
diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go
index d4ebd40265f..6386146bac1 100644
--- a/pkg/integration/tests/test_list.go
+++ b/pkg/integration/tests/test_list.go
@@ -68,6 +68,7 @@ var tests = []*components.IntegrationTest{
 	cherry_pick.CherryPickDuringRebase,
 	cherry_pick.CherryPickRange,
 	commit.AddCoAuthor,
+	commit.AddCoAuthorRange,
 	commit.AddCoAuthorWhileCommitting,
 	commit.Amend,
 	commit.AutoWrapMessage,
@@ -89,11 +90,13 @@ var tests = []*components.IntegrationTest{
 	commit.NewBranch,
 	commit.PreserveCommitMessage,
 	commit.ResetAuthor,
+	commit.ResetAuthorRange,
 	commit.Revert,
 	commit.RevertMerge,
 	commit.Reword,
 	commit.Search,
 	commit.SetAuthor,
+	commit.SetAuthorRange,
 	commit.StageRangeOfLines,
 	commit.Staged,
 	commit.StagedWithoutHooks,