Skip to content

Commit 18d090b

Browse files
authored
feat: configurable globstar shell option (#166)
1 parent 174c6bd commit 18d090b

File tree

4 files changed

+111
-12
lines changed

4 files changed

+111
-12
lines changed

src/shell/commands/shopt.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,14 @@ impl ShellCommand for ShoptCommand {
8686
"off"
8787
}
8888
));
89+
let _ = context.stdout.write_line(&format!(
90+
"globstar\t{}",
91+
if current_options.contains(ShellOptions::GLOBSTAR) {
92+
"on"
93+
} else {
94+
"off"
95+
}
96+
));
8997
let _ = context.stdout.write_line(&format!(
9098
"nullglob\t{}",
9199
if current_options.contains(ShellOptions::NULLGLOB) {
@@ -122,6 +130,7 @@ fn parse_option_name(name: &str) -> Option<ShellOptions> {
122130
match name {
123131
"nullglob" => Some(ShellOptions::NULLGLOB),
124132
"failglob" => Some(ShellOptions::FAILGLOB),
133+
"globstar" => Some(ShellOptions::GLOBSTAR),
125134
_ => None,
126135
}
127136
}
@@ -131,6 +140,8 @@ fn option_to_name(opt: ShellOptions) -> &'static str {
131140
"nullglob"
132141
} else if opt == ShellOptions::FAILGLOB {
133142
"failglob"
143+
} else if opt == ShellOptions::GLOBSTAR {
144+
"globstar"
134145
} else {
135146
"unknown"
136147
}

src/shell/execute.rs

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// Copyright 2018-2025 the Deno authors. MIT license.
22

3+
use std::borrow::Cow;
34
use std::collections::HashMap;
45
use std::ffi::OsStr;
56
use std::ffi::OsString;
@@ -858,11 +859,29 @@ fn evaluate_word_parts(
858859
}
859860
let is_absolute = Path::new(&current_text).is_absolute();
860861
let cwd = state.cwd();
862+
let options = state.shell_options();
863+
864+
// when globstar is disabled, replace ** with * so it doesn't match
865+
// across directory boundaries (the glob crate always treats ** as recursive)
866+
let pattern_text: Cow<str> = if !options.contains(ShellOptions::GLOBSTAR)
867+
&& current_text.contains("**")
868+
{
869+
// repeatedly replace ** with * to handle cases like *** -> ** -> *
870+
let mut result = current_text.clone();
871+
while result.contains("**") {
872+
result = result.replace("**", "*");
873+
}
874+
Cow::Owned(result)
875+
} else {
876+
Cow::Borrowed(&current_text)
877+
};
878+
861879
let pattern = if is_absolute {
862-
current_text.clone()
880+
pattern_text.into_owned()
863881
} else {
864-
format!("{}/{}", cwd.display(), current_text)
882+
format!("{}/{}", cwd.display(), pattern_text)
865883
};
884+
866885
let result = glob::glob_with(
867886
&pattern,
868887
glob::MatchOptions {
@@ -879,7 +898,6 @@ fn evaluate_word_parts(
879898
let paths =
880899
paths.into_iter().filter_map(|p| p.ok()).collect::<Vec<_>>();
881900
if paths.is_empty() {
882-
let options = state.shell_options();
883901
// failglob - error when set
884902
if options.contains(ShellOptions::FAILGLOB) {
885903
Err(EvaluateWordTextError::NoFilesMatched { pattern })

src/shell/types.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,15 @@ bitflags! {
3838
/// When set, pipeline exit code is the rightmost non-zero exit code.
3939
/// Set via `set -o pipefail`.
4040
const PIPEFAIL = 1 << 2;
41+
/// When set, the pattern `**` used in a pathname expansion context will
42+
/// match all files and zero or more directories and subdirectories.
43+
const GLOBSTAR = 1 << 3;
4144
}
4245
}
4346

4447
impl Default for ShellOptions {
4548
fn default() -> Self {
46-
// failglob is on by default to preserve existing deno_task_shell behavior
47-
ShellOptions::FAILGLOB
49+
ShellOptions::FAILGLOB.union(ShellOptions::GLOBSTAR)
4850
}
4951
}
5052

tests/integration_test.rs

Lines changed: 75 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1412,6 +1412,7 @@ async fn glob_basic() {
14121412
.run()
14131413
.await;
14141414

1415+
// ** matches recursively (globstar on by default)
14151416
TestBuilder::new()
14161417
.directory("sub_dir/sub")
14171418
.file("sub_dir/sub/1.txt", "1\n")
@@ -1742,10 +1743,10 @@ async fn sigpipe_from_pipeline() {
17421743

17431744
#[tokio::test]
17441745
async fn shopt() {
1745-
// query all options (default: failglob on, nullglob off)
1746+
// query all options (default: failglob on, globstar on, nullglob off)
17461747
TestBuilder::new()
17471748
.command("shopt")
1748-
.assert_stdout("failglob\ton\nnullglob\toff\n")
1749+
.assert_stdout("failglob\ton\nglobstar\ton\nnullglob\toff\n")
17491750
.run()
17501751
.await;
17511752

@@ -1807,7 +1808,23 @@ async fn shopt() {
18071808
// multiple options
18081809
TestBuilder::new()
18091810
.command("shopt -s nullglob && shopt -u failglob && shopt")
1810-
.assert_stdout("failglob\toff\nnullglob\ton\n")
1811+
.assert_stdout("failglob\toff\nglobstar\ton\nnullglob\ton\n")
1812+
.run()
1813+
.await;
1814+
1815+
// query globstar option (on by default)
1816+
TestBuilder::new()
1817+
.command("shopt globstar")
1818+
.assert_stdout("globstar\ton\n")
1819+
.assert_exit_code(0) // returns 0 when option is on
1820+
.run()
1821+
.await;
1822+
1823+
// disable globstar
1824+
TestBuilder::new()
1825+
.command("shopt -u globstar && shopt globstar")
1826+
.assert_stdout("globstar\toff\n")
1827+
.assert_exit_code(1)
18111828
.run()
18121829
.await;
18131830
}
@@ -1890,25 +1907,76 @@ async fn shopt_failglob() {
18901907
.await;
18911908
}
18921909

1910+
#[tokio::test]
1911+
async fn shopt_globstar() {
1912+
// globstar on by default: ** matches zero or more directories
1913+
// use cat to verify all files are matched (content order verifies glob worked)
1914+
TestBuilder::new()
1915+
.directory("sub/deep")
1916+
.file("a.txt", "a\n")
1917+
.file("sub/b.txt", "b\n")
1918+
.file("sub/deep/c.txt", "c\n")
1919+
.command("cat **/*.txt")
1920+
.assert_stdout("a\nb\nc\n")
1921+
.assert_exit_code(0)
1922+
.run()
1923+
.await;
1924+
1925+
// single * still behaves normally even with globstar enabled
1926+
TestBuilder::new()
1927+
.directory("sub")
1928+
.file("a.txt", "a\n")
1929+
.file("sub/b.txt", "b\n")
1930+
.command("echo *.txt")
1931+
.assert_stdout("a.txt\n")
1932+
.assert_exit_code(0)
1933+
.run()
1934+
.await;
1935+
1936+
// disabling globstar: ** behaves like * (single-segment match)
1937+
TestBuilder::new()
1938+
.directory("sub/deep")
1939+
.file("a.txt", "a\n")
1940+
.file("sub/b.txt", "b\n")
1941+
.file("sub/deep/c.txt", "c\n")
1942+
.command("shopt -u globstar && cat **/*.txt")
1943+
.assert_stdout("b\n") // only matches one level deep (sub/b.txt)
1944+
.assert_exit_code(0)
1945+
.run()
1946+
.await;
1947+
1948+
// re-enabling globstar restores recursive behavior
1949+
TestBuilder::new()
1950+
.directory("sub/deep")
1951+
.file("a.txt", "a\n")
1952+
.file("sub/b.txt", "b\n")
1953+
.file("sub/deep/c.txt", "c\n")
1954+
.command("shopt -u globstar && shopt -s globstar && cat **/*.txt")
1955+
.assert_stdout("a\nb\nc\n")
1956+
.assert_exit_code(0)
1957+
.run()
1958+
.await;
1959+
}
1960+
18931961
#[tokio::test]
18941962
async fn pipefail_option() {
18951963
// Without pipefail: exit code is from last command (0)
18961964
TestBuilder::new()
1897-
.command("sh -c 'exit 1' | true")
1965+
.command("(exit 1) | true")
18981966
.assert_exit_code(0)
18991967
.run()
19001968
.await;
19011969

19021970
// With pipefail: exit code is rightmost non-zero (1)
19031971
TestBuilder::new()
1904-
.command("set -o pipefail && sh -c 'exit 1' | true")
1972+
.command("set -o pipefail && (exit 1) | true")
19051973
.assert_exit_code(1)
19061974
.run()
19071975
.await;
19081976

19091977
// Multiple failures - should return rightmost non-zero
19101978
TestBuilder::new()
1911-
.command("set -o pipefail && sh -c 'exit 2' | sh -c 'exit 3' | true")
1979+
.command("set -o pipefail && (exit 2) | (exit 3) | true")
19121980
.assert_exit_code(3)
19131981
.run()
19141982
.await;
@@ -1922,7 +1990,7 @@ async fn pipefail_option() {
19221990

19231991
// Disable pipefail with +o
19241992
TestBuilder::new()
1925-
.command("set -o pipefail && set +o pipefail && sh -c 'exit 1' | true")
1993+
.command("set -o pipefail && set +o pipefail && (exit 1) | true")
19261994
.assert_exit_code(0)
19271995
.run()
19281996
.await;

0 commit comments

Comments
 (0)