Skip to content

Commit 97a94c4

Browse files
authored
feat: add pipefail handling and set command (#163)
1 parent 766ee5b commit 97a94c4

File tree

6 files changed

+161
-19
lines changed

6 files changed

+161
-19
lines changed

src/shell/commands/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ mod head;
1212
mod mkdir;
1313
mod pwd;
1414
mod rm;
15+
mod set;
1516
mod shopt;
1617
mod sleep;
1718
mod unset;
@@ -77,6 +78,10 @@ pub fn builtin_commands() -> HashMap<String, Rc<dyn ShellCommand>> {
7778
"rm".to_string(),
7879
Rc::new(rm::RmCommand) as Rc<dyn ShellCommand>,
7980
),
81+
(
82+
"set".to_string(),
83+
Rc::new(set::SetCommand) as Rc<dyn ShellCommand>,
84+
),
8085
(
8186
"shopt".to_string(),
8287
Rc::new(shopt::ShoptCommand) as Rc<dyn ShellCommand>,

src/shell/commands/set.rs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// Copyright 2018-2025 the Deno authors. MIT license.
2+
3+
use futures::future::LocalBoxFuture;
4+
5+
use crate::shell::types::EnvChange;
6+
use crate::shell::types::ExecuteResult;
7+
use crate::shell::types::ShellOptions;
8+
9+
use super::ShellCommand;
10+
use super::ShellCommandContext;
11+
12+
pub struct SetCommand;
13+
14+
impl ShellCommand for SetCommand {
15+
fn execute(
16+
&self,
17+
mut context: ShellCommandContext,
18+
) -> LocalBoxFuture<'static, ExecuteResult> {
19+
let result = execute_set(&mut context);
20+
Box::pin(futures::future::ready(result))
21+
}
22+
}
23+
24+
fn execute_set(context: &mut ShellCommandContext) -> ExecuteResult {
25+
let mut changes = Vec::new();
26+
let args: Vec<String> = context
27+
.args
28+
.iter()
29+
.filter_map(|a| a.to_str().map(|s| s.to_string()))
30+
.collect();
31+
32+
let mut i = 0;
33+
while i < args.len() {
34+
let arg = &args[i];
35+
if arg == "-o" || arg == "+o" {
36+
let enable = arg == "-o";
37+
if i + 1 < args.len() {
38+
let option_name = &args[i + 1];
39+
match option_name.as_str() {
40+
"pipefail" => {
41+
changes.push(EnvChange::SetOption(ShellOptions::PIPEFAIL, enable));
42+
}
43+
_ => {
44+
let _ = context
45+
.stderr
46+
.write_line(&format!("set: unknown option: {}", option_name));
47+
return ExecuteResult::from_exit_code(1);
48+
}
49+
}
50+
i += 2;
51+
} else {
52+
// No option name provided - in bash this would list options
53+
// For now, just return success
54+
i += 1;
55+
}
56+
} else {
57+
// Unknown argument
58+
i += 1;
59+
}
60+
}
61+
62+
ExecuteResult::Continue(0, changes, Vec::new())
63+
}

src/shell/commands/shopt.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ impl ShellCommand for ShoptCommand {
6767

6868
let changes: Vec<EnvChange> = options_to_change
6969
.into_iter()
70-
.map(|opt| EnvChange::SetShellOption(opt, enabled))
70+
.map(|opt| EnvChange::SetOption(opt, enabled))
7171
.collect();
7272

7373
ExecuteResult::Continue(0, changes, Vec::new())
@@ -77,7 +77,7 @@ impl ShellCommand for ShoptCommand {
7777
let current_options = context.state.shell_options();
7878

7979
if options_to_change.is_empty() {
80-
// print all options
80+
// print all options (alphabetical order)
8181
let _ = context.stdout.write_line(&format!(
8282
"failglob\t{}",
8383
if current_options.contains(ShellOptions::FAILGLOB) {

src/shell/execute.rs

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -576,20 +576,33 @@ async fn execute_pipe_sequence(
576576
let output_handle = tokio::task::spawn_blocking(|| {
577577
last_output.unwrap().pipe_to_sender(stdout).unwrap();
578578
});
579-
let mut results = futures::future::join_all(wait_tasks).await;
579+
let results = futures::future::join_all(wait_tasks).await;
580580
output_handle.await.unwrap();
581-
let last_result = results.pop().unwrap();
582-
let all_handles = results.into_iter().flat_map(|r| r.into_handles());
583-
match last_result {
584-
ExecuteResult::Exit(code, mut handles) => {
585-
handles.extend(all_handles);
586-
ExecuteResult::Continue(code, Vec::new(), handles)
587-
}
588-
ExecuteResult::Continue(code, _, mut handles) => {
589-
handles.extend(all_handles);
590-
ExecuteResult::Continue(code, Vec::new(), handles)
581+
582+
// Determine exit code based on pipefail option
583+
let exit_code = if state.shell_options().contains(ShellOptions::PIPEFAIL) {
584+
// With pipefail: return the rightmost non-zero exit code, or 0 if all succeeded
585+
results
586+
.iter()
587+
.rev()
588+
.find_map(|r| {
589+
let code = match r {
590+
ExecuteResult::Exit(c, _) => *c,
591+
ExecuteResult::Continue(c, _, _) => *c,
592+
};
593+
if code != 0 { Some(code) } else { None }
594+
})
595+
.unwrap_or(0)
596+
} else {
597+
// Without pipefail: return the last command's exit code
598+
match results.last().unwrap() {
599+
ExecuteResult::Exit(code, _) => *code,
600+
ExecuteResult::Continue(code, _, _) => *code,
591601
}
592-
}
602+
};
603+
604+
let all_handles = results.into_iter().flat_map(|r| r.into_handles());
605+
ExecuteResult::Continue(exit_code, Vec::new(), all_handles.collect())
593606
}
594607

595608
async fn execute_subshell(

src/shell/types.rs

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ use super::commands::ShellCommand;
2525
use super::commands::builtin_commands;
2626

2727
bitflags! {
28-
/// Shell options that can be set via `shopt`.
28+
/// Shell options that can be set via `shopt` or `set -o`.
2929
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3030
pub struct ShellOptions: u32 {
3131
/// When set, a glob pattern that matches no files expands to nothing
@@ -35,6 +35,9 @@ bitflags! {
3535
/// This is the default for deno_task_shell (differs from bash).
3636
/// When unset, unmatched globs are passed through literally (bash default).
3737
const FAILGLOB = 1 << 1;
38+
/// When set, pipeline exit code is the rightmost non-zero exit code.
39+
/// Set via `set -o pipefail`.
40+
const PIPEFAIL = 1 << 2;
3841
}
3942
}
4043

@@ -79,7 +82,7 @@ pub struct ShellState {
7982
kill_signal: KillSignal,
8083
process_tracker: ChildProcessTracker,
8184
tree_exit_code_cell: TreeExitCodeCell,
82-
/// Shell options set via `shopt`.
85+
/// Shell options set via `shopt` or `set -o`.
8386
shell_options: ShellOptions,
8487
}
8588

@@ -163,7 +166,7 @@ impl ShellState {
163166
EnvChange::Cd(new_dir) => {
164167
self.set_cwd(new_dir.clone());
165168
}
166-
EnvChange::SetShellOption(option, enabled) => {
169+
EnvChange::SetOption(option, enabled) => {
167170
self.set_shell_option(*option, *enabled);
168171
}
169172
}
@@ -262,8 +265,8 @@ pub enum EnvChange {
262265
// `unset ENV_VAR`
263266
UnsetVar(OsString),
264267
Cd(PathBuf),
265-
// `shopt -s/-u option`
266-
SetShellOption(ShellOptions, bool),
268+
// `shopt -s/-u option` or `set -o option`
269+
SetOption(ShellOptions, bool),
267270
}
268271

269272
pub type FutureExecuteResult = LocalBoxFuture<'static, ExecuteResult>;

tests/integration_test.rs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1827,3 +1827,61 @@ async fn shopt_failglob() {
18271827
.run()
18281828
.await;
18291829
}
1830+
1831+
#[tokio::test]
1832+
async fn pipefail_option() {
1833+
// Without pipefail: exit code is from last command (0)
1834+
TestBuilder::new()
1835+
.command("sh -c 'exit 1' | true")
1836+
.assert_exit_code(0)
1837+
.run()
1838+
.await;
1839+
1840+
// With pipefail: exit code is rightmost non-zero (1)
1841+
TestBuilder::new()
1842+
.command("set -o pipefail && sh -c 'exit 1' | true")
1843+
.assert_exit_code(1)
1844+
.run()
1845+
.await;
1846+
1847+
// Multiple failures - should return rightmost non-zero
1848+
TestBuilder::new()
1849+
.command("set -o pipefail && sh -c 'exit 2' | sh -c 'exit 3' | true")
1850+
.assert_exit_code(3)
1851+
.run()
1852+
.await;
1853+
1854+
// All succeed - should return 0
1855+
TestBuilder::new()
1856+
.command("set -o pipefail && true | true | true")
1857+
.assert_exit_code(0)
1858+
.run()
1859+
.await;
1860+
1861+
// Disable pipefail with +o
1862+
TestBuilder::new()
1863+
.command("set -o pipefail && set +o pipefail && sh -c 'exit 1' | true")
1864+
.assert_exit_code(0)
1865+
.run()
1866+
.await;
1867+
1868+
// invalid option name
1869+
TestBuilder::new()
1870+
.command("set -o invalidopt")
1871+
.assert_stderr("set: unknown option: invalidopt\n")
1872+
.assert_exit_code(1)
1873+
.run()
1874+
.await;
1875+
}
1876+
1877+
#[tokio::test]
1878+
#[cfg(unix)]
1879+
async fn pipefail_with_sigpipe() {
1880+
// With pipefail and SIGPIPE: should return 141 (128 + 13)
1881+
TestBuilder::new()
1882+
.command("set -o pipefail && yes | head -n 1")
1883+
.assert_stdout("y\n")
1884+
.assert_exit_code(141)
1885+
.run()
1886+
.await;
1887+
}

0 commit comments

Comments
 (0)