mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-27 17:56:37 -04:00
Add a pipeline whose output can be read as it arrives
Rendering a diff through a renderer on Windows means running the renderer ourselves, since ConPTY mangles the metadata records it emits and git only invokes a renderer of its own when it talks to a terminal. That needs a chain of commands whose output a view can be filled from while it runs, where PipeCommands runs a chain to completion and reports what it said afterwards. StartPipeline starts such a chain and hands back the reader for its output, along with a handle offering exactly what a render task asks of a command: something to wait for, something to name it by, and a way to stop it. Every command's stderr joins the output, so a renderer that objects to its input says so where the diff would have been. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a8cd56ba9b
commit
dfd6a7dbf2
@@ -201,7 +201,7 @@ func (c *OSCommand) FileExists(path string) (bool, error) {
|
|||||||
|
|
||||||
// PipeCommands runs a heap of commands and pipes their inputs/outputs together like A | B | C
|
// PipeCommands runs a heap of commands and pipes their inputs/outputs together like A | B | C
|
||||||
func (c *OSCommand) PipeCommands(cmdObjs ...*CmdObj) error {
|
func (c *OSCommand) PipeCommands(cmdObjs ...*CmdObj) error {
|
||||||
c.LogCommand(pipelineString(cmdObjs), true)
|
c.logPipeline(cmdObjs)
|
||||||
|
|
||||||
cmds, parentEnds, err := wirePipeline(cmdObjs)
|
cmds, parentEnds, err := wirePipeline(cmdObjs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package oscommands
|
package oscommands
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@@ -9,6 +10,100 @@ import (
|
|||||||
"github.com/samber/lo"
|
"github.com/samber/lo"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Pipeline is a chain of running commands, each one's output feeding the next
|
||||||
|
// one's input, with the last one's output going somewhere the caller reads. Its
|
||||||
|
// method set is the one a render task expects of a command (see tasks.Cmd), so
|
||||||
|
// a pipeline can render a view just as a single command can.
|
||||||
|
type Pipeline struct {
|
||||||
|
cmds []*exec.Cmd
|
||||||
|
cmdStr string
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartPipeline starts the given commands wired A | B | C and returns the
|
||||||
|
// pipeline together with the reader for its output.
|
||||||
|
//
|
||||||
|
// Every command's stderr goes to that same output, so whatever a command
|
||||||
|
// complains about is part of what the caller reads. A diff renderer's error
|
||||||
|
// message belongs on screen with the diff it failed to render.
|
||||||
|
//
|
||||||
|
// Closing the reader is how a pipeline is brought down. The last command's next
|
||||||
|
// write fails, so it exits, and the failure travels back up the chain as each
|
||||||
|
// command in turn writes into a pipe whose reader is gone.
|
||||||
|
func (c *OSCommand) StartPipeline(cmdObjs ...*CmdObj) (*Pipeline, io.ReadCloser, error) {
|
||||||
|
c.logPipeline(cmdObjs)
|
||||||
|
|
||||||
|
cmds, parentEnds, err := wirePipeline(cmdObjs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
reader, writer, err := os.Pipe()
|
||||||
|
if err != nil {
|
||||||
|
closeAll(parentEnds)
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
for _, cmd := range cmds {
|
||||||
|
cmd.Stderr = writer
|
||||||
|
}
|
||||||
|
cmds[len(cmds)-1].Stdout = writer
|
||||||
|
parentEnds = append(parentEnds, writer)
|
||||||
|
|
||||||
|
started, err := startPipeline(cmds, parentEnds)
|
||||||
|
if err != nil {
|
||||||
|
for _, cmd := range cmds[:started] {
|
||||||
|
_ = cmd.Wait()
|
||||||
|
}
|
||||||
|
_ = reader.Close()
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Pipeline{cmds: cmds, cmdStr: pipelineString(cmdObjs)}, reader, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self *Pipeline) String() string {
|
||||||
|
return self.cmdStr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait waits for every command to exit and reports the failure nearest the end
|
||||||
|
// of the pipeline. A command that fails leaves the ones before it writing into
|
||||||
|
// a pipe nobody reads, so their own broken-pipe failures are consequences of it
|
||||||
|
// rather than the cause worth reporting, while a command that fails early
|
||||||
|
// leaves the ones after it with nothing to read and no reason to fail at all.
|
||||||
|
func (self *Pipeline) Wait() error {
|
||||||
|
var lastErr error
|
||||||
|
for _, cmd := range self.cmds {
|
||||||
|
if err := cmd.Wait(); err != nil {
|
||||||
|
lastErr = fmt.Errorf("%s: %w", cmd.String(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Terminate asks every command to stop, without waiting for any of them. On
|
||||||
|
// platforms where that does nothing, the pipeline comes down when its output
|
||||||
|
// reader is closed; see StartPipeline.
|
||||||
|
func (self *Pipeline) Terminate() error {
|
||||||
|
var firstErr error
|
||||||
|
for _, cmd := range self.cmds {
|
||||||
|
if err := TerminateProcessGracefully(cmd.Process); err != nil && firstErr == nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return firstErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// logPipeline enters a chain of commands into the command log, unless the first
|
||||||
|
// command was marked not to be logged; it speaks for the pipeline. A render runs
|
||||||
|
// its pipeline again on every selection change, so a caller has to be able to
|
||||||
|
// keep it out of the log.
|
||||||
|
func (c *OSCommand) logPipeline(cmdObjs []*CmdObj) {
|
||||||
|
if cmdObjs[0].ShouldLog() {
|
||||||
|
c.LogCommand(pipelineString(cmdObjs), true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// pipelineString names a chain of commands the way a shell would write it.
|
// pipelineString names a chain of commands the way a shell would write it.
|
||||||
func pipelineString(cmdObjs []*CmdObj) string {
|
func pipelineString(cmdObjs []*CmdObj) string {
|
||||||
return strings.Join(
|
return strings.Join(
|
||||||
|
|||||||
@@ -56,6 +56,75 @@ func TestPipelineMember(t *testing.T) {
|
|||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStartPipelineStreamsTheOutputOfTheLastCommand(t *testing.T) {
|
||||||
|
pipeline, reader, err := NewDummyOSCommand().StartPipeline(
|
||||||
|
pipelineMember("count"),
|
||||||
|
pipelineMember("upcase"),
|
||||||
|
)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
output, err := io.ReadAll(reader)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "LINE 1\nLINE 2\nLINE 3\n", string(output))
|
||||||
|
|
||||||
|
assert.NoError(t, pipeline.Wait())
|
||||||
|
assert.NoError(t, reader.Close())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartPipelineReadsWhatTheCommandsComplainAbout(t *testing.T) {
|
||||||
|
pipeline, reader, err := NewDummyOSCommand().StartPipeline(
|
||||||
|
pipelineMember("count"),
|
||||||
|
pipelineMember("complain"),
|
||||||
|
)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
output, err := io.ReadAll(reader)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "something went wrong\n", string(output))
|
||||||
|
|
||||||
|
// The failure of the command nearest the output is the one reported, even
|
||||||
|
// though the one feeding it was left writing into a pipe nobody reads.
|
||||||
|
assert.ErrorContains(t, pipeline.Wait(), "exit status 3")
|
||||||
|
|
||||||
|
assert.NoError(t, reader.Close())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClosingAPipelinesOutputBringsItDown(t *testing.T) {
|
||||||
|
pipeline, reader, err := NewDummyOSCommand().StartPipeline(
|
||||||
|
pipelineMember("flood"),
|
||||||
|
pipelineMember("copy"),
|
||||||
|
)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Read some output first, so that both commands are past their startup and
|
||||||
|
// really running when the reader goes.
|
||||||
|
buf := make([]byte, len("line 1\n"))
|
||||||
|
_, err = io.ReadFull(reader, buf)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "line 1\n", string(buf))
|
||||||
|
|
||||||
|
assert.NoError(t, reader.Close())
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- pipeline.Wait() }()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatal("the pipeline was still running long after its output was closed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartPipelineReportsACommandItCannotStart(t *testing.T) {
|
||||||
|
osCommand := NewDummyOSCommand()
|
||||||
|
|
||||||
|
_, _, err := osCommand.StartPipeline(
|
||||||
|
pipelineMember("count"),
|
||||||
|
osCommand.Cmd.New([]string{"lazygit-no-such-command"}),
|
||||||
|
)
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
func TestPipeCommandsReturnsWhenALaterCommandDiesEarly(t *testing.T) {
|
func TestPipeCommandsReturnsWhenALaterCommandDiesEarly(t *testing.T) {
|
||||||
done := make(chan error, 1)
|
done := make(chan error, 1)
|
||||||
go func() {
|
go func() {
|
||||||
|
|||||||
Reference in New Issue
Block a user