【问题标题】:Perform command from command line inside directories from glob in bash shell script从 bash shell 脚本中 glob 的目录内的命令行执行命令
【发布时间】:2017-05-02 19:51:35
【问题描述】:

在 bash shell 脚本do-for.sh 中,我想使用 bash 在 glob 中命名的所有目录中执行命令。这已经回答了很多次,但我想在命令行上提供命令本身。换句话说,假设我有目录:

  • foo
  • bar

我要进入

do-for * pwd

并让 bash 打印工作目录foo 内,然后在 bar

通过阅读网络上的无数答案,我认为我可以做到这一点:

for dir in $1; do
  pushd ${dir}
  $2 $3 $4 $5 $6 $7 $8 $9
  popd
done

显然,尽管 glob * 已扩展为其他命令行参数变量!所以第一次通过循环,对于$2 $3 $4 $5 $6 $7 $8 $9,我期待foo pwd,但似乎我得到foo bar

如何防止命令行上的 glob 扩展到其他参数?还是有更好的方法来解决这个问题?

为了更清楚地说明这一点,这是我想要使用批处理文件的方式。 (顺便说一句,这在 Windows 批处理文件版本上运行良好。)

./do-for.sh repo-* git commit -a -m "Added new files."

【问题讨论】:

  • 请看mywiki.wooledge.org/Quotes 我保证会回答你所有的问题。
  • do-for '*' pwd 引用它。但是,如果任何目录的名称中包含空格,您的脚本将无法运行。
  • andlr 我相信你的页面是一个很好的参考,但标准的 Stack Overflow 做法是提供答案;链接是次要的。还有 Barmar,我不想强​​迫我的用户添加引号。
  • 我不想强迫我的用户添加引号。很抱歉,您必须希望您的用户知道 shell工作。
  • 我的用户知道如何使用 shell。我不想强迫他们使用引号。

标签: bash for-loop directory glob


【解决方案1】:

我假设您对必须提供某种分隔符的用户持开放态度,就像这样

./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

请注意,此解决方案的优点是,如果您的用户忘记了“--”分隔符,她将收到通知(而不是由于引用而导致失败)。

【讨论】:

  • 其他建议的解决方案假定所使用的命令永远不会具有与有效目录相同的名称。虽然这允许没有分隔符的解决方案,但这也是一个冒险的假设,具体取决于您的情况。我个人更喜欢使用分隔符来允许可能被忽视的细微故障模式。
  • 不幸的是,似乎没有完美的答案;这似乎只是 bash 的一个限制。许多答案很有帮助,尤其是@Dario 的答案非常透彻且写得很好。这可能是最简单和最优雅的解决方案,尽管在很大程度上这是一个偏好问题。我个人决定使用do: 作为分隔符。非常感谢大家的回复!
  • 谢谢。有很多事情 Bash 没有完美的解决方案,关键是找到一个可行的解决方案,不是太慢(对于大多数脚本来说很少是一个问题)并且足够清晰,5 年后当你需要时你可以理解它修改或修复它。至少,这是我尝试做的。
  • 基于这个讨论,我创建了一个 Windows 批处理文件和一个 Linux shell 脚本,它们的功能相同,使用 --do 作为分隔符。它们是in-each-dir.batin-each-dir.sh,我已将它们放在公共bitbucket.org/globalmentor/util 存储库中。
【解决方案2】:

在这种情况下,问题不在于元字符的扩展,而只是您的脚本具有未定义数量的参数,其中最后一个是对所有先前参数执行的命令。

#!/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 "$1" && 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),脚本必须更长,因为必须测试每个参数是否是目录,直到命令被识别(或向后直到dir 被识别)。

这是一个可以接受语法的替代脚本:./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 "$1" ]] && DIRS[COUNT++]="$1" && shift || break
done

# Validate that the command received is valid
which "$1" >/dev/null 2>&1 || { echo "invalid command: $1"; exit 1; }

# Execute the command + it's arguments for each dir from array
for D in "${DIRS[@]}"; do 
     ( cd "$D" && eval "$@" )
done

【讨论】:

  • 所以澄清一下,这是一个有效的用法吗? ./do-for.sh repo-* git commit -a -m "Added new files."
  • 不,因为命令有很多参数。可以使用该脚本的形式是:./do-for.sh repo-* 'git commit -a -m "Added new files." '我会写给你更长的版本,与你写的完全一致。
  • 请注意,这在 Windows 批处理文件中非常简单。 :)
  • CMND="${!#}"比写CMND=$(eval echo "\${$#}")简单
【解决方案3】:

我会这样做:

#!/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 数组中。一旦遇到第一个非目录参数,我们就假设现在命令开始了。

然后使用shift 从参数中删除目录,并将当前目录存储在cur_dir 中。

最后一个循环访问每个目录并执行由其余参数组成的命令。

这对你有用

./do-for.sh repo-* git commit -a -m "Added new files."

示例 - 但如果 repo-* 扩展到目录以外的任何内容,则脚本会中断,因为它会尝试将文件名作为命令的一部分执行。

