Gitfed
bastien-mrq/gitfed / cmd / gitfed-ctl / exec.go
package main

import (
	"bufio"
	"bytes"
	"context"
	"fmt"
	"io"
	"os/exec"
	"strings"

	tea "github.com/charmbracelet/bubbletea"
)

// logLineMsg is one line of output from a running step's subprocess,
// tagged with which step it belongs to — same pattern as
// cmd/gitfed-install/exec.go.
type logLineMsg struct {
	stream string
	line   string
}

// stepResultMsg reports a step's subprocess finishing, successfully or not.
type stepResultMsg struct {
	stream string
	err    error
}

// listen drains the shared message channel one value at a time — see
// cmd/gitfed-install/exec.go for the full rationale (same idiom, reused
// verbatim: one channel, one listener loop, re-issued after every message).
func listen(ch chan tea.Msg) tea.Cmd {
	return func() tea.Msg { return <-ch }
}

// runStreamed runs name(args...) with dir as its working directory (empty
// for the current one), sending each line of its combined output as a
// logLineMsg tagged stream, then exactly one stepResultMsg when it exits.
// Meant to be launched with `go runStreamed(...)` from a tea.Cmd.
//
// Also returns the same error it put in the stepResultMsg, so a multi-
// command sequence can bail out after the first failure.
func runStreamed(ctx context.Context, ch chan<- tea.Msg, stream, dir, name string, args ...string) error {
	cmd := exec.CommandContext(ctx, name, args...)
	if dir != "" {
		cmd.Dir = dir
	}
	stdout, err := cmd.StdoutPipe()
	if err != nil {
		ch <- stepResultMsg{stream: stream, err: err}
		return err
	}
	cmd.Stderr = cmd.Stdout

	if err := cmd.Start(); err != nil {
		ch <- stepResultMsg{stream: stream, err: err}
		return err
	}

	scanner := bufio.NewScanner(stdout)
	// Docker build progress lines can be long; bump the buffer so a single
	// unusually long line never aborts the scan mid-stream.
	scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
	for scanner.Scan() {
		ch <- logLineMsg{stream: stream, line: scanner.Text()}
	}

	err = cmd.Wait()
	ch <- stepResultMsg{stream: stream, err: err}
	return err
}

// runQuiet runs name(args...) and returns its combined output and error,
// for a step that needs a command's result but not a live log of it.
func runQuiet(ctx context.Context, dir, name string, args ...string) (string, error) {
	cmd := exec.CommandContext(ctx, name, args...)
	if dir != "" {
		cmd.Dir = dir
	}
	out, err := cmd.CombinedOutput()
	return string(out), err
}

// runPiped runs first | second without a shell — first's stdout feeds
// second's stdin directly through an in-process pipe. This replaces a
// former `sh -c "cmd1 '<arg>' | cmd2"` pattern whose quoting (a bare wrap
// in single quotes) didn't escape an embedded single quote, letting a
// crafted argument (e.g. a VERSION file containing one) break out and
// inject arbitrary shell commands running as root. No shell involved
// here, so there's nothing to escape.
func runPiped(ctx context.Context, first, second []string) error {
	c1 := exec.CommandContext(ctx, first[0], first[1:]...)
	c2 := exec.CommandContext(ctx, second[0], second[1:]...)

	pr, pw := io.Pipe()
	c1.Stdout = pw
	c2.Stdin = pr

	var stderr1, stderr2 bytes.Buffer
	c1.Stderr = &stderr1
	c2.Stderr = &stderr2

	if err := c2.Start(); err != nil {
		return fmt.Errorf("%s: %w", second[0], err)
	}
	if err := c1.Start(); err != nil {
		return fmt.Errorf("%s: %w", first[0], err)
	}

	err1 := c1.Wait()
	pw.Close()
	err2 := c2.Wait()

	if err1 != nil {
		return fmt.Errorf("%s: %w: %s", first[0], err1, strings.TrimSpace(stderr1.String()))
	}
	if err2 != nil {
		return fmt.Errorf("%s: %w: %s", second[0], err2, strings.TrimSpace(stderr2.String()))
	}
	return nil
}

// runToFile runs name(args...) and streams its raw stdout straight into w
// — used for the one step whose output is binary (backup.go's `tar` pull),
// where treating it as line-oriented text like runStreamed would corrupt
// it. stderr is captured separately (not mixed into w, since w is expected
// to be exactly the command's stdout bytes) and folded into the returned
// error so a failure here isn't just a bare "exit status 1" either.
func runToFile(ctx context.Context, w io.Writer, name string, args ...string) error {
	cmd := exec.CommandContext(ctx, name, args...)
	cmd.Stdout = w
	var stderr bytes.Buffer
	cmd.Stderr = &stderr
	if err := cmd.Run(); err != nil {
		if stderr.Len() > 0 {
			return fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String()))
		}
		return err
	}
	return nil
}