termstatus: centralize OutputIsTerminal checks

This commit is contained in:
Michael Eischer 2025-09-14 17:58:52 +02:00
parent c745e4221e
commit 1ae2d08d1b
14 changed files with 85 additions and 68 deletions

View file

@ -523,7 +523,7 @@ func runBackup(ctx context.Context, opts BackupOptions, gopts GlobalOptions, ter
progressPrinter = backup.NewTextProgress(term, gopts.verbosity)
}
progressReporter := backup.NewProgress(progressPrinter,
calculateProgressInterval(!gopts.Quiet, gopts.JSON))
calculateProgressInterval(!gopts.Quiet, gopts.JSON, term.CanUpdateStatus()))
defer progressReporter.Done()
// rejectByNameFuncs collect functions that can reject items from the backup based on path only

View file

@ -540,5 +540,6 @@ func (p *jsonErrorPrinter) E(msg string, args ...interface{}) {
}
func (*jsonErrorPrinter) S(_ string, _ ...interface{}) {}
func (*jsonErrorPrinter) P(_ string, _ ...interface{}) {}
func (*jsonErrorPrinter) PT(_ string, _ ...interface{}) {}
func (*jsonErrorPrinter) V(_ string, _ ...interface{}) {}
func (*jsonErrorPrinter) VV(_ string, _ ...interface{}) {}

View file

