Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Changelog

- **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))
- **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))
- **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))
- **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))
Expand Down
3 changes: 2 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ future_not_send = "allow"

[workspace.dependencies]
allocator-api2 = { version = "0.2.21", default-features = false, features = ["alloc", "std"] }
anstream = "0.6.21"
anyhow = "1.0.98"
assert2 = "0.4.0"
assertables = "9.8.1"
Expand Down
47 changes: 47 additions & 0 deletions crates/pty_terminal/src/terminal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,53 @@ impl PtyReader {
self.parser.lock().unwrap().screen().contents()
}

/// Returns the screen contents row-by-row with inline ANSI SGR escapes
/// preserved — useful for snapshot tests that need to assert colour/style.
///
/// Rows are produced via [`vt100::Screen::rows_formatted`], which emits
/// only the SGR attribute escapes (no cursor positioning, no
/// screen-erase sequences), so the output is platform-stable. Trailing
/// fully-empty rows are dropped; remaining rows are joined with `\n`.
///
/// Bare SGR-reset sequences (`\x1b[m`) are also stripped: Unix PTYs emit
/// them between styled spans and at the end of styled runs, but Windows
/// `ConPTY` consolidates the byte stream and elides those resets. Stripping
/// them produces identical output on all platforms while preserving the
/// non-reset SGR transitions that the test actually cares about.
///
/// # Panics
///
/// Panics if the parser lock is poisoned.
#[expect(
clippy::significant_drop_tightening,
reason = "vt100::Screen::rows_formatted yields borrowed iterators that need the guard alive"
)]
#[must_use]
pub fn screen_contents_formatted(&self) -> Vec<u8> {
const RESET: &[u8] = b"\x1b[m";
let guard = self.parser.lock().unwrap();
let screen = guard.screen();
let cols = screen.size().1;
let rows: Vec<Vec<u8>> = screen
.rows_formatted(0, cols)
.map(|mut row| {
while let Some(idx) = row.windows(RESET.len()).position(|w| w == RESET) {
row.drain(idx..idx + RESET.len());
}
row
})
.collect();
let last_non_empty = rows.iter().rposition(|r| !r.is_empty()).map_or(0, |i| i + 1);
let mut out = Vec::new();
for (i, row) in rows[..last_non_empty].iter().enumerate() {
if i > 0 {
out.push(b'\n');
}
out.extend_from_slice(row);
}
out
}

/// Drains and returns all unhandled OSC sequences received since the last call.
///
/// Each entry is a list of byte-vector parameters from a single OSC sequence
Expand Down
7 changes: 7 additions & 0 deletions crates/pty_terminal_test/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ impl Reader {
contents
}

/// Returns the screen contents with inline ANSI SGR escape codes preserved.
/// Useful for snapshot tests that need to assert colour or style attributes.
#[must_use]
pub fn screen_contents_formatted(&self) -> Vec<u8> {
self.pty.get_ref().screen_contents_formatted()
}

/// Reads from the PTY until a milestone with the given name is encountered.
///
/// Returns the terminal screen contents at the moment the milestone is detected.
Expand Down
2 changes: 2 additions & 0 deletions crates/vite_task/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ rust-version.workspace = true
workspace = true