例如,如果 glob 和命令由 -- 之类的指示符分隔,则它可以变得更加稳定,但是如果您知道 glob 将始终只是目录,这应该工作。

【讨论】:

  • 我刚刚注意到这与@czvtools 的解决方案非常相似。他的上一个版本不适合你吗,@Garret?
  • 哦,你在检查每个参数是否是一个目录——聪明。我还没有看到@czvtools 的更新答案。
【解决方案4】:

我将从您提到两次的 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 正常工作。

  • 使用分隔符:

    ./do-for.sh repo-* -- git commit -a -m "Added new files."
    

这很有效,类似于其他 shell 命令在类似情况下采用的解决方案,并且仅当您的目录名称扩展包含与分隔符完全相同的目录名称时才会失败(不太可能发生的事件,在上面的例子,但一般情况下可能会发生。)

  • 将命令作为 last 参数,其余都是目录:

    ./do-for.sh repo-* 'git commit -a -m "Added new files."'
    

这行得通,但同样,它涉及引用,甚至可能是嵌套的,与更常见的通配符引用相比,它没有任何意义。

  • 尝试变得聪明:

     ./do-for.sh repo-* git commit -a -m "Added new files."
    

并考虑处理目录,直到您找到一个不是目录的名称。这在许多情况下都有效,但可能会在一些晦涩难懂的情况下失败(例如,当您有一个以命令命名的目录时)。

我的解决方案不属于上述任何类别。事实上,我建议不要在脚本的第一个参数中使用 * 作为通配符。 (这类似于split 命令使用的语法,您为要生成的文件提供非全局前缀参数。)我有两个版本(下面的代码)。对于第一个版本,您将执行以下操作:

        # 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."

第二个版本更灵活,因为它允许非最终的 glob,但对于 bash 世界的标准较低。

代码如下:

#!/bin/bash
if [ "$#" -lt 2 ]; then
  echo "Usage: ${0##*/} PREFIX command..." >&2
  exit 1
fi

pathPrefix="$1"
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."

(但是如果前缀不包含空格,你可以避免引用它,它会正确处理任何以该前缀开头的包含空格的目录名;有明显的变化,第二个中的 %-patterns 也是如此版本。)

请注意 Windows 和 Linux 环境之间的其他相关差异,例如路径名区分大小写、字符被视为特殊字符的差异等等。

【讨论】:

  • 感谢您写得非常好的答案!最后我不得不选择一个,所以奖给了@Fred,因为这是我决定在现实生活中使用的方法。
【解决方案5】:

在 bash 中,您可以执行“set -o noglob”,这将禁止 shell 扩展路径名(glob)。但这必须在执行脚本之前在运行的 shell 上设置,否则你应该引用你在参数中提供的任何元字符。

【讨论】:

  • 我不能强迫我的用户在运行我的 shell 脚本之前设置一些晦涩的(对他们来说)标志。
【解决方案6】:

find-while-read 组合是解析文件名的最安全组合之一。执行以下操作

#!/bin/bash
myfunc(){
 cd "$2"
 eval "$1" # 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

【讨论】:

    【解决方案7】:

    为什么不保持简单并创建一个使用 find 的 shell 函数,但可以减轻用户输入命令的负担,例如:

    do_for() { find . -type d \( ! -name . \) -not -path '*/\.*' -name $1 -exec bash -c "cd '{}' && "${@:2}" " \;  }
    

    所以他们可以输入类似do_for repo-* git commit -a -m "Added new files." 请注意,如果您想单独使用 *,则必须对其进行转义:

    do_for \* pwd 
    

    【讨论】:

      【解决方案8】:

      通配符在传递给任何程序或脚本之前由 shell 评估。对此你无能为力。

      但如果您接受引用 globbing 表达式,那么这个脚本应该可以解决问题

      #!/usr/bin/env bash
      
      for dir in $1; 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."
      

      【讨论】:

      • 我想之前有人提到过。他们说如果我没记错的话,它不适用于包含空格的目录。这是真的吗?
      • 这不是问题。 cd "$dir" 上的引用处理空格。
      • @HaraldNordgren 你对cd "$dir" 处理空格是正确的,但是$1 扩展的项目将仅在最近的shell 中包含空格。我不记得何时进行了更改,但我清楚地记得几年前,当路径名包含空格时,在这种情况下它被扩展为两个不同的项目。不要批评您的回答,只是应该让 OP 意识到这一点......
      【解决方案9】:

      没有人提出使用 find 的解决方案?为什么不尝试这样的事情:

      find . -type d \( -wholename 'YOURPATTERN' \) -print0 | xargs -0 YOURCOMMAND
      

      查看man find 了解更多选项。

      【讨论】:

        猜你喜欢
        • 2020-01-02
        • 2020-06-24
        • 1970-01-01
        • 2017-12-07
        • 1970-01-01
        • 2018-02-27
        • 2018-03-29
        • 1970-01-01
        • 1970-01-01
        相关资源
        最近更新 更多