diff --git a/docs/mdbook/SUMMARY.md b/docs/mdbook/SUMMARY.md index 0361912f8..f513f03df 100644 --- a/docs/mdbook/SUMMARY.md +++ b/docs/mdbook/SUMMARY.md @@ -86,6 +86,7 @@ - [`fail_text`](./configuration/fail_text.md) - [`stage_fixed`](./configuration/stage_fixed.md) - [`interactive`](./configuration/interactive.md) + - [`stream`](./configuration/stream.md) - [`use_stdin`](./configuration/use_stdin.md) - [`commands`](./configuration/Commands.md) - [`run`](./configuration/run.md) @@ -101,6 +102,7 @@ - [`fail_text`](./configuration/fail_text.md) - [`stage_fixed`](./configuration/stage_fixed.md) - [`interactive`](./configuration/interactive.md) + - [`stream`](./configuration/stream.md) - [`use_stdin`](./configuration/use_stdin.md) - [`priority`](./configuration/priority.md) - [`scripts`](./configuration/Scripts.md) @@ -112,6 +114,7 @@ - [`fail_text`](./configuration/fail_text.md) - [`stage_fixed`](./configuration/stage_fixed.md) - [`interactive`](./configuration/interactive.md) + - [`stream`](./configuration/stream.md) - [`use_stdin`](./configuration/use_stdin.md) - [`priority`](./configuration/priority.md) - [ENV variables](./usage/env.md) diff --git a/docs/mdbook/configuration/stream.md b/docs/mdbook/configuration/stream.md new file mode 100644 index 000000000..77d63a528 --- /dev/null +++ b/docs/mdbook/configuration/stream.md @@ -0,0 +1,37 @@ +## `stream` + +**Default: `false`** + +Enable real-time streaming output for commands or scripts without requiring a TTY. This is useful when you want to see command output as it happens in non-interactive environments like VS Code's git commit interface, CI/CD pipelines, or any environment where a TTY is not available. + +Unlike [`interactive`](./interactive.md), `stream` does not attempt to open `/dev/tty` for user input, making it work seamlessly in environments without TTY access. + +**Example** + +Use this option when you want to see real-time output from long-running commands or scripts, such as linters or test runners, even when running commits from editors or IDEs: + +```yml +# lefthook.yml +pre-commit: + commands: + tests: + run: npm test + stream: true + lint: + run: npm run lint + stream: true +``` + +**Comparison with other options** + +- `stream: true` - Streams output in real-time, no TTY required, no user input +- `interactive: true` - Requires TTY for user input, will fail if TTY is unavailable +- `use_stdin: true` - Passes stdin to the command but doesn't control output streaming +- Default (all false) - Uses pseudo-TTY for output buffering + +**When to use** + +- You want real-time output visibility +- You're running hooks from non-TTY environments (VS Code, GitHub Desktop, etc.) +- Your command produces output you want to see immediately +- You don't need user input during execution diff --git a/internal/config/command.go b/internal/config/command.go index bd9775023..bfc238a5e 100644 --- a/internal/config/command.go +++ b/internal/config/command.go @@ -27,6 +27,7 @@ type Command struct { Priority int `json:"priority,omitempty" mapstructure:"priority" toml:"priority,omitempty" yaml:",omitempty"` Interactive bool `json:"interactive,omitempty" mapstructure:"interactive" toml:"interactive,omitempty" yaml:",omitempty"` + Stream bool `json:"stream,omitempty" mapstructure:"stream" toml:"stream,omitempty" yaml:",omitempty"` UseStdin bool `json:"use_stdin,omitempty" koanf:"use_stdin" mapstructure:"use_stdin" toml:"use_stdin,omitempty" yaml:"use_stdin,omitempty"` StageFixed bool `json:"stage_fixed,omitempty" koanf:"stage_fixed" mapstructure:"stage_fixed" toml:"stage_fixed,omitempty" yaml:"stage_fixed,omitempty"` } @@ -45,6 +46,7 @@ func CommandsToJobs(commands map[string]*Command) []*Job { FileTypes: command.FileTypes, Env: command.Env, Interactive: command.Interactive, + Stream: command.Stream, UseStdin: command.UseStdin, StageFixed: command.StageFixed, Exclude: command.Exclude, diff --git a/internal/config/job.go b/internal/config/job.go index e6ce7bc98..b5ea5f50b 100644 --- a/internal/config/job.go +++ b/internal/config/job.go @@ -17,6 +17,7 @@ type Job struct { Env map[string]string `json:"env,omitempty" mapstructure:"env" toml:"env,omitempty" yaml:",omitempty"` Interactive bool `json:"interactive,omitempty" mapstructure:"interactive" toml:"interactive,omitempty" yaml:",omitempty"` + Stream bool `json:"stream,omitempty" mapstructure:"stream" toml:"stream,omitempty" yaml:",omitempty"` UseStdin bool `json:"use_stdin,omitempty" koanf:"use_stdin" mapstructure:"use_stdin" toml:"use_stdin,omitempty" yaml:"use_stdin,omitempty"` StageFixed bool `json:"stage_fixed,omitempty" koanf:"stage_fixed" mapstructure:"stage_fixed" toml:"stage_fixed,omitempty" yaml:"stage_fixed,omitempty"` diff --git a/internal/config/jsonschema.json b/internal/config/jsonschema.json index 108060531..8fcec389e 100644 --- a/internal/config/jsonschema.json +++ b/internal/config/jsonschema.json @@ -93,6 +93,9 @@ "interactive": { "type": "boolean" }, + "stream": { + "type": "boolean" + }, "use_stdin": { "type": "boolean" }, @@ -300,6 +303,9 @@ "interactive": { "type": "boolean" }, + "stream": { + "type": "boolean" + }, "use_stdin": { "type": "boolean" }, @@ -421,6 +427,9 @@ "interactive": { "type": "boolean" }, + "stream": { + "type": "boolean" + }, "use_stdin": { "type": "boolean" }, @@ -432,7 +441,7 @@ "type": "object" } }, - "$comment": "Last updated on 2025.10.24.", + "$comment": "Last updated on 2025.10.31.", "properties": { "min_version": { "type": "string", diff --git a/internal/config/script.go b/internal/config/script.go index a9be4227e..db84af09f 100644 --- a/internal/config/script.go +++ b/internal/config/script.go @@ -19,6 +19,7 @@ type Script struct { FailText string `json:"fail_text,omitempty" koanf:"fail_text" mapstructure:"fail_text" toml:"fail_text,omitempty" yaml:"fail_text,omitempty"` Interactive bool `json:"interactive,omitempty" mapstructure:"interactive" toml:"interactive,omitempty" yaml:",omitempty"` + Stream bool `json:"stream,omitempty" mapstructure:"stream" toml:"stream,omitempty" yaml:",omitempty"` UseStdin bool `json:"use_stdin,omitempty" koanf:"use_stdin" mapstructure:"use_stdin" toml:"use_stdin,omitempty" yaml:"use_stdin,omitempty"` StageFixed bool `json:"stage_fixed,omitempty" koanf:"stage_fixed" mapstructure:"stage_fixed" toml:"stage_fixed,omitempty" yaml:"stage_fixed,omitempty"` } @@ -34,6 +35,7 @@ func ScriptsToJobs(scripts map[string]*Script) []*Job { Tags: script.Tags, Env: script.Env, Interactive: script.Interactive, + Stream: script.Stream, UseStdin: script.UseStdin, StageFixed: script.StageFixed, Skip: script.Skip, diff --git a/internal/run/controller/exec/exec_unix.go b/internal/run/controller/exec/exec_unix.go index 0da710d19..24b6ee827 100644 --- a/internal/run/controller/exec/exec_unix.go +++ b/internal/run/controller/exec/exec_unix.go @@ -20,15 +20,16 @@ import ( type CommandExecutor struct{} type executeArgs struct { - in io.Reader - out io.Writer - envs []string - root string - interactive, useStdin bool + in io.Reader + out io.Writer + envs []string + root string + interactive, stream, useStdin bool } func (e CommandExecutor) Execute(ctx context.Context, opts Options, in io.Reader, out io.Writer) error { - if opts.Interactive && !isatty.IsTerminal(os.Stdin.Fd()) { + // Only try to open TTY for interactive mode (user input needed), not for stream mode + if opts.Interactive && !opts.Stream && !isatty.IsTerminal(os.Stdin.Fd()) { tty, err := os.Open("/dev/tty") if err == nil { defer func() { @@ -63,6 +64,7 @@ func (e CommandExecutor) Execute(ctx context.Context, opts Options, in io.Reader envs: envs, root: root, interactive: opts.Interactive, + stream: opts.Stream, useStdin: opts.UseStdin, } @@ -83,7 +85,7 @@ func (e CommandExecutor) execute(ctx context.Context, cmdstr string, args *execu command.Dir = args.root command.Env = append(os.Environ(), args.envs...) - if args.interactive || args.useStdin { + if args.interactive || args.stream || args.useStdin { command.Stdout = args.out command.Stdin = args.in command.Stderr = os.Stderr diff --git a/internal/run/controller/exec/exec_windows.go b/internal/run/controller/exec/exec_windows.go index d3212a4f4..70664e8c7 100644 --- a/internal/run/controller/exec/exec_windows.go +++ b/internal/run/controller/exec/exec_windows.go @@ -25,7 +25,8 @@ type executeArgs struct { } func (e CommandExecutor) Execute(ctx context.Context, opts Options, in io.Reader, out io.Writer) error { - if opts.Interactive && !isatty.IsTerminal(os.Stdin.Fd()) { + // Only try to open TTY for interactive mode (user input needed), not for stream mode + if opts.Interactive && !opts.Stream && !isatty.IsTerminal(os.Stdin.Fd()) { tty, err := tty.Open() if err == nil { defer tty.Close() diff --git a/internal/run/controller/exec/executor.go b/internal/run/controller/exec/executor.go index 09d6a6fc4..dc9b83c0f 100644 --- a/internal/run/controller/exec/executor.go +++ b/internal/run/controller/exec/executor.go @@ -7,10 +7,10 @@ import ( // Options contains the data that controls the execution. type Options struct { - Root string - Commands []string - Env map[string]string - Interactive, UseStdin bool + Root string + Commands []string + Env map[string]string + Interactive, Stream, UseStdin bool } // Executor provides an interface for command execution. diff --git a/internal/run/controller/job.go b/internal/run/controller/job.go index 112c434f3..e99372cbf 100644 --- a/internal/run/controller/job.go +++ b/internal/run/controller/job.go @@ -131,6 +131,7 @@ func (c *Controller) runSingleJob(ctx context.Context, scope *scope, id string, Root: filepath.Join(c.git.RootPath, scope.root), Commands: commands, Interactive: job.Interactive && !scope.opts.DisableTTY, + Stream: job.Stream, UseStdin: job.UseStdin, Env: env, }) diff --git a/schema.json b/schema.json index 108060531..8fcec389e 100644 --- a/schema.json +++ b/schema.json @@ -93,6 +93,9 @@ "interactive": { "type": "boolean" }, + "stream": { + "type": "boolean" + }, "use_stdin": { "type": "boolean" }, @@ -300,6 +303,9 @@ "interactive": { "type": "boolean" }, + "stream": { + "type": "boolean" + }, "use_stdin": { "type": "boolean" }, @@ -421,6 +427,9 @@ "interactive": { "type": "boolean" }, + "stream": { + "type": "boolean" + }, "use_stdin": { "type": "boolean" }, @@ -432,7 +441,7 @@ "type": "object" } }, - "$comment": "Last updated on 2025.10.24.", + "$comment": "Last updated on 2025.10.31.", "properties": { "min_version": { "type": "string",