我编写了以下脚本来比较两个目录中所有相同文件的输出:
#!/bin/bash
for file in `find . -name "*.csv"`
do
echo "file = $file";
diff $file /some/other/path/$file;
read char;
done
我知道还有其他方法可以实现这一目标。但奇怪的是,当文件中包含空格时,该脚本会失败。我该如何处理这个问题?
查找的示例输出:
./zQuery - abc - Do Not Prompt for Date.csv
答案1
简短答案(最接近您的答案,但处理空格)
OIFS="$IFS"
IFS=$'\n'
for file in `find . -type f -name "*.csv"`
do
echo "file = $file"
diff "$file" "/some/other/path/$file"
read line
done
IFS="$OIFS"
更好的答案(还处理文件名中的通配符和换行符)
find . -type f -name "*.csv" -print0 | while IFS= read -r -d '' file; do
echo "file = $file"
diff "$file" "/some/other/path/$file"
read line </dev/tty
done
最佳答案(基于吉尔斯的回答)
find . -type f -name '*.csv' -exec sh -c '
file="$0"
echo "$file"
diff "$file" "/some/other/path/$file"
read line </dev/tty
' exec-sh {} ';'
或者更好的是,避免sh
每个文件运行一个:
find . -type f -name '*.csv' -exec sh -c '
for file do
echo "$file"
diff "$file" "/some/other/path/$file"
read line </dev/tty
done
' exec-sh {} +
长答案
你有三个问题:
- 默认情况下,shell 将命令的输出拆分为空格、制表符和换行符
- 文件名可以包含通配符,这些字符会被扩展
- 如果有一个名称以 结尾的目录怎么办
*.csv
?
1. 仅在换行符上分割
为了弄清楚要设置的内容file
,shell 必须获取 的输出find
并以某种方式解释它,否则file
将只是 的整个输出find
。
shell 读取默认IFS
设置的变量。<space><tab><newline>
然后它查看 输出中的每个字符find
。一旦它看到 中的任何字符IFS
,它就会认为 标记了文件名的结尾,因此它设置file
为到目前为止看到的任何字符并运行循环。然后它从上次停止的地方开始获取下一个文件名,并运行下一个循环等,直到到达输出末尾。
所以它有效地做到了这一点:
for file in "zquery" "-" "abc" ...
要告诉它只在换行符上分割输入,您需要这样做
IFS=$'\n'
在你的命令之前for ... find
。
这设置IFS
为单个换行符,因此它仅在换行符上分割,而不是空格和制表符。
如果您使用sh
ordash
代替ksh93
, bash
or zsh
,则需要IFS=$'\n'
这样写:
IFS='
'
这可能足以让您的脚本正常工作,但如果您有兴趣正确处理其他一些极端情况,请继续阅读...
2.$file
不使用通配符扩展
在循环内部
diff $file /some/other/path/$file
外壳尝试扩展$file
(再次!)。
它可以包含空格,但由于我们已经IFS
在上面设置了,所以这里不会有问题。
但它也可能包含通配符,例如*
或?
,这会导致不可预测的行为。 (感谢吉尔斯指出这一点。)
要告诉 shell 不要扩展通配符,请将变量放在双引号内,例如
diff "$file" "/some/other/path/$file"
同样的问题也可能会困扰我们
for file in `find . -name "*.csv"`
例如,如果您有这三个文件
file1.csv
file2.csv
*.csv
(极不可能,但仍有可能)
就好像你跑了一样
for file in file1.csv file2.csv *.csv
这将扩展到
for file in file1.csv file2.csv *.csv file1.csv file2.csv
导致file1.csv
并被file2.csv
处理两次。
相反,我们必须做
find . -name "*.csv" -print | while IFS= read -r file; do
echo "file = $file"
diff "$file" "/some/other/path/$file"
read line </dev/tty
done
read
从标准输入读取行,根据将行拆分为单词IFS
并将它们存储在您指定的变量名称中。
在这里,我们告诉它不要将该行拆分为单词,并将该行存储在$file
.
另请注意,read line
已更改为read line </dev/tty
.
这是因为在循环内部,标准输入来自find
管道。
如果我们只是这样做read
,它将消耗部分或全部文件名,并且某些文件将被跳过。
/dev/tty
是用户运行脚本的终端。请注意,如果脚本通过 cron 运行,这将导致错误,但我认为在这种情况下这并不重要。
那么,如果文件名包含换行符怎么办?
我们可以通过更改-print
为-print0
并read -d ''
在管道末端使用来处理这个问题:
find . -name "*.csv" -print0 | while IFS= read -r -d '' file; do
echo "file = $file"
diff "$file" "/some/other/path/$file"
read char </dev/tty
done
这使得find
在每个文件名的末尾放置一个空字节。空字节是文件名中唯一不允许的字符,因此这应该处理所有可能的文件名,无论多么奇怪。
为了获取另一侧的文件名,我们使用IFS= read -r -d ''
.
在上面使用的地方read
,我们使用了默认的行分隔符换行符,但现在find
使用 null 作为行分隔符。在 中bash
,您不能将参数中的 NUL 字符传递给命令(即使是内置命令),但bash
可以理解-d ''
为含义NUL 分隔。所以我们使用-d ''
makeread
使用与 相同的行分隔符find
。请注意-d $'\0'
,顺便说一句, 也可以工作,因为bash
不支持 NUL 字节会将其视为空字符串。
为了正确起见,我们还添加了-r
,它表示不要专门处理文件名中的反斜杠。例如,没有-r
,\<newline>
被删除,并\n
转换为n
.
一种更可移植的编写方式不需要bash
或zsh
或记住上述所有有关空字节的规则(再次感谢 Gilles):
find . -name '*.csv' -exec sh -c '
file="$0"
echo "$file"
diff "$file" "/some/other/path/$file"
read char </dev/tty
' exec-sh {} ';'
*3. 跳过名称结尾的目录.csv
find . -name "*.csv"
还将匹配名为 的目录something.csv
。
为了避免这种情况,请添加-type f
到find
命令中。
find . -type f -name '*.csv' -exec sh -c '
file="$0"
echo "$file"
diff "$file" "/some/other/path/$file"
read line </dev/tty
' exec-sh {} ';'
作为格伦·杰克曼指出,在这两个示例中,为每个文件执行的命令都在子 shell 中运行,因此如果更改循环内的任何变量,它们将被忘记。
如果您需要设置变量并在循环结束时仍然设置它们,您可以重写它以使用进程替换,如下所示:
i=0
while IFS= read -r -d '' file; do
echo "file = $file"
diff "$file" "/some/other/path/$file"
read line </dev/tty
i=$((i+1))
done < <(find . -type f -name '*.csv' -print0)
echo "$i files processed"
请注意,如果您尝试在命令行中复制并粘贴此内容,read line
将会消耗echo "$i files processed"
,因此该命令将不会运行。
为了避免这种情况,您可以删除结果read line </dev/tty
并将结果发送到寻呼机,例如less
.
笔记
;
我删除了循环内的分号 ( )。如果需要,您可以将它们放回去,但不需要它们。
如今,$(command)
比 更常见`command`
。这主要是因为它$(command1 $(command2))
比`command1 \`command2\``
.
read char
并没有真正读取一个字符。它读取整行,所以我将其更改为read line
.
答案2
如果任何文件名包含空格或 shell 通配符,则此脚本将失败\[?*
。该find
命令每行输出一个文件名。然后`find …`
shell 评估命令替换,如下所示:
- 执行
find
命令,获取其输出。 - 将输出拆分
find
为单独的单词。任何空白字符都是单词分隔符。 - 对于每个单词,如果它是通配模式,则将其展开到它匹配的文件列表。
例如,假设当前目录中有三个文件,分别称为`foo* bar.csv
、foo 1.txt
和foo 2.txt
。
- 该
find
命令返回./foo* bar.csv
。 - shell 在空格处分割该字符串,生成两个单词:
./foo*
和bar.csv
。 - 由于
./foo*
包含一个通配元字符,因此它会扩展为匹配文件的列表:./foo 1.txt
和./foo 2.txt
。 - 因此,
for
循环将依次执行./foo 1.txt
、./foo 2.txt
和bar.csv
。
您可以通过减少分词和关闭通配符来避免此阶段的大多数问题。要减弱分词效果,请将IFS
变量设置为单个换行符;这样, 的输出find
将仅在换行符处分割,并且空格将保留。要关闭通配符,请运行set -f
.那么只要文件名不包含换行符,这部分代码就会起作用。
IFS='
'
set -f
for file in $(find . -name "*.csv"); do …
(这不是您问题的一部分,但我建议使用$(…)
over `…`
。它们具有相同的含义,但反引号版本具有奇怪的引用规则。)
下面还有一个问题:diff $file /some/other/path/$file
应该是
diff "$file" "/some/other/path/$file"
否则, 的值$file
将被拆分为单词,并且单词将被视为全局模式,就像上面的命令 substitutio 一样。如果您必须记住有关 shell 编程的一件事,请记住这一点:$foo
始终在变量扩展 ( ) 和命令替换 ( $(bar)
)周围使用双引号,除非你知道你想分裂。 (在上面,我们知道我们想要将find
输出分成几行。)
一种可靠的调用方式find
是告诉它为找到的每个文件运行命令:
find . -name '*.csv' -exec sh -c '
echo "$0"
diff "$0" "/some/other/path/$0"
' {} ';'
在这种情况下,另一种方法是比较两个目录,尽管您必须显式排除所有“无聊”文件。
diff -r -x '*.txt' -x '*.ods' -x '*.pdf' … . /some/other/path
答案3
我很惊讶没有看到readarray
提及。与运算符结合使用时,这变得非常容易<<<
:
$ touch oneword "two words"
$ readarray -t files <<<"$(ls)"
$ for file in "${files[@]}"; do echo "|$file|"; done
|oneword|
|two words|
使用该<<<"$expansion"
构造还允许您将包含换行符的变量拆分为数组,例如:
$ string=$(dmesg)
$ readarray -t lines <<<"$string"
$ echo "${lines[0]}"
[ 0.000000] Initializing cgroup subsys cpuset
readarray
已经在 Bash 中使用多年了,所以这可能应该是在 Bash 中执行此操作的规范方法。
答案4
Afaik find 有您需要的一切。
find . -okdir diff {} /some/other/path/{} ";"
find 负责保存调用程序。 -okdir 将在差异之前提示您(您确定是/否)。
不涉及 shell,没有通配符、小丑、pi、pa、po。
作为旁注:如果将 find 与 for/while/do/xargs 结合起来,在大多数情况下,您会做错。 :)