0%

cp src/* 为什么拷不全?从一个星号看 Shell 展开

cp src/* 为什么拷不全?从一个星号看 Shell 展开

一、一次”看起来成功”的拷贝

前段时间迁移一个服务,需要把项目目录整体拷到新位置。目录结构很简单:

1
2
$ ls -A src
.env .gitignore app.py conf main.go

我顺手敲了一行再熟悉不过的命令:

1
$ cp -r src/* backup/

没有报错,ls 看了一眼,文件都在。结果服务一启动就报找不到配置。再仔细看:

1
2
$ ls -A backup
app.py conf main.go

.env 和 .gitignore 不见了。

后来换成 cp -r src/. backup/,一个文件都没少。同样是”拷贝 src 下的所有内容”,* 和 . 的结果为什么不同?要回答这个问题,得先弄清楚 cp 到底收到了什么。

二、cp 收到的根本不是星号

bash 提供了一个调试开关 set -x,它会把命令真正执行前的样子打印出来:

set -x(全称 set -o xtrace)的作用可以概括为一句话:每条命令在展开之后、执行之前,先把它最终的样子打印到标准错误(stderr)

1
2
$ set -x
ll

此时命令行返回

1
2
3
4
5
6
+ ls --color=auto -l --color=auto
total 4
-rw-------. 1 root root 1345 Aug 14 14:13 anaconda-ks.cfg
drwxr-xr-x. 2 root root 6 Oct 4 13:51 backup
drwxr-xr-x. 2 root root 77 Oct 4 13:41 src
++ printf '\033]0;%s@%s:%s\007' root localhost '~'

注意最后一行++开头的这是正常现象,不是报错。它是终端提示符每次显示前自动执行的一条命令,被 set -x 一起跟踪打印出来了。

后续为了文档美观方便读者观看,我把这行从后续输出中人为删除,并不代表shell不返回

我们开始执行cp -r 命令,看看到底发生了什么

1
2
3
4
cp -r src/* backup/

# 返回
+ cp -i -r src/app.py src/conf src/main.go backup/

* 不见了,取而代之的是三个具体的文件名。如果觉得这只是 bash 自己打印的说法,可以用 strace 看一眼内核实际收到的 execve 系统调用:

1
$ strace -f -e trace=execve bash -c 'cp -r src/* backup/'

典型的返回如下

这就是 cp 的全部输入:一个包含 6 个字符串的参数数组。cp 启动时,* 已经被 bash 替换掉了,cp 根本不知道用户输入过星号,自然也没有机会去拷贝隐藏文件。

问题因此转移到了 bash 身上:它把 src/* 展开成这三个名字,依据的是什么规则?

三、bash 如何展开 *

bash 解析完一行命令后,在执行前会对每个词做一系列展开处理,最后一步叫路径名展开(Pathname Expansion),也就是常说的 glob。它的工作过程大致如下:

  1. 发现某个词里含有未被引号包住的 *、? 或 [,就把它当作模式;
  2. 读取模式所在的目录(这里是 src),逐个拿目录项去匹配;
  3. 把匹配到的名字按字母顺序排好,替换掉原来的词。

关键在第 2 步里一条特殊规则:以 . 开头的文件名,只有在模式本身也以 . 开头时才会被匹配。* 不以点开头,所以 .env、.gitignore 被直接跳过。这条规则同时也让 * 不会匹配到 . 和 .. 这两个特殊目录项。

这里有一点值得强调:所谓”隐藏文件”,只是 shell 和 ls 等工具共同遵守的一个约定。对文件系统来说,.env 和 app.py 没有任何区别,内核也不存在”隐藏”这个属性。

理解了展开是 shell 的行为,另外两个现象就好解释了。

第一,如果目录是空的,模式一个都匹配不到,bash 默认会把它原样保留:

1
2
$ cp -r empty/* backup/
cp: cannot stat 'empty/*': No such file or directory

cp 收到的是字面上的 empty/*,它当然找不到一个名叫 * 的文件。

第二,只要用引号或反斜杠把星号包起来,展开就不会发生:

1
2
3
4
5
6
7
8
9
10
11
$ echo src/*
src/app.py src/conf src/main.go

$ echo 'src/*'
src/*

$ echo "src/*"
src/*

$ echo src/\*
src/*

只有第一条命令里的星号被展开了,后面三种写法都让 * 原样保留了下来。

到这里,* 为什么会漏掉隐藏文件已经清楚了。

但另一半问题还没解决:src/. 同样交给了 cp,它凭什么能拷全?这就要看 cp 拿到参数之后是怎么干活的。

顺带一提,常见的 cp nginx.conf{,.bak} 中的 {} 并不是通配符,它属于另一种机制,叫花括号展开(Brace Expansion)。它在所有展开中最先执行,而且是纯文本替换,不会去查文件系统:echo src/{app.py,index.js} 会输出 src/app.py src/index.js,哪怕 index.js 根本不存在。正因为先执行,它的结果还会继续参与路径名展开,比如 src/{*,.[!.]*} 会先被拆成 src/* 和 src/.[!.]* 两个模式,再分别去目录里匹配,所以 * 的那些限制它一个也没少。

四、cp 如何决定目标路径

当最后一个参数是一个已存在的目录时,cp 对前面的每个源参数做同一件事:取源路径的最后一个部分,拼到目标目录后面,作为目标路径。加上 -v 参数就能看到它的计算结果。

先看 * 展开后的情况,三个源分别落到 backup/ 下:

1
2
3
4
5
$ cp -rv src/* backup/
'src/app.py' -> 'backup/app.py'
'src/conf' -> 'backup/conf'
'src/conf/db.yml' -> 'backup/conf/db.yml'
'src/main.go' -> 'backup/main.go'

再看直接传目录名的情况。源的最后一部分是 src,所以目标路径是 backup/src,整个目录被嵌套了一层:

1
2
3
4
5
6
$ cp -rv src backup/
'src' -> 'backup/src'
'src/conf' -> 'backup/src/conf'
...
'src/.env' -> 'backup/src/.env'
'src/.gitignore' -> 'backup/src/.gitignore'

注意这次隐藏文件是拷上了的。因为 -r 递归时,cp 用 readdir 自己读取目录内容,读到什么就拷什么,不经过 shell,也就没有”跳过点开头文件”的规则。

隐藏文件的问题解决了,新的问题却随之而来:这种写法会把 src 目录本身也一起拷过去,结果 backup 下多出了一层 src,文件全都落在了 backup/src/ 里,而不是我们想要的 backup/。

那么,有没有办法既让 cp 自己去读整个目录,又让它算出来的目标路径恰好就是 backup 本身?src/. 正是这样一个写法。

五、. 为什么能做到

首先,src/. 里没有任何通配符,bash 不会动它,原样交给 cp。

其次,. 不是 shell 或 cp 发明的语法,而是每个目录中真实存在的一个目录项,指向目录自身。

ls -i 会在文件名前显示 inode 号,也就是文件系统用来标识一个文件或目录的编号;加上 -d 则只显示目录本身,而不是列出它里面的内容。用它对比一下 src 和 src 里的 .:

1
2
3
4
5
$ ls -id src
950308 src
$ ls -ai src | head -2
950308 .
950307 ..

src 和 src/. 是同一个 inode,所以内核解析 src/. 时,得到的就是 src 这个目录。cp 打开它、用 readdir 读出全部内容,包括隐藏文件。

最后再看目标路径。前面说过,cp 会取源路径的最后一部分,拼到目标目录后面:源路径 src/. 的最后一部分是 .,于是目标路径变成 backup/.,而 backup/. 就是 backup 自己。-v 的输出把这个过程展示得很清楚:

1
2
3
4
5
6
7
$ cp -rv src/. backup/
'src/./conf' -> 'backup/./conf'
'src/./conf/db.yml' -> 'backup/./conf/db.yml'
'src/./app.py' -> 'backup/./app.py'
'src/./main.go' -> 'backup/./main.go'
'src/./.env' -> 'backup/./.env'
'src/./.gitignore' -> 'backup/./.gitignore'

这三种写法的区别可以归结为一点:*** 由 shell 解释,在 cp 运行前就被替换掉了;. 由文件系统解释,cp 拿到的是一个完整的目录**。前者受 glob 规则约束,后者不受。

用三种写法套一下就清楚了:

命令 源路径的最后一部分 目标路径 结果
cp -r src/app.py backup/ app.py backup/app.py 正常
cp -r src backup/ src backup/src 多一层 src
cp -r src/. backup/ . backup/. = backup 内容直接合并进 backup

cp 只机械地取最后一部分来拼接,而 src/. 的最后一部分恰好是 .,拼出来的 backup/. 恰好又指向 backup 自己。两个巧合叠在一起,才有了”合并内容”的效果。

六、还有哪些写法,该怎么选

搞清楚原理后,网上常见的几种替代写法也就能逐一判断了。

cp -r src/.* backup/ 看似能补上隐藏文件,在 RHEL 7/8 上却是最危险的一种。bash 5.2 之前,.* 会匹配到 . 和 ..:

1
2
3
4
5
$ printf '%s\n' src/.*
src/.
src/..
src/.env
src/.gitignore

src/.. 就是 src 的父目录。这条命令会把父目录整个递归拷进 backup,项目放在家目录下的话,可能就是把整个家目录拷了一遍。

src/.[!.]* 能排除 . 和 ..,但会漏掉 ..foo 这类以两个点开头的文件名,而且只能匹配隐藏文件,需要和 src/* 搭配使用。另外,两个模式中只要有一个匹配不到,就会触发前面提到的字面量原样保留的问题。

shopt -s dotglob 可以让 * 匹配隐藏文件(但依然不会匹配 . 和 ..),适合在脚本里使用。不过它改变的是当前 shell 的全局行为,交互式终端里开了容易忘记关。

rsync -a src/ backup/ 也是常用做法。注意源路径末尾的斜杠,它和 . 一样,是由 rsync 程序自己解释的,表示”拷目录的内容而不是目录本身”。

日常使用中,我的选择是:

1
cp -a src/. backup/

-a 相当于 -dR --preserve=all,会保留权限、属主和时间戳,迁移配置时比 -r 更稳妥。这种写法不依赖任何 shell 选项,不受隐藏文件规则影响,空目录也不会报错,参数个数也永远只有两个。

写在最后

回过头看,这个坑本身并不复杂,但它涉及一个很容易被忽略的事实:我们在终端里敲的命令,并不是程序最终收到的命令。中间隔着 shell,它会按自己的规则改写很多东西,星号只是其中之一。

所以下次再遇到命令行为和预期不一致时,可以先问自己一个问题:这个字符是谁在解释,shell 还是程序? 拿不准的时候,set -x 会告诉你答案。