Skip to content

Commit 5c0c6a9

Browse files
authored
chore(with-watch): add --clear terminal refresh mode (#377)
## Summary - add a global `--clear` flag across passthrough, `--shell`, and `exec --input` modes - clear the terminal before the initial run and each rerun only when stdout is a TTY - document the new contract in the with-watch README and project docs ## Testing - cargo test -p with-watch - cargo test - cargo run -p with-watch -- --help
1 parent 6817211 commit 5c0c6a9

10 files changed

Lines changed: 241 additions & 29 deletions

File tree

crates/AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444

4545
- Keep passthrough, shell, and `exec --input` command shapes stable and documented in `docs/project-with-watch.md` and `docs/crates-with-watch-foundation.md`.
4646
- Keep default rerun filtering content-hash-based, with `--no-hash` as the documented metadata-only override.
47+
- Keep `--clear` as a best-effort TTY-only output refresh flag; redirected or piped stdout must stay byte-for-byte clean.
4748
- Keep shell support scoped to command-line expressions and do not silently broaden into shell-script control-flow without updating docs first.
4849
- Keep logs sufficient to explain inferred inputs, watcher anchors, snapshot counts, and rerun causes.
4950
- Keep public release contracts aligned across root publish-tag allowlist, `.github/workflows/release-with-watch.yml`, and Homebrew packaging assets.

crates/with-watch/README.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,19 @@ brew install delinoio/tap/with-watch
1919

2020
## Command modes
2121

22-
- Passthrough mode: `with-watch [--no-hash] <utility> [args...]`
23-
- Shell mode: `with-watch [--no-hash] --shell '<expr>'`
24-
- Explicit-input mode: `with-watch exec [--no-hash] --input <glob>... -- <command> [args...]`
22+
- Passthrough mode: `with-watch [--no-hash] [--clear] <utility> [args...]`
23+
- Shell mode: `with-watch [--no-hash] [--clear] --shell '<expr>'`
24+
- Explicit-input mode: `with-watch exec [--no-hash] [--clear] --input <glob>... -- <command> [args...]`
25+
26+
All modes also accept `--clear`, which clears the terminal before the initial run and each rerun when stdout is an interactive terminal.
2527

2628
Use passthrough mode for a single delegated command, shell mode for simple command-line expressions that need `&&`, `||`, or `|`, and `exec --input` when you want to declare the watched files yourself.
2729

2830
## Quick start
2931

3032
```sh
3133
with-watch cat input.txt
34+
with-watch --clear cat input.txt
3235
with-watch cp src.txt dest.txt
3336
with-watch ls -l
3437
with-watch --shell 'cat src.txt | grep hello'
@@ -104,6 +107,7 @@ with-watch exec --input 'src/**/*.rs' -- cargo test -p with-watch
104107
## Rerun behavior
105108

106109
- `with-watch` always performs one initial run after it has inferred inputs and armed the watcher, even before any external filesystem change occurs.
110+
- `--clear` clears stdout before the initial run and each rerun only when stdout is a terminal; redirected and piped output remain unchanged.
107111
- The default rerun filter compares content hashes, which avoids reruns from metadata churn alone.
108112
- `ls`, `dir`, and `vdir` use metadata-based listing snapshots instead of hashing every file under the watched directory before the first run.
109113
- `--no-hash` switches the filter to metadata-only comparison.

crates/with-watch/src/analysis.rs

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -494,15 +494,15 @@ pub fn render_after_long_help() -> String {
494494
let inventory = help_inventory();
495495

496496
format!(
497-
"Command modes:\n Passthrough: with-watch [--no-hash] <utility> [args...]\n Shell: \
498-
with-watch [--no-hash] --shell '<expr>'\n Explicit inputs: with-watch exec [--no-hash] \
499-
--input <glob>... -- <command> [args...]\n\nWrapper commands:\n {}\n\nDedicated \
500-
built-in adapters and aliases:\n {}\n\nGeneric read-path commands:\n {}\n\nSafe \
501-
current-directory defaults:\n {}\n\nRecognized but not auto-watchable commands:\n {}\n \
502-
These commands are recognized, but they do not expose stable filesystem inputs on their \
503-
own.\n\nexec --input escape hatch:\n Use `with-watch exec --input <glob>... -- \
504-
<command> [args...]` when inference is ambiguous, when a command has no stable \
505-
filesystem inputs, or when you want an explicit watch set.",
497+
"Command modes:\n Passthrough: with-watch [--no-hash] [--clear] <utility> [args...]\n \
498+
Shell: with-watch [--no-hash] [--clear] --shell '<expr>'\n Explicit inputs: with-watch \
499+
exec [--no-hash] [--clear] --input <glob>... -- <command> [args...]\n\nWrapper \
500+
commands:\n {}\n\nDedicated built-in adapters and aliases:\n {}\n\nGeneric read-path \
501+
commands:\n {}\n\nSafe current-directory defaults:\n {}\n\nRecognized but not \
502+
auto-watchable commands:\n {}\n These commands are recognized, but they do not expose \
503+
stable filesystem inputs on their own.\n\nexec --input escape hatch:\n Use `with-watch \
504+
exec --input <glob>... -- <command> [args...]` when inference is ambiguous, when a \
505+
command has no stable filesystem inputs, or when you want an explicit watch set.",
506506
join_command_names(&inventory.wrapper_commands),
507507
join_command_names(&inventory.dedicated_built_ins),
508508
join_command_names(inventory.generic_read_path_commands),

crates/with-watch/src/cli.rs

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use clap::{Args, CommandFactory, FromArgMatches, Parser, Subcommand};
55
use crate::{
66
analysis::render_after_long_help,
77
error::{Result, WithWatchError},
8+
runner::OutputRefreshMode,
89
snapshot::ChangeDetectionMode,
910
};
1011

@@ -19,6 +20,10 @@ pub struct Cli {
1920
#[arg(long, global = true)]
2021
pub no_hash: bool,
2122

23+
/// Clear the terminal before the initial run and each rerun.
24+
#[arg(long, global = true)]
25+
pub clear: bool,
26+
2227
/// Run a quoted shell command line that may contain `&&`, `||`, or `|`.
2328
#[arg(long, global = true, value_name = "EXPR")]
2429
pub shell: Option<String>,
@@ -78,6 +83,14 @@ impl Cli {
7883
}
7984
}
8085

86+
pub fn output_refresh_mode(&self) -> OutputRefreshMode {
87+
if self.clear {
88+
OutputRefreshMode::ClearTerminal
89+
} else {
90+
OutputRefreshMode::Preserve
91+
}
92+
}
93+
8194
pub fn command_mode(&self) -> Result<CommandMode> {
8295
match (&self.shell, &self.command) {
8396
(Some(_), Some(_)) => Err(WithWatchError::ConflictingModes),
@@ -115,7 +128,7 @@ mod tests {
115128
use clap::Parser;
116129

117130
use super::{Cli, CommandMode};
118-
use crate::{error::WithWatchError, snapshot::ChangeDetectionMode};
131+
use crate::{error::WithWatchError, runner::OutputRefreshMode, snapshot::ChangeDetectionMode};
119132

120133
#[test]
121134
fn passthrough_mode_preserves_external_subcommand_arguments() {
@@ -142,6 +155,42 @@ mod tests {
142155
assert!(matches!(error, WithWatchError::ConflictingModes));
143156
}
144157

158+
#[test]
159+
fn passthrough_mode_accepts_clear_flag() {
160+
let cli = Cli::parse_from(["with-watch", "--clear", "cat", "input.txt"]);
161+
let mode = cli.command_mode().expect("command mode");
162+
163+
assert_eq!(cli.output_refresh_mode(), OutputRefreshMode::ClearTerminal);
164+
assert!(matches!(mode, CommandMode::Passthrough { .. }));
165+
}
166+
167+
#[test]
168+
fn shell_mode_accepts_clear_flag() {
169+
let cli = Cli::parse_from(["with-watch", "--clear", "--shell", "cat input.txt"]);
170+
let mode = cli.command_mode().expect("command mode");
171+
172+
assert_eq!(cli.output_refresh_mode(), OutputRefreshMode::ClearTerminal);
173+
assert!(matches!(mode, CommandMode::Shell { .. }));
174+
}
175+
176+
#[test]
177+
fn exec_mode_accepts_clear_flag() {
178+
let cli = Cli::parse_from([
179+
"with-watch",
180+
"exec",
181+
"--clear",
182+
"--input",
183+
"src/**/*.rs",
184+
"--",
185+
"cargo",
186+
"test",
187+
]);
188+
let mode = cli.command_mode().expect("command mode");
189+
190+
assert_eq!(cli.output_refresh_mode(), OutputRefreshMode::ClearTerminal);
191+
assert!(matches!(mode, CommandMode::Exec { .. }));
192+
}
193+
145194
#[test]
146195
fn exec_mode_uses_mtime_only_when_hashing_is_disabled() {
147196
let cli = Cli::parse_from([
@@ -177,7 +226,9 @@ mod tests {
177226

178227
assert!(!short_help.contains("Wrapper commands:"));
179228
assert!(!short_help.contains("Recognized but not auto-watchable commands:"));
229+
assert!(short_help.contains("--clear"));
180230
assert!(long_help.contains("Wrapper commands:"));
181231
assert!(long_help.contains("Recognized but not auto-watchable commands:"));
232+
assert!(long_help.contains("--clear"));
182233
}
183234
}

crates/with-watch/src/error.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ pub enum WithWatchError {
6464
#[source]
6565
source: io::Error,
6666
},
67+
#[error("Failed to refresh stdout before running the delegated command: {0}")]
68+
StdoutRefresh(#[source] io::Error),
6769
#[error("`--shell` execution is only supported on Unix-like platforms.")]
6870
UnsupportedShellPlatform,
6971
}
@@ -87,7 +89,8 @@ impl WithWatchError {
8789
| Self::WatcherCreate(_)
8890
| Self::WatchPath { .. }
8991
| Self::Spawn { .. }
90-
| Self::Wait { .. } => 1,
92+
| Self::Wait { .. }
93+
| Self::StdoutRefresh(_) => 1,
9194
}
9295
}
9396
}

crates/with-watch/src/lib.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,21 +13,23 @@ use analysis::{analyze_argv, analyze_shell_expression, CommandAnalysis, CommandA
1313
use cli::{Cli, CommandMode};
1414
use error::{Result, WithWatchError};
1515
use parser::parse_shell_expression;
16-
use runner::{ExecutionMetadata, ExecutionPlan, RunnerOptions};
16+
use runner::{ExecutionMetadata, ExecutionPlan, OutputRefreshMode, RunnerOptions};
1717
use snapshot::{ChangeDetectionMode, WatchInput, WatchInputKind};
1818
use tracing::debug;
1919

2020
pub fn run_cli(cli: Cli, options: RunnerOptions) -> Result<i32> {
2121
let mode = cli.command_mode()?;
2222
let cwd = std::env::current_dir().map_err(WithWatchError::CurrentDirectory)?;
2323
let detection_mode = cli.change_detection_mode();
24-
let plan = build_execution_plan(mode, detection_mode, &cwd)?;
24+
let output_refresh_mode = cli.output_refresh_mode();
25+
let plan = build_execution_plan(mode, detection_mode, output_refresh_mode, &cwd)?;
2526
runner::run(plan, options)
2627
}
2728

2829
fn build_execution_plan(
2930
mode: CommandMode,
3031
detection_mode: ChangeDetectionMode,
32+
output_refresh_mode: OutputRefreshMode,
3133
cwd: &Path,
3234
) -> Result<ExecutionPlan> {
3335
match mode {
@@ -39,6 +41,7 @@ fn build_execution_plan(
3941
argv,
4042
inputs,
4143
detection_mode,
44+
output_refresh_mode,
4245
execution_metadata(&analysis),
4346
))
4447
}
@@ -51,6 +54,7 @@ fn build_execution_plan(
5154
expression,
5255
inputs,
5356
detection_mode,
57+
output_refresh_mode,
5458
execution_metadata(&analysis),
5559
))
5660
}
@@ -62,6 +66,7 @@ fn build_execution_plan(
6266
argv,
6367
planned_inputs,
6468
detection_mode,
69+
output_refresh_mode,
6570
execution_metadata(&analysis),
6671
))
6772
}

0 commit comments

Comments
 (0)