@ -11,7 +11,6 @@ import (
"github.com/restic/restic/internal/dump"
"github.com/restic/restic/internal/errors"
"github.com/restic/restic/internal/restic"
"github.com/restic/restic/internal/terminal"
"github.com/restic/restic/internal/ui"
"github.com/spf13/cobra"
@ -180,7 +179,7 @@ func runDump(ctx context.Context, opts DumpOptions, gopts GlobalOptions, args []
}
outputFileWriter := term.OutputRaw()
canWriteArchiveFunc := checkStdoutArchive
canWriteArchiveFunc := checkStdoutArchive(term)
if opts.Target != "" {
file, err := os.Create(opts.Target)
@ -204,9 +203,9 @@ func runDump(ctx context.Context, opts DumpOptions, gopts GlobalOptions, args []
return nil
}
func checkStdoutArchive() error {
if terminal.StdoutIsTerminal() {
return fmt.Errorf("stdout is the terminal, please redirect output")
func checkStdoutArchive(term ui.Terminal) func() error {
if term.OutputIsTerminal() {
return func() error { return fmt.Errorf("stdout is the terminal, please redirect output") }
}
return nil
return func() error { return nil }
}

View file

@ -6,7 +6,6 @@ import (
"time"
"github.com/restic/restic/internal/errors"
"github.com/restic/restic/internal/terminal"
"github.com/restic/restic/internal/ui"
"github.com/restic/restic/internal/ui/progress"
"github.com/spf13/cobra"
@ -76,9 +75,7 @@ func writeManpages(root *cobra.Command, dir string, printer progress.Printer) er
}
func writeCompletion(filename string, shell string, generate func(w io.Writer) error, printer progress.Printer, gopts GlobalOptions) (err error) {
if terminal.StdoutIsTerminal() {
printer.P("writing %s completion file to %v", shell, filename)
}
printer.PT("writing %s completion file to %v", shell, filename)
var outWriter io.Writer
if filename != "-" {
var outFile *os.File

View file

@ -165,7 +165,7 @@ func runRestore(ctx context.Context, opts RestoreOptions, gopts GlobalOptions,
printer = restoreui.NewTextProgress(term, gopts.verbosity)
}
progress := restoreui.NewProgress(printer, calculateProgressInterval(!gopts.Quiet, gopts.JSON))
progress := restoreui.NewProgress(printer, calculateProgressInterval(!gopts.Quiet, gopts.JSON, term.CanUpdateStatus()))
res := restorer.NewRestorer(repo, sn, restorer.Options{
DryRun: opts.DryRun,
Sparse: opts.Sparse,

View file

@ -6,10 +6,13 @@ import (
"github.com/restic/restic/internal/restic"
rtest "github.com/restic/restic/internal/test"
"github.com/restic/restic/internal/ui"
)
func testRunTag(t testing.TB, opts TagOptions, gopts GlobalOptions) {
rtest.OK(t, runTag(context.TODO(), opts, gopts, nil, []string{}))
rtest.OK(t, withTermStatus(gopts, func(ctx context.Context, term ui.Terminal) error {
return runTag(context.TODO(), opts, gopts, term, []string{})
}))
}
func TestTag(t *testing.T) {

View file

@ -267,9 +267,7 @@ func ReadPassword(ctx context.Context, opts GlobalOptions, prompt string, printe
if terminal.StdinIsTerminal() {
password, err = terminal.ReadPassword(ctx, os.Stdin, os.Stderr, prompt)
} else {
if terminal.StdoutIsTerminal() {
printer.P("reading repository password from stdin")
}
printer.PT("reading repository password from stdin")
password, err = readPassword(os.Stdin)
}
@ -385,19 +383,15 @@ func OpenRepository(ctx context.Context, opts GlobalOptions, printer progress.Pr
return nil, errors.Fatalf("%s", err)
}
if terminal.StdoutIsTerminal() && !opts.JSON {
id := s.Config().ID
if len(id) > 8 {
id = id[:8]
}
if !opts.JSON {
extra := ""
if s.Config().Version >= 2 {
extra = ", compression level " + opts.Compression.String()
}
printer.P("repository %v opened (version %v%s)", id, s.Config().Version, extra)
}
id := s.Config().ID
if len(id) > 8 {
id = id[:8]
}
extra := ""
if s.Config().Version >= 2 {
extra = ", compression level " + opts.Compression.String()
}
printer.PT("repository %v opened (version %v%s)", id, s.Config().Version, extra)
if opts.NoCache {
return s, nil
@ -409,8 +403,8 @@ func OpenRepository(ctx context.Context, opts GlobalOptions, printer progress.Pr
return s, nil
}
if c.Created && !opts.JSON && terminal.StdoutIsTerminal() {
printer.P("created new cache in %v", c.Base)
if c.Created {
printer.PT("created new cache in %v", c.Base)
}
// start using the cache
@ -428,9 +422,7 @@ func OpenRepository(ctx context.Context, opts GlobalOptions, printer progress.Pr
// cleanup old cache dirs if instructed to do so
if opts.CleanupCache {
if terminal.StdoutIsTerminal() && !opts.JSON {
printer.P("removing %d old cache dirs from %v", len(oldCacheDirs), c.Base)
}
printer.PT("removing %d old cache dirs from %v", len(oldCacheDirs), c.Base)
for _, item := range oldCacheDirs {
dir := filepath.Join(c.Base, item.Name())
err = os.RemoveAll(dir)
@ -439,10 +431,8 @@ func OpenRepository(ctx context.Context, opts GlobalOptions, printer progress.Pr
}
}
} else {
if terminal.StdoutIsTerminal() {
printer.P("found %d old cache directories in %v, run `restic cache --cleanup` to remove them",
len(oldCacheDirs), c.Base)
}
printer.PT("found %d old cache directories in %v, run `restic cache --cleanup` to remove them",
len(oldCacheDirs), c.Base)
}
return s, nil

View file

@ -6,7 +6,6 @@ import (
"strconv"
"time"
"github.com/restic/restic/internal/terminal"
"github.com/restic/restic/internal/ui"
"github.com/restic/restic/internal/ui/progress"
)
@ -14,7 +13,7 @@ import (
// calculateProgressInterval returns the interval configured via RESTIC_PROGRESS_FPS
// or if unset returns an interval for 60fps on interactive terminals and 0 (=disabled)
// for non-interactive terminals or when run using the --quiet flag
func calculateProgressInterval(show bool, json bool) time.Duration {
func calculateProgressInterval(show bool, json bool, canUpdateStatus bool) time.Duration {
interval := time.Second / 60
fps, err := strconv.ParseFloat(os.Getenv("RESTIC_PROGRESS_FPS"), 64)
if err == nil && fps > 0 {
@ -22,7 +21,7 @@ func calculateProgressInterval(show bool, json bool) time.Duration {
fps = 60
}
interval = time.Duration(float64(time.Second) / fps)
} else if !json && !terminal.StdoutCanUpdateStatus() || !show {
} else if !json && !canUpdateStatus || !show {
interval = 0
}
return interval
@ -33,7 +32,7 @@ func newTerminalProgressMax(show bool, max uint64, description string, term ui.T
if !show {
return nil
}
interval := calculateProgressInterval(show, false)
interval := calculateProgressInterval(show, false, term.CanUpdateStatus())
return progress.NewCounter(interval, max, func(v uint64, max uint64, d time.Duration, final bool) {
var status string
@ -65,7 +64,7 @@ func (t *terminalProgressPrinter) NewCounter(description string) *progress.Count
}
func (t *terminalProgressPrinter) NewCounterTerminalOnly(description string) *progress.Counter {
return newTerminalProgressMax(t.show && terminal.StdoutIsTerminal(), 0, description, t.term)
return newTerminalProgressMax(t.show && t.term.OutputIsTerminal(), 0, description, t.term)
}
func newTerminalProgressPrinter(json bool, verbosity uint, term ui.Terminal) progress.Printer {

View file

@ -10,18 +10,11 @@ func StdinIsTerminal() bool {
return term.IsTerminal(int(os.Stdin.Fd()))
}
func StdoutIsTerminal() bool {
func OutputIsTerminal(fd uintptr) bool {
// mintty on windows can use pipes which behave like a posix terminal,
// but which are not a terminal handle
return term.IsTerminal(int(os.Stdout.Fd())) || StdoutCanUpdateStatus()
}
func StdoutCanUpdateStatus() bool {
return CanUpdateStatus(os.Stdout.Fd())
}
func StdoutWidth() int {
return Width(os.Stdout.Fd())
// but which are not a terminal handle. Thus also check `CanUpdateStatus`,
// which is able to detect such pipes.
return term.IsTerminal(int(fd)) || CanUpdateStatus(fd)
}
func Width(fd uintptr) int {

View file

@ -30,8 +30,17 @@ func (m *Message) S(msg string, args ...interface{}) {
m.term.Print(fmt.Sprintf(msg, args...))
}
// P prints a message if verbosity >= 1 (neither --quiet nor --verbose is specified),
// this is used for normal messages which are not errors.
// PT prints a message if verbosity >= 1 (neither --quiet nor --verbose is specified)
// and stdout points to a terminal.
// This is used for informational messages.
func (m *Message) PT(msg string, args ...interface{}) {
if m.term.OutputIsTerminal() && m.v >= 1 {
m.term.Print(fmt.Sprintf(msg, args...))
}
}
// P prints a message if verbosity >= 1 (neither --quiet nor --verbose is specified).
// This is used for normal messages which are not errors.
func (m *Message) P(msg string, args ...interface{}) {
if m.v >= 1 {
m.term.Print(fmt.Sprintf(msg, args...))

View file

@ -28,3 +28,7 @@ func (m *MockTerminal) CanUpdateStatus() bool {
func (m *MockTerminal) OutputRaw() io.Writer {
return nil
}
func (m *MockTerminal) OutputIsTerminal() bool {
return true
}

View file

@ -19,6 +19,10 @@ type Printer interface {
// that are not errors. The message is even printed if --quiet is specified.
// Appends a newline if not present.
S(msg string, args ...interface{})
// PT prints a message if verbosity >= 1 (neither --quiet nor --verbose is specified)
// and stdout points to a terminal.
// This is used for informational messages.
PT(msg string, args ...interface{})
// P prints a message if verbosity >= 1 (neither --quiet nor --verbose is specified),
// this is used for normal messages which are not errors. Appends a newline if not present.
P(msg string, args ...interface{})
@ -47,6 +51,8 @@ func (*NoopPrinter) E(_ string, _ ...interface{}) {}
func (*NoopPrinter) S(_ string, _ ...interface{}) {}
func (*NoopPrinter) PT(_ string, _ ...interface{}) {}
func (*NoopPrinter) P(_ string, _ ...interface{}) {}
func (*NoopPrinter) V(_ string, _ ...interface{}) {}
@ -82,6 +88,10 @@ func (p *TestPrinter) S(msg string, args ...interface{}) {
p.t.Logf("stdout: "+msg, args...)
}
func (p *TestPrinter) PT(msg string, args ...interface{}) {
p.t.Logf("stdout(terminal): "+msg, args...)
}
func (p *TestPrinter) P(msg string, args ...interface{}) {
p.t.Logf("print: "+msg, args...)
}

View file

@ -17,4 +17,5 @@ type Terminal interface {
// other option. Must not be used in combination with Print, Error, SetStatus
// or any other method that writes to the terminal.
OutputRaw() io.Writer
OutputIsTerminal() bool
}

View file

@ -16,13 +16,14 @@ var _ ui.Terminal = &Terminal{}
// updated. When the output is redirected to a file, the status lines are not
// printed.
type Terminal struct {
wr io.Writer
fd uintptr
errWriter io.Writer
msg chan message
status chan status
canUpdateStatus bool
lastStatusLen int
wr io.Writer
fd uintptr
errWriter io.Writer
msg chan message
status chan status
outputIsTerminal bool
canUpdateStatus bool
lastStatusLen int
// will be closed when the goroutine which runs Run() terminates, so it'll
// yield a default value immediately
@ -65,12 +66,17 @@ func New(wr io.Writer, errWriter io.Writer, disableStatus bool) *Terminal {
return t
}
if d, ok := wr.(fder); ok && terminal.CanUpdateStatus(d.Fd()) {
// only use the fancy status code when we're running on a real terminal.
t.canUpdateStatus = true
t.fd = d.Fd()
t.clearCurrentLine = terminal.ClearCurrentLine(t.fd)
t.moveCursorUp = terminal.MoveCursorUp(t.fd)
if d, ok := wr.(fder); ok {
if terminal.CanUpdateStatus(d.Fd()) {
// only use the fancy status code when we're running on a real terminal.
t.canUpdateStatus = true
t.fd = d.Fd()
t.clearCurrentLine = terminal.ClearCurrentLine(t.fd)
t.moveCursorUp = terminal.MoveCursorUp(t.fd)
}
if terminal.OutputIsTerminal(d.Fd()) {
t.outputIsTerminal = true
}
}
return t
@ -88,6 +94,11 @@ func (t *Terminal) OutputRaw() io.Writer {
return t.wr
}
// OutputIsTerminal returns whether the output is a terminal.
func (t *Terminal) OutputIsTerminal() bool {
return t.outputIsTerminal
}
// Run updates the screen. It should be run in a separate goroutine. When
// ctx is cancelled, the status lines are cleanly removed.
func (t *Terminal) Run(ctx context.Context) {