Allow copying streamed git output from the command log

Hook and push output is easy to read in lazygit but hard to reuse
elsewhere. Parse git output blocks from the command log view so copies
match what is shown, include the action and command above each block,
and avoid logging clipboard operations back into the stream.
This commit is contained in:
tmwatchanan 2026-06-20 18:43:09 +07:00
parent 0b78438848
commit 224f8e7e50
No known key found for this signature in database
GPG key ID: F4D43507B7A7F4AC
9 changed files with 340 additions and 1 deletions

View file

@ -267,6 +267,15 @@ func (c *OSCommand) PipeCommands(cmdObjs ...*CmdObj) error {
}
func (c *OSCommand) CopyToClipboard(str string) error {
c.logCopyToClipboard(str)
return c.writeToClipboard(str)
}
func (c *OSCommand) CopyToClipboardQuiet(str string) error {
return c.writeToClipboard(str)
}
func (c *OSCommand) logCopyToClipboard(str string) {
escaped := strings.ReplaceAll(str, "\n", "\\n")
truncated := utils.TruncateWithEllipsis(escaped, 40)
@ -277,6 +286,9 @@ func (c *OSCommand) CopyToClipboard(str string) error {
},
)
c.LogCommand(msg, false)
}
func (c *OSCommand) writeToClipboard(str string) error {
if c.UserConfig().OS.CopyToClipboardCmd != "" {
cmdStr := utils.ResolvePlaceholderString(c.UserConfig().OS.CopyToClipboardCmd, map[string]string{
"text": c.Cmd.Quote(str),

View file

@ -33,6 +33,128 @@ func (gui *Gui) LogAction(action string) {
fmt.Fprint(gui.Views.Extras, "\n"+style.FgYellow.Sprint(action))
}
func (gui *Gui) gitOutputBlocksFromView() []string {
if gui.Views.Extras == nil {
return nil
}
return gitOutputBlocksFromCommandLogLines(gui.Views.Extras.BufferLines(), gui.c.Tr.GitOutput)
}
func (gui *Gui) lastGitOutput() string {
blocks := gui.gitOutputBlocksFromView()
if len(blocks) == 0 {
return ""
}
return blocks[len(blocks)-1]
}
func (gui *Gui) allGitOutput() string {
return strings.Join(gui.gitOutputBlocksFromView(), "\n\n")
}
func (gui *Gui) hasGitOutput() bool {
return gui.lastGitOutput() != ""
}
func gitOutputBlocksFromCommandLogLines(lines []string, gitOutputHeader string) []string {
var blocks []string
for i, line := range lines {
if line != gitOutputHeader {
continue
}
block := commandLogEntryBeforeGitOutput(lines, i, gitOutputHeader)
if len(block) > 0 {
block = append(block, "")
}
block = append(block, gitOutputHeader)
block = append(block, gitOutputLinesAfterHeader(lines, i+1, gitOutputHeader)...)
if trimmed := strings.TrimRight(strings.Join(block, "\n"), "\n"); trimmed != "" {
blocks = append(blocks, trimmed)
}
}
return blocks
}
func commandLogEntryBeforeGitOutput(lines []string, headerIdx int, gitOutputHeader string) []string {
i := headerIdx - 1
for i >= 0 && lines[i] == "" {
i--
}
var commands []string
for i >= 0 && strings.HasPrefix(lines[i], " ") && !isCopyToClipboardLogLine(lines[i]) {
commands = append([]string{lines[i]}, commands...)
i--
}
if len(commands) == 0 {
return nil
}
for i >= 0 && lines[i] == "" {
i--
}
var entry []string
if i >= 0 && !strings.HasPrefix(lines[i], " ") && lines[i] != gitOutputHeader {
entry = append(entry, lines[i])
}
entry = append(entry, commands...)
return entry
}
func gitOutputLinesAfterHeader(lines []string, startIdx int, gitOutputHeader string) []string {
output := make([]string, 0, len(lines)-startIdx)
for i := startIdx; i < len(lines); i++ {
line := lines[i]
if line == gitOutputHeader {
break
}
if isCopyToClipboardLogLine(line) {
continue
}
if strings.HasPrefix(line, " ") {
continue
}
if isStartOfNewCommandLogEntry(lines, i) {
break
}
output = append(output, line)
}
return output
}
func isCopyToClipboardLogLine(line string) bool {
trimmed := strings.TrimSpace(line)
return strings.HasPrefix(trimmed, "Copying '") && strings.HasSuffix(trimmed, "' to clipboard")
}
func isStartOfNewCommandLogEntry(lines []string, i int) bool {
line := lines[i]
if line == "" || strings.HasPrefix(line, " ") {
return false
}
for j := i + 1; j < len(lines); j++ {
if lines[j] == "" {
continue
}
if isCopyToClipboardLogLine(lines[j]) {
continue
}
return strings.HasPrefix(lines[j], " ")
}
return false
}
func (gui *Gui) LogCommand(cmdStr string, commandLine bool) {
if gui.Views.Extras == nil {
return

View file

@ -0,0 +1,89 @@
package gui
import (
"testing"
"github.com/stretchr/testify/assert"
)
const gitOutputHeader = "Git output:"
func TestGitOutputBlocksFromCommandLogLines(t *testing.T) {
t.Parallel()
lines := []string{
"Push",
" git push",
"",
gitOutputHeader,
"line1",
"line2",
}
assert.Equal(t, []string{"Push\n git push\n\nGit output:\nline1\nline2"}, gitOutputBlocksFromCommandLogLines(lines, gitOutputHeader))
}
func TestGitOutputBlocksSkipCopyNotifications(t *testing.T) {
t.Parallel()
lines := []string{
"Push",
" git push",
gitOutputHeader,
"hook line",
" Copying 'hook line' to clipboard",
"hook line 2",
}
assert.Equal(t, []string{"Push\n git push\n\nGit output:\nhook line\nhook line 2"}, gitOutputBlocksFromCommandLogLines(lines, gitOutputHeader))
}
func TestGitOutputBlocksEndAtNextCommandLogEntry(t *testing.T) {
t.Parallel()
lines := []string{
"Push",
" git push",
gitOutputHeader,
"first command output",
"Stage file",
" git add foo",
"",
gitOutputHeader,
"second command output",
}
assert.Equal(t, []string{
"Push\n git push\n\nGit output:\nfirst command output",
"Stage file\n git add foo\n\nGit output:\nsecond command output",
}, gitOutputBlocksFromCommandLogLines(lines, gitOutputHeader))
}
func TestGitOutputBlocksMultipleBlocksJoined(t *testing.T) {
t.Parallel()
lines := []string{
"Push",
" git push",
gitOutputHeader,
"first command",
"Pull",
" git pull",
gitOutputHeader,
"second command",
}
blocks := gitOutputBlocksFromCommandLogLines(lines, gitOutputHeader)
assert.Equal(t, "Push\n git push\n\nGit output:\nfirst command\n\nPull\n git pull\n\nGit output:\nsecond command", joinGitOutputBlocks(blocks))
}
func joinGitOutputBlocks(blocks []string) string {
result := ""
for i, block := range blocks {
if i > 0 {
result += "\n\n"
}
result += block
}
return result
}

View file

@ -1,6 +1,7 @@
package gui
import (
"errors"
"io"
"github.com/jesseduffield/lazygit/pkg/gocui"
@ -10,6 +11,13 @@ import (
)
func (gui *Gui) handleCreateExtrasMenuPanel() error {
noGitOutputDisabledReason := func() *types.DisabledReason {
if gui.hasGitOutput() {
return nil
}
return &types.DisabledReason{Text: gui.c.Tr.NoGitOutputToCopy}
}
return gui.c.Menu(types.CreateMenuOptions{
Title: gui.c.Tr.CommandLog,
Items: []*types.MenuItem{
@ -33,10 +41,50 @@ func (gui *Gui) handleCreateExtrasMenuPanel() error {
Keys: []gocui.Key{gocui.NewKeyRune('f')},
OnPress: gui.handleFocusCommandLog,
},
{
Label: gui.c.Tr.CopyGitOutputToClipboard,
Keys: []gocui.Key{gocui.NewKeyRune('c')},
OnPress: gui.handleCopyLastGitOutputToClipboard,
DisabledReason: noGitOutputDisabledReason(),
},
{
Label: gui.c.Tr.CopyAllGitOutputToClipboard,
Keys: []gocui.Key{gocui.NewKeyRune('a')},
OnPress: gui.handleCopyAllGitOutputToClipboard,
DisabledReason: noGitOutputDisabledReason(),
},
},
})
}
func (gui *Gui) handleCopyLastGitOutputToClipboard() error {
output := gui.lastGitOutput()
if output == "" {
return errors.New(gui.c.Tr.NoGitOutputToCopy)
}
if err := gui.os.CopyToClipboardQuiet(output); err != nil {
return err
}
gui.c.Toast(gui.c.Tr.GitOutputCopiedToClipboard)
return nil
}
func (gui *Gui) handleCopyAllGitOutputToClipboard() error {
output := gui.allGitOutput()
if output == "" {
return errors.New(gui.c.Tr.NoGitOutputToCopy)
}
if err := gui.os.CopyToClipboardQuiet(output); err != nil {
return err
}
gui.c.Toast(gui.c.Tr.GitOutputCopiedToClipboard)
return nil
}
func (gui *Gui) handleFocusCommandLog() error {
gui.c.State().SetShowExtrasWindow(true)
// TODO: is this necessary? Can't I just call 'return from context'?
@ -94,7 +142,10 @@ func (gui *Gui) goToExtrasPanelBottom() error {
}
func (gui *Gui) getCmdWriter() io.Writer {
return &prefixWriter{writer: gui.Views.Extras, prefix: style.FgMagenta.Sprintf("\n\n%s\n", gui.c.Tr.GitOutput)}
return &prefixWriter{
writer: gui.Views.Extras,
prefix: style.FgMagenta.Sprintf("\n\n%s\n", gui.c.Tr.GitOutput),
}
}
// Ensures that the first write is preceded by writing a prefix.

View file

@ -189,3 +189,11 @@ func (self *guiCommon) WithInlineStatus(item types.HasUrn, operation types.ItemO
self.gui.helpers.InlineStatus.WithInlineStatus(helpers.InlineStatusOpts{Item: item, Operation: operation, ContextKey: contextKey}, f)
return nil
}
func (self *guiCommon) LastGitOutput() string {
return self.gui.lastGitOutput()
}
func (self *guiCommon) AllGitOutput() string {
return self.gui.allGitOutput()
}

View file

@ -114,6 +114,9 @@ type IGuiCommon interface {
ResetKeybindings() error
LastGitOutput() string
AllGitOutput() string
// hopefully we can remove this once we've moved all our keybinding stuff out of the gui god struct.
GetInitialKeybindingsWithCustomCommands() ([]*Binding, []*gocui.ViewMouseBinding)

View file

@ -768,6 +768,10 @@ type TranslationSet struct {
CommandLog string
ToggleShowCommandLog string
FocusCommandLog string
CopyGitOutputToClipboard string
CopyAllGitOutputToClipboard string
NoGitOutputToCopy string
GitOutputCopiedToClipboard string
CommandLogHeader string
RandomTip string
ToggleWhitespaceInDiffView string
@ -1899,6 +1903,10 @@ func EnglishTranslationSet() *TranslationSet {
ErrWorktreeMovedOrRemoved: "Cannot find worktree. It might have been moved or removed ¯\\_(ツ)_/¯",
ToggleShowCommandLog: "Toggle show/hide command log",
FocusCommandLog: "Focus command log",
CopyGitOutputToClipboard: "Copy last git output to clipboard",
CopyAllGitOutputToClipboard: "Copy all git outputs to clipboard",
NoGitOutputToCopy: "No git output to copy",
GitOutputCopiedToClipboard: "Git output copied to clipboard",
CommandLogHeader: "You can hide/focus this panel by pressing '%s'\n",
RandomTip: "Random tip",
ToggleWhitespaceInDiffView: "Toggle whitespace",

View file

@ -0,0 +1,45 @@
package misc
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var CopyGitOutputToClipboard = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Copy streamed git output from the command log to the clipboard",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().OS.CopyToClipboardCmd = "printf '%s' {{text}} > clipboard"
},
SetupRepo: func(shell *Shell) {
shell.EmptyCommit("one")
shell.CloneIntoRemote("origin")
shell.SetBranchUpstream("master", "origin/master")
shell.EmptyCommit("two")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Press(keys.Universal.Push)
t.Views().Status().Content(Equals("✓ repo → master"))
t.GlobalPress(keys.Universal.ExtrasMenu)
t.ExpectPopup().Menu().
Title(Equals("Command log")).
Select(Contains("Copy last git output to clipboard")).
Confirm()
t.ExpectToast(Equals("Git output copied to clipboard"))
t.FileSystem().FileContent("clipboard",
Contains("master -> master").
Contains("git push").
Contains("Push"))
},
})

View file

@ -334,6 +334,7 @@ var tests = []*components.IntegrationTest{
interactive_rebase.ViewFilesOfTodoEntries,
misc.ConfirmOnQuit,
misc.CopyConfirmationMessageToClipboard,
misc.CopyGitOutputToClipboard,
misc.CopyToClipboard,
misc.DirenvApprovesEnvrc,
misc.DirenvLoadedOnRepoSwitch,