[dependencies]
anstream = { workspace = true }
anyhow = { workspace = true }
async-trait = { workspace = true }
wincode = { workspace = true, features = ["derive"] }
Expand All @@ -28,6 +29,7 @@ rusqlite = { workspace = true, features = ["bundled"] }
rustc-hash = { workspace = true }
serde = { workspace = true, features = ["derive", "rc"] }
serde_json = { workspace = true }
supports-color = { workspace = true }
thiserror = { workspace = true }
tar = { workspace = true }
tokio = { workspace = true, features = [
Expand Down
76 changes: 59 additions & 17 deletions crates/vite_task/src/session/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use clap::Parser as _;
use once_cell::sync::OnceCell;
pub use reporter::ExitStatus;
use reporter::{
GroupedReporterBuilder, InterleavedReporterBuilder, LabeledReporterBuilder,
ColorSupport, GroupedReporterBuilder, InterleavedReporterBuilder, LabeledReporterBuilder,
SummaryReporterBuilder,
summary::{LastRunSummary, ReadSummaryError, format_full_summary},
};
Expand Down Expand Up @@ -313,20 +313,36 @@ impl<'a> Session<'a> {
let workspace_path = self.workspace_path();
let writer: Box<dyn std::io::Write> = Box::new(std::io::stdout());

let inner: Box<dyn reporter::GraphExecutionReporterBuilder> =
match run_command.flags.log {
crate::cli::LogMode::Interleaved => Box::new(
InterleavedReporterBuilder::new(Arc::clone(&workspace_path), writer),
),
crate::cli::LogMode::Labeled => Box::new(LabeledReporterBuilder::new(
Arc::clone(&workspace_path),
writer,
)),
crate::cli::LogMode::Grouped => Box::new(GroupedReporterBuilder::new(
Arc::clone(&workspace_path),
writer,
)),
};
// Detect color support once at the point where reporters are
// constructed. The reporters and their pipe writers then strip
// ANSI escapes from cached/replayed output if the terminal
// can't render them. Detect per-stream so a redirected stdout
// doesn't trigger stripping of an interactive stderr.
let color_support = ColorSupport {
stdout: stdout_supports_color(),
stderr: stderr_supports_color(),
};

let inner: Box<dyn reporter::GraphExecutionReporterBuilder> = match run_command
.flags
.log
{
crate::cli::LogMode::Interleaved => Box::new(InterleavedReporterBuilder::new(
Arc::clone(&workspace_path),
writer,
color_support,
)),
crate::cli::LogMode::Labeled => Box::new(LabeledReporterBuilder::new(
Arc::clone(&workspace_path),
writer,
color_support,
)),
crate::cli::LogMode::Grouped => Box::new(GroupedReporterBuilder::new(
Arc::clone(&workspace_path),
writer,
color_support,
)),
};

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

// Create a plain (standalone) reporter — no graph awareness, no summary
let plain_reporter =
reporter::PlainReporter::new(silent_if_cache_hit, Box::new(std::io::stdout()));
let plain_reporter = reporter::PlainReporter::new(
silent_if_cache_hit,
Box::new(std::io::stdout()),
ColorSupport { stdout: stdout_supports_color(), stderr: stderr_supports_color() },
);

// Execute the spawn directly using the free function, bypassing the graph pipeline
let outcome = execute::execute_spawn(
Expand Down Expand Up @@ -770,3 +794,21 @@ impl<'a> Session<'a> {
.await
}
}

/// Whether stdout supports ANSI color output for the current process. Honors
/// `NO_COLOR`/`FORCE_COLOR` and detects TTY capability via the `supports-color`
/// crate. Result is cached for the process lifetime.
fn stdout_supports_color() -> bool {
use std::sync::OnceLock;
static CACHE: OnceLock<bool> = OnceLock::new();
*CACHE.get_or_init(|| supports_color::on(supports_color::Stream::Stdout).is_some())
Comment thread
branchseer marked this conversation as resolved.
}

/// Whether stderr supports ANSI color output. Detected independently from
/// stdout so a redirected stdout (non-TTY) does not strip ANSI from a stderr
/// that is still an interactive terminal.
fn stderr_supports_color() -> bool {
use std::sync::OnceLock;
static CACHE: OnceLock<bool> = OnceLock::new();
*CACHE.get_or_init(|| supports_color::on(supports_color::Stream::Stderr).is_some())
}
39 changes: 32 additions & 7 deletions crates/vite_task/src/session/reporter/grouped/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ use vite_path::AbsolutePath;
use vite_task_plan::{ExecutionItemDisplay, LeafExecutionKind};

use super::{
ColorizeExt, ExitStatus, GraphExecutionReporter, GraphExecutionReporterBuilder,
ColorSupport, ColorizeExt, ExitStatus, GraphExecutionReporter, GraphExecutionReporterBuilder,
LeafExecutionReporter, PipeWriters, StdioConfig, StdioSuggestion,
format_command_with_cache_status, format_task_label, write_leaf_trailing_output,
format_command_with_cache_status, format_task_label, maybe_strip_writer,
write_leaf_trailing_output,
};
use crate::session::event::{CacheStatus, CacheUpdateStatus, ExecutionError};

Expand All @@ -20,11 +21,21 @@ use writer::GroupedWriter;
pub struct GroupedReporterBuilder {
workspace_path: Arc<AbsolutePath>,
writer: Box<dyn Write>,
color_support: ColorSupport,
}

impl GroupedReporterBuilder {
pub fn new(workspace_path: Arc<AbsolutePath>, writer: Box<dyn Write>) -> Self {
Self { workspace_path, writer }
/// Grouped mode buffers child output and flushes it through `writer`
/// at finish time. The pipe writers themselves (see
/// `LeafExecutionReporter::start`) strip ANSI on the way into the buffer,
/// so by the time the buffer reaches `writer` it already matches the
/// terminal's colour capability. `writer` is therefore stored unwrapped.
pub fn new(
workspace_path: Arc<AbsolutePath>,
writer: Box<dyn Write>,
color_support: ColorSupport,
) -> Self {
Self { workspace_path, writer, color_support }
}
}

Expand All @@ -33,13 +44,15 @@ impl GraphExecutionReporterBuilder for GroupedReporterBuilder {
Box::new(GroupedGraphReporter {
writer: Rc::new(RefCell::new(self.writer)),
workspace_path: self.workspace_path,
color_support: self.color_support,
})
}
}

struct GroupedGraphReporter {
writer: Rc<RefCell<Box<dyn Write>>>,
workspace_path: Arc<AbsolutePath>,
color_support: ColorSupport,
}

impl GraphExecutionReporter for GroupedGraphReporter {
Expand All @@ -56,6 +69,7 @@ impl GraphExecutionReporter for GroupedGraphReporter {
label,
started: false,
grouped_buffer: None,
color_support: self.color_support,
})
}

Expand All @@ -73,6 +87,7 @@ struct GroupedLeafReporter {
label: vite_str::Str,
started: bool,
grouped_buffer: Option<Rc<RefCell<Vec<u8>>>>,
color_support: ColorSupport,
}

impl LeafExecutionReporter for GroupedLeafReporter {
Expand All @@ -95,8 +110,14 @@ impl LeafExecutionReporter for GroupedLeafReporter {
StdioConfig {
suggestion: StdioSuggestion::Piped,
writers: PipeWriters {
stdout_writer: Box::new(GroupedWriter::new(Rc::clone(&buffer))),
stderr_writer: Box::new(GroupedWriter::new(buffer)),
stdout_writer: maybe_strip_writer(
Box::new(GroupedWriter::new(Rc::clone(&buffer))),
self.color_support.stdout,
),
stderr_writer: maybe_strip_writer(
Box::new(GroupedWriter::new(buffer)),
self.color_support.stderr,
),
},
}
}
Expand Down Expand Up @@ -152,7 +173,11 @@ mod tests {
let task = spawn_task("build");
let item = &task.items[0];

let builder = Box::new(GroupedReporterBuilder::new(test_path(), Box::new(std::io::sink())));
let builder = Box::new(GroupedReporterBuilder::new(
test_path(),
Box::new(std::io::sink()),
ColorSupport::uniform(false),
));
let mut reporter = builder.build();
let mut leaf = reporter.new_leaf_execution(&item.execution_item_display, leaf_kind(item));
let stdio_config = leaf.start(CacheStatus::Disabled(CacheDisabledReason::NoCacheMetadata));
Expand Down
Loading
Loading