Skip to content

Commit 51e35ea

Browse files
wan9chiclaude
andauthored
feat(cache): store colored task logs, strip at display when needed (#378)
Fixes #358. ## Summary - Cached tasks are spawned with `FORCE_COLOR=1`, so the cache always stores task logs with ANSI colors intact. On a cache hit the original bytes are replayed verbatim — cached logs keep their colors regardless of the parent's `FORCE_COLOR` value. - Colors are stripped at display time when the user's terminal does not support them, decided per stdout/stderr via the `supports-color` crate. - `NO_COLOR`, `COLORTERM`, `TERM`, and `TERM_PROGRAM` are no longer in `DEFAULT_UNTRACKED_ENV`. Tasks that need them can opt in via `env` / `untrackedEnv`. ## Implementation notes - `EnvFingerprints::resolve` pre-inserts `FORCE_COLOR=1` before pattern filtering. `FORCE_COLOR` stays in `DEFAULT_UNTRACKED_ENV`, so the value is passed through but not part of the cache key. - A new `ColorSupport { stdout, stderr }` struct threads per-stream support through the reporter builders. `maybe_strip_writer` is applied only to the `StdioConfig.writers` returned from `LeafExecutionReporter::start` (child stdout/stderr and the grouped buffer); banners and summaries use a thin `ColorizeExt::style` extension that delegates to `OwoColorize::if_supports_color`. - e2e harness gained `formatted-snapshot = true` (per-step) and switched `screen_contents_formatted` to `vt100::Screen::rows_formatted`. Raw `\x1b[m` resets are stripped from the rendered rows so snapshots match across Linux/macOS/Windows ConPTY. ## Test plan - [x] `cargo test -p vite_task -p vite_task_plan -p vite_task_graph` - [x] `cargo test -p vite_task_bin --test e2e_snapshots` - [x] `cargo clippy --tests` on Linux - [x] `cargo doc --workspace -D warnings` - [ ] CI passes on all platforms (Linux gnu, musl, macOS x86_64/arm64, Windows) https://claude.ai/code/session_01EAhpw6TzUWExMeF7hdwuF3 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c1d6648 commit 51e35ea

52 files changed

Lines changed: 713 additions & 169 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# Changelog
22

3+
- **Changed** Cached logs are stored with colors intact (`FORCE_COLOR=1` is auto-injected into spawned tasks). Colors are then stripped at display time when the terminal does not support them. Other color-related env vars (`NO_COLOR`, `COLORTERM`, `TERM`, `TERM_PROGRAM`) are no longer passed through by default. Opt in via a task's `env`/`untrackedEnv` ([#378](https://github.com/voidzero-dev/vite-task/pull/378))
34
- **Added** `output` field for cached tasks: archives matching files after a successful run and restores them on cache hit ([#375](https://github.com/voidzero-dev/vite-task/pull/375))
45
- **Fixed** Windows cached tasks can now run package shims rewritten through PowerShell; default env passthrough now preserves `PATHEXT` ([#366](https://github.com/voidzero-dev/vite-task/pull/366))
56
- **Added** Platform support for targets without `input` auto-inference (e.g. Android). Tasks still run; those relying on auto-inference run uncached, with the summary noting that `input` must be configured manually to enable caching ([#352](https://github.com/voidzero-dev/vite-task/pull/352))

Cargo.lock

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ future_not_send = "allow"
4040

4141
[workspace.dependencies]
4242
allocator-api2 = { version = "0.2.21", default-features = false, features = ["alloc", "std"] }
43+
anstream = "0.6.21"
4344
anyhow = "1.0.98"
4445
assert2 = "0.4.0"
4546
assertables = "9.8.1"

crates/pty_terminal/src/terminal.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,53 @@ impl PtyReader {
128128
self.parser.lock().unwrap().screen().contents()
129129
}
130130

131+
/// Returns the screen contents row-by-row with inline ANSI SGR escapes
132+
/// preserved — useful for snapshot tests that need to assert colour/style.
133+
///
134+
/// Rows are produced via [`vt100::Screen::rows_formatted`], which emits
135+
/// only the SGR attribute escapes (no cursor positioning, no
136+
/// screen-erase sequences), so the output is platform-stable. Trailing
137+
/// fully-empty rows are dropped; remaining rows are joined with `\n`.
138+
///
139+
/// Bare SGR-reset sequences (`\x1b[m`) are also stripped: Unix PTYs emit
140+
/// them between styled spans and at the end of styled runs, but Windows
141+
/// `ConPTY` consolidates the byte stream and elides those resets. Stripping
142+
/// them produces identical output on all platforms while preserving the
143+
/// non-reset SGR transitions that the test actually cares about.
144+
///
145+
/// # Panics
146+
///
147+
/// Panics if the parser lock is poisoned.
148+
#[expect(
149+
clippy::significant_drop_tightening,
150+
reason = "vt100::Screen::rows_formatted yields borrowed iterators that need the guard alive"
151+
)]
152+
#[must_use]
153+
pub fn screen_contents_formatted(&self) -> Vec<u8> {
154+
const RESET: &[u8] = b"\x1b[m";
155+
let guard = self.parser.lock().unwrap();
156+
let screen = guard.screen();
157+
let cols = screen.size().1;
158+
let rows: Vec<Vec<u8>> = screen
159+
.rows_formatted(0, cols)
160+
.map(|mut row| {
161+
while let Some(idx) = row.windows(RESET.len()).position(|w| w == RESET) {
162+
row.drain(idx..idx + RESET.len());
163+
}
164+
row
165+
})
166+
.collect();
167+
let last_non_empty = rows.iter().rposition(|r| !r.is_empty()).map_or(0, |i| i + 1);
168+
let mut out = Vec::new();
169+
for (i, row) in rows[..last_non_empty].iter().enumerate() {
170+
if i > 0 {
171+
out.push(b'\n');
172+
}
173+
out.extend_from_slice(row);
174+
}
175+
out
176+
}
177+
131178
/// Drains and returns all unhandled OSC sequences received since the last call.
132179
///
133180
/// Each entry is a list of byte-vector parameters from a single OSC sequence

crates/pty_terminal_test/src/lib.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,13 @@ impl Reader {
5252
contents
5353
}
5454

55+
/// Returns the screen contents with inline ANSI SGR escape codes preserved.
56+
/// Useful for snapshot tests that need to assert colour or style attributes.
57+
#[must_use]
58+
pub fn screen_contents_formatted(&self) -> Vec<u8> {
59+
self.pty.get_ref().screen_contents_formatted()
60+
}
61+
5562
/// Reads from the PTY until a milestone with the given name is encountered.
5663
///
5764
/// Returns the terminal screen contents at the moment the milestone is detected.

crates/vite_task/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ rust-version.workspace = true
1212
workspace = true
1313

1414
[dependencies]
15+
anstream = { workspace = true }
1516
anyhow = { workspace = true }
1617
async-trait = { workspace = true }
1718
wincode = { workspace = true, features = ["derive"] }
@@ -28,6 +29,7 @@ rusqlite = { workspace = true, features = ["bundled"] }
2829
rustc-hash = { workspace = true }
2930
serde = { workspace = true, features = ["derive", "rc"] }
3031
serde_json = { workspace = true }
32+
supports-color = { workspace = true }
3133
thiserror = { workspace = true }
3234
tar = { workspace = true }
3335
tokio = { workspace = true, features = [

crates/vite_task/src/session/mod.rs

Lines changed: 59 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ use clap::Parser as _;
1212
use once_cell::sync::OnceCell;
1313
pub use reporter::ExitStatus;
1414
use reporter::{
15-
GroupedReporterBuilder, InterleavedReporterBuilder, LabeledReporterBuilder,
15+
ColorSupport, GroupedReporterBuilder, InterleavedReporterBuilder, LabeledReporterBuilder,
1616
SummaryReporterBuilder,
1717
summary::{LastRunSummary, ReadSummaryError, format_full_summary},
1818
};
@@ -313,20 +313,36 @@ impl<'a> Session<'a> {
313313
let workspace_path = self.workspace_path();
314314
let writer: Box<dyn std::io::Write> = Box::new(std::io::stdout());
315315

316-
let inner: Box<dyn reporter::GraphExecutionReporterBuilder> =
317-
match run_command.flags.log {
318-
crate::cli::LogMode::Interleaved => Box::new(
319-
InterleavedReporterBuilder::new(Arc::clone(&workspace_path), writer),
320-
),
321-
crate::cli::LogMode::Labeled => Box::new(LabeledReporterBuilder::new(
322-
Arc::clone(&workspace_path),
323-
writer,
324-
)),
325-
crate::cli::LogMode::Grouped => Box::new(GroupedReporterBuilder::new(
326-
Arc::clone(&workspace_path),
327-
writer,
328-
)),
329-
};
316+
// Detect color support once at the point where reporters are
317+
// constructed. The reporters and their pipe writers then strip
318+
// ANSI escapes from cached/replayed output if the terminal
319+
// can't render them. Detect per-stream so a redirected stdout
320+
// doesn't trigger stripping of an interactive stderr.
321+
let color_support = ColorSupport {
322+
stdout: stdout_supports_color(),
323+
stderr: stderr_supports_color(),
324+
};
325+
326+
let inner: Box<dyn reporter::GraphExecutionReporterBuilder> = match run_command
327+
.flags
328+
.log
329+
{
330+
crate::cli::LogMode::Interleaved => Box::new(InterleavedReporterBuilder::new(
331+
Arc::clone(&workspace_path),
332+
writer,
333+
color_support,
334+
)),
335+
crate::cli::LogMode::Labeled => Box::new(LabeledReporterBuilder::new(
336+
Arc::clone(&workspace_path),
337+
writer,
338+
color_support,
339+
)),
340+
crate::cli::LogMode::Grouped => Box::new(GroupedReporterBuilder::new(
341+
Arc::clone(&workspace_path),
342+
writer,
343+
color_support,
344+
)),
345+
};
330346

331347
let builder = Box::new(SummaryReporterBuilder::new(
332348
inner,
@@ -335,6 +351,7 @@ impl<'a> Session<'a> {
335351
run_command.flags.verbose,
336352
Some(self.make_summary_writer()),
337353
self.program_name.clone(),
354+
color_support,
338355
));
339356
// Don't let SIGINT/CTRL_C kill the runner. Child tasks receive
340357
// the signal directly from the terminal driver and handle it
@@ -590,6 +607,10 @@ impl<'a> Session<'a> {
590607
let path = self.summary_file_path();
591608
match LastRunSummary::read_from_path(&path) {
592609
Ok(Some(summary)) => {
610+
// `format_full_summary` decides colour vs plain text per
611+
// styled span via `ColorizeExt` (which consults
612+
// `supports-color`), so the buffer already matches the
613+
// terminal's capability and we write it to stdout directly.
593614
let buf = format_full_summary(&summary);
594615
{
595616
use std::io::Write;
@@ -668,8 +689,11 @@ impl<'a> Session<'a> {
668689
let cache = self.cache()?;
669690

670691
// Create a plain (standalone) reporter — no graph awareness, no summary
671-
let plain_reporter =
672-
reporter::PlainReporter::new(silent_if_cache_hit, Box::new(std::io::stdout()));
692+
let plain_reporter = reporter::PlainReporter::new(
693+
silent_if_cache_hit,
694+
Box::new(std::io::stdout()),
695+
ColorSupport { stdout: stdout_supports_color(), stderr: stderr_supports_color() },
696+
);
673697

674698
// Execute the spawn directly using the free function, bypassing the graph pipeline
675699
let outcome = execute::execute_spawn(
@@ -770,3 +794,21 @@ impl<'a> Session<'a> {
770794
.await
771795
}
772796
}
797+
798+
/// Whether stdout supports ANSI color output for the current process. Honors
799+
/// `NO_COLOR`/`FORCE_COLOR` and detects TTY capability via the `supports-color`
800+
/// crate. Result is cached for the process lifetime.
801+
fn stdout_supports_color() -> bool {
802+
use std::sync::OnceLock;
803+
static CACHE: OnceLock<bool> = OnceLock::new();
804+
*CACHE.get_or_init(|| supports_color::on(supports_color::Stream::Stdout).is_some())
805+
}
806+
807+
/// Whether stderr supports ANSI color output. Detected independently from
808+
/// stdout so a redirected stdout (non-TTY) does not strip ANSI from a stderr
809+
/// that is still an interactive terminal.
810+
fn stderr_supports_color() -> bool {
811+
use std::sync::OnceLock;
812+
static CACHE: OnceLock<bool> = OnceLock::new();
813+
*CACHE.get_or_init(|| supports_color::on(supports_color::Stream::Stderr).is_some())
814+
}

crates/vite_task/src/session/reporter/grouped/mod.rs

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@ use vite_path::AbsolutePath;
77
use vite_task_plan::{ExecutionItemDisplay, LeafExecutionKind};
88

99
use super::{
10-
ColorizeExt, ExitStatus, GraphExecutionReporter, GraphExecutionReporterBuilder,
10+
ColorSupport, ColorizeExt, ExitStatus, GraphExecutionReporter, GraphExecutionReporterBuilder,
1111
LeafExecutionReporter, PipeWriters, StdioConfig, StdioSuggestion,
12-
format_command_with_cache_status, format_task_label, write_leaf_trailing_output,
12+
format_command_with_cache_status, format_task_label, maybe_strip_writer,
13+
write_leaf_trailing_output,
1314
};
1415
use crate::session::event::{CacheStatus, CacheUpdateStatus, ExecutionError};
1516

@@ -20,11 +21,21 @@ use writer::GroupedWriter;
2021
pub struct GroupedReporterBuilder {
2122
workspace_path: Arc<AbsolutePath>,
2223
writer: Box<dyn Write>,
24+
color_support: ColorSupport,
2325
}
2426

2527
impl GroupedReporterBuilder {
26-
pub fn new(workspace_path: Arc<AbsolutePath>, writer: Box<dyn Write>) -> Self {
27-
Self { workspace_path, writer }
28+
/// Grouped mode buffers child output and flushes it through `writer`
29+
/// at finish time. The pipe writers themselves (see
30+
/// `LeafExecutionReporter::start`) strip ANSI on the way into the buffer,
31+
/// so by the time the buffer reaches `writer` it already matches the
32+
/// terminal's colour capability. `writer` is therefore stored unwrapped.
33+
pub fn new(
34+
workspace_path: Arc<AbsolutePath>,
35+
writer: Box<dyn Write>,
36+
color_support: ColorSupport,
37+
) -> Self {
38+
Self { workspace_path, writer, color_support }
2839
}
2940
}
3041

@@ -33,13 +44,15 @@ impl GraphExecutionReporterBuilder for GroupedReporterBuilder {
3344
Box::new(GroupedGraphReporter {
3445
writer: Rc::new(RefCell::new(self.writer)),
3546
workspace_path: self.workspace_path,
47+
color_support: self.color_support,
3648
})
3749
}
3850
}
3951

4052
struct GroupedGraphReporter {
4153
writer: Rc<RefCell<Box<dyn Write>>>,
4254
workspace_path: Arc<AbsolutePath>,
55+
color_support: ColorSupport,
4356
}
4457

4558
impl GraphExecutionReporter for GroupedGraphReporter {
@@ -56,6 +69,7 @@ impl GraphExecutionReporter for GroupedGraphReporter {
5669
label,
5770
started: false,
5871
grouped_buffer: None,
72+
color_support: self.color_support,
5973
})
6074
}
6175

@@ -73,6 +87,7 @@ struct GroupedLeafReporter {
7387
label: vite_str::Str,
7488
started: bool,
7589
grouped_buffer: Option<Rc<RefCell<Vec<u8>>>>,
90+
color_support: ColorSupport,
7691
}
7792

7893
impl LeafExecutionReporter for GroupedLeafReporter {
@@ -95,8 +110,14 @@ impl LeafExecutionReporter for GroupedLeafReporter {
95110
StdioConfig {
96111
suggestion: StdioSuggestion::Piped,
97112
writers: PipeWriters {
98-
stdout_writer: Box::new(GroupedWriter::new(Rc::clone(&buffer))),
99-
stderr_writer: Box::new(GroupedWriter::new(buffer)),
113+
stdout_writer: maybe_strip_writer(
114+
Box::new(GroupedWriter::new(Rc::clone(&buffer))),
115+
self.color_support.stdout,
116+
),
117+
stderr_writer: maybe_strip_writer(
118+
Box::new(GroupedWriter::new(buffer)),
119+
self.color_support.stderr,
120+
),
100121
},
101122
}
102123
}
@@ -152,7 +173,11 @@ mod tests {
152173
let task = spawn_task("build");
153174
let item = &task.items[0];
154175

155-
let builder = Box::new(GroupedReporterBuilder::new(test_path(), Box::new(std::io::sink())));
176+
let builder = Box::new(GroupedReporterBuilder::new(
177+
test_path(),
178+
Box::new(std::io::sink()),
179+
ColorSupport::uniform(false),
180+
));
156181
let mut reporter = builder.build();
157182
let mut leaf = reporter.new_leaf_execution(&item.execution_item_display, leaf_kind(item));
158183
let stdio_config = leaf.start(CacheStatus::Disabled(CacheDisabledReason::NoCacheMetadata));

0 commit comments

Comments
 (0)