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) progressPrinter = backup.NewTextProgress(term, gopts.verbosity)
} }
progressReporter := backup.NewProgress(progressPrinter, progressReporter := backup.NewProgress(progressPrinter,
calculateProgressInterval(!gopts.Quiet, gopts.JSON)) calculateProgressInterval(!gopts.Quiet, gopts.JSON, term.CanUpdateStatus()))
defer progressReporter.Done() defer progressReporter.Done()
// rejectByNameFuncs collect functions that can reject items from the backup based on path only // 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) S(_ string, _ ...interface{}) {}
func (*jsonErrorPrinter) P(_ string, _ ...interface{}) {} func (*jsonErrorPrinter) P(_ string, _ ...interface{}) {}
func (*jsonErrorPrinter) PT(_ string, _ ...interface{}) {}
func (*jsonErrorPrinter) V(_ string, _ ...interface{}) {} func (*jsonErrorPrinter) V(_ string, _ ...interface{}) {}
func (*jsonErrorPrinter) VV(_ 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/dump"
"github.com/restic/restic/internal/errors" "github.com/restic/restic/internal/errors"
"github.com/restic/restic/internal/restic" "github.com/restic/restic/internal/restic"
"github.com/restic/restic/internal/terminal"
"github.com/restic/restic/internal/ui" "github.com/restic/restic/internal/ui"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@ -180,7 +179,7 @@ func runDump(ctx context.Context, opts DumpOptions, gopts GlobalOptions, args []
} }
outputFileWriter := term.OutputRaw() outputFileWriter := term.OutputRaw()
canWriteArchiveFunc := checkStdoutArchive canWriteArchiveFunc := checkStdoutArchive(term)
if opts.Target != "" { if opts.Target != "" {
file, err := os.Create(opts.Target) file, err := os.Create(opts.Target)
@ -204,9 +203,9 @@ func runDump(ctx context.Context, opts DumpOptions, gopts GlobalOptions, args []
return nil return nil
} }
func checkStdoutArchive() error { func checkStdoutArchive(term ui.Terminal) func() error {
if terminal.StdoutIsTerminal() { if term.OutputIsTerminal() {
return fmt.Errorf("stdout is the terminal, please redirect output") 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" "time"
"github.com/restic/restic/internal/errors" "github.com/restic/restic/internal/errors"
"github.com/restic/restic/internal/terminal"
"github.com/restic/restic/internal/ui" "github.com/restic/restic/internal/ui"
"github.com/restic/restic/internal/ui/progress" "github.com/restic/restic/internal/ui/progress"
"github.com/spf13/cobra" "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) { func writeCompletion(filename string, shell string, generate func(w io.Writer) error, printer progress.Printer, gopts GlobalOptions) (err error) {
if terminal.StdoutIsTerminal() { printer.PT("writing %s completion file to %v", shell, filename)
printer.P("writing %s completion file to %v", shell, filename)
}
var outWriter io.Writer var outWriter io.Writer
if filename != "-" { if filename != "-" {
var outFile *os.File 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) 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{ res := restorer.NewRestorer(repo, sn, restorer.Options{
DryRun: opts.DryRun, DryRun: opts.DryRun,
Sparse: opts.Sparse, Sparse: opts.Sparse,

View file

@ -6,10 +6,13 @@ import (
"github.com/restic/restic/internal/restic" "github.com/restic/restic/internal/restic"
rtest "github.com/restic/restic/internal/test" rtest "github.com/restic/restic/internal/test"
"github.com/restic/restic/internal/ui"
) )
func testRunTag(t testing.TB, opts TagOptions, gopts GlobalOptions) { 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) { func TestTag(t *testing.T) {

View file

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

View file

@ -6,7 +6,6 @@ import (
"strconv" "strconv"
"time" "time"
"github.com/restic/restic/internal/terminal"
"github.com/restic/restic/internal/ui" "github.com/restic/restic/internal/ui"
"github.com/restic/restic/internal/ui/progress" "github.com/restic/restic/internal/ui/progress"
) )
@ -14,7 +13,7 @@ import (
// calculateProgressInterval returns the interval configured via RESTIC_PROGRESS_FPS // calculateProgressInterval returns the interval configured via RESTIC_PROGRESS_FPS
// or if unset returns an interval for 60fps on interactive terminals and 0 (=disabled) // 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 // 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 interval := time.Second / 60
fps, err := strconv.ParseFloat(os.Getenv("RESTIC_PROGRESS_FPS"), 64) fps, err := strconv.ParseFloat(os.Getenv("RESTIC_PROGRESS_FPS"), 64)
if err == nil && fps > 0 { if err == nil && fps > 0 {
@ -22,7 +21,7 @@ func calculateProgressInterval(show bool, json bool) time.Duration {
fps = 60 fps = 60
} }
interval = time.Duration(float64(time.Second) / fps) interval = time.Duration(float64(time.Second) / fps)
} else if !json && !terminal.StdoutCanUpdateStatus() || !show { } else if !json && !canUpdateStatus || !show {
interval = 0 interval = 0
} }
return interval return interval
@ -33,7 +32,7 @@ func newTerminalProgressMax(show bool, max uint64, description string, term ui.T
if !show { if !show {
return nil 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) { return progress.NewCounter(interval, max, func(v uint64, max uint64, d time.Duration, final bool) {
var status string var status string
@ -65,7 +64,7 @@ func (t *terminalProgressPrinter) NewCounter(description string) *progress.Count
} }
func (t *terminalProgressPrinter) NewCounterTerminalOnly(description string) *progress.Counter { 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 { 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())) 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, // mintty on windows can use pipes which behave like a posix terminal,
// but which are not a terminal handle // but which are not a terminal handle. Thus also check `CanUpdateStatus`,
return term.IsTerminal(int(os.Stdout.Fd())) || StdoutCanUpdateStatus() // which is able to detect such pipes.
} return term.IsTerminal(int(fd)) || CanUpdateStatus(fd)
func StdoutCanUpdateStatus() bool {
return CanUpdateStatus(os.Stdout.Fd())
}
func StdoutWidth() int {
return Width(os.Stdout.Fd())
} }
func Width(fd uintptr) int { 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...)) m.term.Print(fmt.Sprintf(msg, args...))
} }
// P prints a message if verbosity >= 1 (neither --quiet nor --verbose is specified), // PT prints a message if verbosity >= 1 (neither --quiet nor --verbose is specified)
// this is used for normal messages which are not errors. // 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{}) { func (m *Message) P(msg string, args ...interface{}) {
if m.v >= 1 { if m.v >= 1 {
m.term.Print(fmt.Sprintf(msg, args...)) m.term.Print(fmt.Sprintf(msg, args...))

View file

@ -28,3 +28,7 @@ func (m *MockTerminal) CanUpdateStatus() bool {
func (m *MockTerminal) OutputRaw() io.Writer { func (m *MockTerminal) OutputRaw() io.Writer {
return nil 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. // that are not errors. The message is even printed if --quiet is specified.
// Appends a newline if not present. // Appends a newline if not present.
S(msg string, args ...interface{}) 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), // 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. // this is used for normal messages which are not errors. Appends a newline if not present.
P(msg string, args ...interface{}) P(msg string, args ...interface{})
@ -47,6 +51,8 @@ func (*NoopPrinter) E(_ string, _ ...interface{}) {}
func (*NoopPrinter) S(_ string, _ ...interface{}) {} func (*NoopPrinter) S(_ string, _ ...interface{}) {}
func (*NoopPrinter) PT(_ string, _ ...interface{}) {}
func (*NoopPrinter) P(_ string, _ ...interface{}) {} func (*NoopPrinter) P(_ string, _ ...interface{}) {}
func (*NoopPrinter) V(_ 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...) 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{}) { func (p *TestPrinter) P(msg string, args ...interface{}) {
p.t.Logf("print: "+msg, args...) 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 // other option. Must not be used in combination with Print, Error, SetStatus
// or any other method that writes to the terminal. // or any other method that writes to the terminal.
OutputRaw() io.Writer OutputRaw() io.Writer
OutputIsTerminal() bool
} }

View file

@ -21,6 +21,7 @@ type Terminal struct {
errWriter io.Writer errWriter io.Writer
msg chan message msg chan message
status chan status status chan status
outputIsTerminal bool
canUpdateStatus bool canUpdateStatus bool
lastStatusLen int lastStatusLen int
@ -65,13 +66,18 @@ func New(wr io.Writer, errWriter io.Writer, disableStatus bool) *Terminal {
return t return t
} }
if d, ok := wr.(fder); ok && terminal.CanUpdateStatus(d.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. // only use the fancy status code when we're running on a real terminal.
t.canUpdateStatus = true t.canUpdateStatus = true
t.fd = d.Fd() t.fd = d.Fd()
t.clearCurrentLine = terminal.ClearCurrentLine(t.fd) t.clearCurrentLine = terminal.ClearCurrentLine(t.fd)
t.moveCursorUp = terminal.MoveCursorUp(t.fd) t.moveCursorUp = terminal.MoveCursorUp(t.fd)
} }
if terminal.OutputIsTerminal(d.Fd()) {
t.outputIsTerminal = true
}
}
return t return t
} }
@ -88,6 +94,11 @@ func (t *Terminal) OutputRaw() io.Writer {
return t.wr 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 // Run updates the screen. It should be run in a separate goroutine. When
// ctx is cancelled, the status lines are cleanly removed. // ctx is cancelled, the status lines are cleanly removed.
func (t *Terminal) Run(ctx context.Context) { func (t *Terminal) Run(ctx context.Context) {