在 bash shell 脚本中从 glob 目录内的命令行执行命令
Perform command from command line inside directories from glob in bash shell script
在 bash shell 脚本中 do-for.sh
我想在使用 bash 的 glob 中命名的所有目录中执行命令。这已经回答了很多次,但我想在命令行上提供命令本身。换句话说,假设我有目录:
foo
bar
我要进入
do-for * pwd
并让 bash 打印工作目录 inside foo
然后 inside bar
.
看了网上无数的答案,我觉得我可以这样做:
for dir in ; do
pushd ${dir}
popd
done
显然,虽然 glob *
被扩展到其他命令行参数变量中!所以第一次通过循环时,对于
我期望 foo pwd
但是我得到的却是 foo bar
!
如何防止命令行上的 glob 扩展到其他参数?或者有更好的方法来解决这个问题吗?
为了更清楚地说明这一点,下面是我要如何使用批处理文件。 (顺便说一句,这在 Windows 批处理文件版本上工作正常。)
./do-for.sh repo-* git commit -a -m "Added new files."
在bash中你可以执行"set -o noglob"这将禁止shell扩展路径名(globs)。但这必须在执行脚本之前在 运行 shell 上设置,否则你应该引用你在参数中提供的任何元字符。
find-while-read
组合是解析文件名最安全的组合之一。做如下的事情
#!/bin/bash
myfunc(){
cd ""
eval "" # Execute the command parsed as an argument
}
cur_dir=$(pwd) # storing the current directory
find . -type d -print0 | while read -rd '' dname
do
myfunc "pwd" "$dname"
cd "$cur_dir" #Remember myfunc changes the current working dir, so you need this
done
在这种情况下,问题不在于元字符的扩展,只是您的脚本具有未定义数量的参数,其中最后一个参数是对所有先前参数执行的命令。
#!/bin/bash
CMND=$(eval echo "${$#}") # get the command as last argument without arguments or
while [[ $# -gt 1 ]]; do # execute loop for each argument except last one
( cd "" && eval "$CMND" ) # switch to each directory received and execute the command
shift # throw away 1st arg and move to the next one in line
done
用法:./script.sh * pwd
或 ./script.sh * "ls -l"
要让命令后跟参数(例如 ./script.sh * ls -l),脚本必须更长,因为必须测试每个参数是否是目录,直到识别命令为止(或向后直到确定目录)。
这是一个接受语法的替代脚本:./script.sh <dirs...> <command> <arguments...>
例如:./script.sh * ls -la
# Move all dirs from args to DIRS array
typeset -i COUNT=0
while [[ $# -gt 1 ]]; do
[[ -d "" ]] && DIRS[COUNT++]="" && shift || break
done
# Validate that the command received is valid
which "" >/dev/null 2>&1 || { echo "invalid command: "; exit 1; }
# Execute the command + it's arguments for each dir from array
for D in "${DIRS[@]}"; do
( cd "$D" && eval "$@" )
done
我会这样做:
#!/bin/bash
# Read directory arguments into dirs array
for arg in "$@"; do
if [[ -d $arg ]]; then
dirs+=("$arg")
else
break
fi
done
# Remove directories from arguments
shift ${#dirs[@]}
cur_dir=$PWD
# Loop through directories and execute command
for dir in "${dirs[@]}"; do
cd "$dir"
"$@"
cd "$cur_dir"
done
这会遍历扩展后看到的参数,只要它们是目录,它们就会被添加到 dirs
数组中。一旦遇到第一个 non-directory 参数,我们假设命令现在开始。
然后使用 shift
从参数中删除目录,我们将当前目录存储在 cur_dir
。
最后一个循环访问每个目录并执行由其余参数组成的命令。
这适用于您的
./do-for.sh repo-* git commit -a -m "Added new files."
示例 – 但如果 repo-*
扩展到目录以外的任何内容,脚本就会中断,因为它将尝试将文件名作为命令的一部分执行。
如果 glob 和命令由 --
等指示符分隔,则可以使它更稳定,但是如果您 知道 glob 将永远只是目录,这应该有效。
我假设您对必须提供某种分隔符的用户开放,就像这样
./do-for.sh repo-* -- git commit -a -m "Added new files."
你的脚本可以做类似的事情(这只是为了解释这个概念,我没有测试实际的代码):
CURRENT_DIR="$PWD"
declare -a FILES=()
for ARG in "$@"
do
[[ "$ARG" != "--" ]] || break
FILES+=("$ARG")
shift
done
if
[[ "${1-}" = "--" ]]
then
shift
else
echo "You must terminate the file list with -- to separate it from the command"
(return, exit, whatever you prefer to stop the script/function)
fi
此时,所有目标文件都在一个数组中,“$@”只包含要执行的命令。剩下要做的就是:
for FILE in "${FILES[@]-}"
do
cd "$FILE"
"$@"
cd "$CURRENT_DIR"
done
请注意,此解决方案的优点是,如果您的用户忘记了“--”分隔符,她将收到通知(而不是由于引用而失败)。
为什么不保持简单并创建一个使用 find
的 shell 函数,同时减轻用户输入命令的负担,例如:
do_for() { find . -type d \( ! -name . \) -not -path '*/\.*' -name -exec bash -c "cd '{}' && "${@:2}" " \; }
所以他们可以输入 do_for repo-* git commit -a -m "Added new files."
注意,如果你想单独使用 *,你必须转义它:
do_for \* pwd
通配符在传递给任何程序或脚本之前由 shell 评估。你对此无能为力。
但是如果您接受引用 globbing 表达式,那么这个脚本应该可以解决问题
#!/usr/bin/env bash
for dir in ; do (
cd "$dir"
"${@:2}"
) done
我用两个测试目录进行了尝试,它似乎可以正常工作。像这样使用它:
mkdir test_dir1 test_dir2
./do-for.sh "test_dir*" git init
./do-for.sh "test_dir*" touch test_file
./do-for.sh "test_dir*" git add .
./do-for.sh "test_dir*" git status
./do-for.sh "test_dir*" git commit -m "Added new files."
没有人提出使用 find
的解决方案?为什么不尝试这样的事情:
find . -type d \( -wholename 'YOURPATTERN' \) -print0 | xargs -0 YOURCOMMAND
查看 man find
了解更多选项。
我将从您提到过两次的 Windows 批处理文件开始。最大的区别在于 Windows 上的 shell 不进行任何通配,将其留给各种命令(并且每个命令都不同),而 Linux/Unix 上的通配通常由 shell 完成,可以通过引用或转义来防止。 Windows 方法和 Linux 方法都有其优点,并且它们在不同的用例中进行了不同的比较。
对于普通 bash 用户,引用
./do-for.sh repo-'*' git commit -a -m "Added new files."
或转义
./do-for.sh repo-\* git commit -a -m "Added new files."
是最简单的解决方案,因为它们是他们每天一贯使用的解决方案。如果您的用户需要不同的语法,您已经拥有迄今为止提出的所有解决方案,在提出我自己的解决方案之前,我将把它们分为四类(请注意,在下面的每个示例中,do-for.sh
代表 不同的 脚本采用相应的解决方案,可以在其他答案之一中找到。)
禁用 shell 通配。这很笨拙,因为即使您记得哪个 shell 选项执行此操作,您也必须记住将其重置为默认值以使 shell 之后正常工作。
使用分隔符:
./do-for.sh repo-* -- git commit -a -m "Added new files."
这行得通,类似于其他 shell 命令在类似情况下采用的解决方案,并且仅当目录名扩展包含与分隔符完全相同的目录名时才会失败(不太可能发生的事件,这在上面的例子中不会发生,但一般情况下可能会发生。)
将命令作为最后一个参数,其余均为目录:
./do-for.sh repo-* 'git commit -a -m "Added new files."'
这行得通,但同样,它涉及引用,甚至可能是嵌套的,与更常见的通配字符引用相比,更喜欢它是没有意义的。
自作聪明:
./do-for.sh repo-* git commit -a -m "Added new files."
并考虑处理目录,直到您找到一个不是目录的名称。这在很多情况下都有效,但可能会以一些不明确的方式失败(例如,当你有一个像命令一样命名的目录时)。
我的解决方案不属于上述任何类别。事实上,我的建议是不要在脚本的第一个参数中使用 *
作为通配符。 (这类似于 split
命令使用的语法,您在其中为要生成的文件提供 non-globbed 前缀参数。)我有两个版本(下面的代码)。对于第一个版本,您将执行以下操作:
# repo- is a prefix: the command will be excuted in all
# subdirectories whose name starts with it
./do-for.sh repo- git commit -a -m "Added new files."
# The command will be excuted in all subdirectories
# of the current one
./do-for.sh . git commit -a -m "Added new files."
# If you want the command to be executed in exactly
# one subdirectory with no globbing at all,
# '/' can be used as a 'stop character'. But why
# use do-for.sh in this case?
./do-for.sh repo/ git commit -a -m "Added new files."
# Use '.' to disable the stop character.
# The command will be excuted in all subdirectories of the
# given one (paths have to be always relative, though)
./do-for.sh repos/. git commit -a -m "Added new files."
第二个版本涉及使用 shell 不知道的通配符,例如 SQL 的 %
字符
# the command will be excuted in all subdirectories
# matching the SQL glob
./do-for.sh repo-% git commit -a -m "Added new files."
./do-for.sh user-%-repo git commit -a -m "Added new files."
./do-for.sh % git commit -a -m "Added new files."
第二个版本更灵活,因为它允许 non-final glob,但对于 bash 世界来说不太标准。
代码如下:
#!/bin/bash
if [ "$#" -lt 2 ]; then
echo "Usage: ${0##*/} PREFIX command..." >&2
exit 1
fi
pathPrefix=""
shift
### For second version, comment out the following five lines
case "$pathPrefix" in
(*/) pathPrefix="${pathPrefix%/}" ;; # Stop character, remove it
(*.) pathPrefix="${pathPrefix%.}*" ;; # Replace final dot with glob
(*) pathPrefix+=\* ;; # Add a final glob
esac
### For second version, uncomment the following line
# pathPrefix="${pathPrefix//%/*}" # Add a final glob
tmp=${pathPrefix//[^\/]} # Count how many levels down we have to go
maxDepth=$((1+${#tmp}))
# Please note that this won’t work if matched directory names
# contain newline characters (comment added for those bash freaks who
# care about extreme cases)
declare -a directories=()
while read d; do
directories+=("$d")
done < <(find . -maxdepth "$maxDepth" -path ./"$pathPrefix" -type d -print)
curDir="$(pwd)"
for d in "${directories[@]}"; do
cd "$d";
"$@"
cd "$curDir"
done
与 Windows 一样,如果前缀包含空格,您仍然需要使用引号
./do-for.sh 'repository for project' git commit -a -m "Added new files."
(但如果前缀不包含空格,您可以避免引用它,它将正确处理任何以该前缀开头的 space-containing 目录名称;有明显的变化,同样适用于 %-第二个版本中的模式。)
请注意 Windows 和 Linux 环境之间的其他相关差异,例如路径名区分大小写、特殊字符的差异等。
在 bash shell 脚本中 do-for.sh
我想在使用 bash 的 glob 中命名的所有目录中执行命令。这已经回答了很多次,但我想在命令行上提供命令本身。换句话说,假设我有目录:
foo
bar
我要进入
do-for * pwd
并让 bash 打印工作目录 inside foo
然后 inside bar
.
看了网上无数的答案,我觉得我可以这样做:
for dir in ; do
pushd ${dir}
popd
done
显然,虽然 glob *
被扩展到其他命令行参数变量中!所以第一次通过循环时,对于
我期望 foo pwd
但是我得到的却是 foo bar
!
如何防止命令行上的 glob 扩展到其他参数?或者有更好的方法来解决这个问题吗?
为了更清楚地说明这一点,下面是我要如何使用批处理文件。 (顺便说一句,这在 Windows 批处理文件版本上工作正常。)
./do-for.sh repo-* git commit -a -m "Added new files."
在bash中你可以执行"set -o noglob"这将禁止shell扩展路径名(globs)。但这必须在执行脚本之前在 运行 shell 上设置,否则你应该引用你在参数中提供的任何元字符。
find-while-read
组合是解析文件名最安全的组合之一。做如下的事情
#!/bin/bash
myfunc(){
cd ""
eval "" # Execute the command parsed as an argument
}
cur_dir=$(pwd) # storing the current directory
find . -type d -print0 | while read -rd '' dname
do
myfunc "pwd" "$dname"
cd "$cur_dir" #Remember myfunc changes the current working dir, so you need this
done
在这种情况下,问题不在于元字符的扩展,只是您的脚本具有未定义数量的参数,其中最后一个参数是对所有先前参数执行的命令。
#!/bin/bash
CMND=$(eval echo "${$#}") # get the command as last argument without arguments or
while [[ $# -gt 1 ]]; do # execute loop for each argument except last one
( cd "" && eval "$CMND" ) # switch to each directory received and execute the command
shift # throw away 1st arg and move to the next one in line
done
用法:./script.sh * pwd
或 ./script.sh * "ls -l"
要让命令后跟参数(例如 ./script.sh * ls -l),脚本必须更长,因为必须测试每个参数是否是目录,直到识别命令为止(或向后直到确定目录)。
这是一个接受语法的替代脚本:./script.sh <dirs...> <command> <arguments...>
例如:./script.sh * ls -la
# Move all dirs from args to DIRS array
typeset -i COUNT=0
while [[ $# -gt 1 ]]; do
[[ -d "" ]] && DIRS[COUNT++]="" && shift || break
done
# Validate that the command received is valid
which "" >/dev/null 2>&1 || { echo "invalid command: "; exit 1; }
# Execute the command + it's arguments for each dir from array
for D in "${DIRS[@]}"; do
( cd "$D" && eval "$@" )
done
我会这样做:
#!/bin/bash
# Read directory arguments into dirs array
for arg in "$@"; do
if [[ -d $arg ]]; then
dirs+=("$arg")
else
break
fi
done
# Remove directories from arguments
shift ${#dirs[@]}
cur_dir=$PWD
# Loop through directories and execute command
for dir in "${dirs[@]}"; do
cd "$dir"
"$@"
cd "$cur_dir"
done
这会遍历扩展后看到的参数,只要它们是目录,它们就会被添加到 dirs
数组中。一旦遇到第一个 non-directory 参数,我们假设命令现在开始。
然后使用 shift
从参数中删除目录,我们将当前目录存储在 cur_dir
。
最后一个循环访问每个目录并执行由其余参数组成的命令。
这适用于您的
./do-for.sh repo-* git commit -a -m "Added new files."
示例 – 但如果 repo-*
扩展到目录以外的任何内容,脚本就会中断,因为它将尝试将文件名作为命令的一部分执行。
如果 glob 和命令由 --
等指示符分隔,则可以使它更稳定,但是如果您 知道 glob 将永远只是目录,这应该有效。
我假设您对必须提供某种分隔符的用户开放,就像这样
./do-for.sh repo-* -- git commit -a -m "Added new files."
你的脚本可以做类似的事情(这只是为了解释这个概念,我没有测试实际的代码):
CURRENT_DIR="$PWD"
declare -a FILES=()
for ARG in "$@"
do
[[ "$ARG" != "--" ]] || break
FILES+=("$ARG")
shift
done
if
[[ "${1-}" = "--" ]]
then
shift
else
echo "You must terminate the file list with -- to separate it from the command"
(return, exit, whatever you prefer to stop the script/function)
fi
此时,所有目标文件都在一个数组中,“$@”只包含要执行的命令。剩下要做的就是:
for FILE in "${FILES[@]-}"
do
cd "$FILE"
"$@"
cd "$CURRENT_DIR"
done
请注意,此解决方案的优点是,如果您的用户忘记了“--”分隔符,她将收到通知(而不是由于引用而失败)。
为什么不保持简单并创建一个使用 find
的 shell 函数,同时减轻用户输入命令的负担,例如:
do_for() { find . -type d \( ! -name . \) -not -path '*/\.*' -name -exec bash -c "cd '{}' && "${@:2}" " \; }
所以他们可以输入 do_for repo-* git commit -a -m "Added new files."
注意,如果你想单独使用 *,你必须转义它:
do_for \* pwd
通配符在传递给任何程序或脚本之前由 shell 评估。你对此无能为力。
但是如果您接受引用 globbing 表达式,那么这个脚本应该可以解决问题
#!/usr/bin/env bash
for dir in ; do (
cd "$dir"
"${@:2}"
) done
我用两个测试目录进行了尝试,它似乎可以正常工作。像这样使用它:
mkdir test_dir1 test_dir2
./do-for.sh "test_dir*" git init
./do-for.sh "test_dir*" touch test_file
./do-for.sh "test_dir*" git add .
./do-for.sh "test_dir*" git status
./do-for.sh "test_dir*" git commit -m "Added new files."
没有人提出使用 find
的解决方案?为什么不尝试这样的事情:
find . -type d \( -wholename 'YOURPATTERN' \) -print0 | xargs -0 YOURCOMMAND
查看 man find
了解更多选项。
我将从您提到过两次的 Windows 批处理文件开始。最大的区别在于 Windows 上的 shell 不进行任何通配,将其留给各种命令(并且每个命令都不同),而 Linux/Unix 上的通配通常由 shell 完成,可以通过引用或转义来防止。 Windows 方法和 Linux 方法都有其优点,并且它们在不同的用例中进行了不同的比较。
对于普通 bash 用户,引用
./do-for.sh repo-'*' git commit -a -m "Added new files."
或转义
./do-for.sh repo-\* git commit -a -m "Added new files."
是最简单的解决方案,因为它们是他们每天一贯使用的解决方案。如果您的用户需要不同的语法,您已经拥有迄今为止提出的所有解决方案,在提出我自己的解决方案之前,我将把它们分为四类(请注意,在下面的每个示例中,do-for.sh
代表 不同的 脚本采用相应的解决方案,可以在其他答案之一中找到。)
禁用 shell 通配。这很笨拙,因为即使您记得哪个 shell 选项执行此操作,您也必须记住将其重置为默认值以使 shell 之后正常工作。
使用分隔符:
./do-for.sh repo-* -- git commit -a -m "Added new files."
这行得通,类似于其他 shell 命令在类似情况下采用的解决方案,并且仅当目录名扩展包含与分隔符完全相同的目录名时才会失败(不太可能发生的事件,这在上面的例子中不会发生,但一般情况下可能会发生。)
将命令作为最后一个参数,其余均为目录:
./do-for.sh repo-* 'git commit -a -m "Added new files."'
这行得通,但同样,它涉及引用,甚至可能是嵌套的,与更常见的通配字符引用相比,更喜欢它是没有意义的。
自作聪明:
./do-for.sh repo-* git commit -a -m "Added new files."
并考虑处理目录,直到您找到一个不是目录的名称。这在很多情况下都有效,但可能会以一些不明确的方式失败(例如,当你有一个像命令一样命名的目录时)。
我的解决方案不属于上述任何类别。事实上,我的建议是不要在脚本的第一个参数中使用 *
作为通配符。 (这类似于 split
命令使用的语法,您在其中为要生成的文件提供 non-globbed 前缀参数。)我有两个版本(下面的代码)。对于第一个版本,您将执行以下操作:
# repo- is a prefix: the command will be excuted in all
# subdirectories whose name starts with it
./do-for.sh repo- git commit -a -m "Added new files."
# The command will be excuted in all subdirectories
# of the current one
./do-for.sh . git commit -a -m "Added new files."
# If you want the command to be executed in exactly
# one subdirectory with no globbing at all,
# '/' can be used as a 'stop character'. But why
# use do-for.sh in this case?
./do-for.sh repo/ git commit -a -m "Added new files."
# Use '.' to disable the stop character.
# The command will be excuted in all subdirectories of the
# given one (paths have to be always relative, though)
./do-for.sh repos/. git commit -a -m "Added new files."
第二个版本涉及使用 shell 不知道的通配符,例如 SQL 的 %
字符
# the command will be excuted in all subdirectories
# matching the SQL glob
./do-for.sh repo-% git commit -a -m "Added new files."
./do-for.sh user-%-repo git commit -a -m "Added new files."
./do-for.sh % git commit -a -m "Added new files."
第二个版本更灵活,因为它允许 non-final glob,但对于 bash 世界来说不太标准。
代码如下:
#!/bin/bash
if [ "$#" -lt 2 ]; then
echo "Usage: ${0##*/} PREFIX command..." >&2
exit 1
fi
pathPrefix=""
shift
### For second version, comment out the following five lines
case "$pathPrefix" in
(*/) pathPrefix="${pathPrefix%/}" ;; # Stop character, remove it
(*.) pathPrefix="${pathPrefix%.}*" ;; # Replace final dot with glob
(*) pathPrefix+=\* ;; # Add a final glob
esac
### For second version, uncomment the following line
# pathPrefix="${pathPrefix//%/*}" # Add a final glob
tmp=${pathPrefix//[^\/]} # Count how many levels down we have to go
maxDepth=$((1+${#tmp}))
# Please note that this won’t work if matched directory names
# contain newline characters (comment added for those bash freaks who
# care about extreme cases)
declare -a directories=()
while read d; do
directories+=("$d")
done < <(find . -maxdepth "$maxDepth" -path ./"$pathPrefix" -type d -print)
curDir="$(pwd)"
for d in "${directories[@]}"; do
cd "$d";
"$@"
cd "$curDir"
done
与 Windows 一样,如果前缀包含空格,您仍然需要使用引号
./do-for.sh 'repository for project' git commit -a -m "Added new files."
(但如果前缀不包含空格,您可以避免引用它,它将正确处理任何以该前缀开头的 space-containing 目录名称;有明显的变化,同样适用于 %-第二个版本中的模式。)
请注意 Windows 和 Linux 环境之间的其他相关差异,例如路径名区分大小写、特殊字符的差异等。