在bash中,如果文件名已经存在,则创建新文件并添加数字。

40

我在 Stack Overflow 上找到了类似的问题,但不是关于 Linux/Bash 的。

我想让我的脚本创建一个具有给定名称的文件(通过用户输入),但是如果文件名已经存在,则在结尾添加数字。

例如:

$ create somefile
Created "somefile.ext"
$ create somefile
Created "somefile-2.ext"
9个回答

45
以下脚本可以帮助你。为避免竞争条件,您不应同时运行几个副本的脚本。
name=somefile
if [[ -e $name.ext || -L $name.ext ]] ; then
    i=0
    while [[ -e $name-$i.ext || -L $name-$i.ext ]] ; do
        let i++
    done
    name=$name-$i
fi
touch -- "$name".ext

2
它起作用了,但我将初始的 i 改为了 2,以便成为文件的“第二”份副本。谢谢! - heltonbiker
我在循环的第一个通道中使用了else条件。 - PatriceG
1
@Maëlan,注意答案中已经注意到了竞态条件。 - Stephane Chazelas
在touch命令中,为什么$name要加引号,而它的扩展名却不需要呢? touch -- "$name".ext ? 这样做有什么实际用途吗? - Keithel
@Keithel:如果要适用于包含空格的名称等,则应将$name用双引号括起来。引用.ext是可能的,但不会改变任何内容。 - choroba
显示剩余3条评论

17

更容易:

touch file`ls file* | wc -l`.ext

您将获得:

$ ls file*
file0.ext  file1.ext  file2.ext  file3.ext  file4.ext  file5.ext  file6.ext

如果我想让第一个输出文件名为file1.ext,该怎么做? - Uzi
6
好的,如果(前提是)你确定那里永远不会有非连续数字存在,也就是说,如果我在你的示例中删除 file2.txt,那么现在会覆盖 file6.txt - Ulrich Schwarz
1
通常情况下,你应该避免在脚本中使用ls,但这似乎相当无害。为了风格上的考虑,可以将ls替换为printf '%s\n'(甚至是printf '.\n',这对包含换行符的文件名具有鲁棒性)。 - tripleee

9

为了避免竞态条件:

name=some-file

n=
set -o noclobber
until
  file=$name${n:+-$n}.ext
  { command exec 3> "$file"; } 2> /dev/null
do
  ((n++))
done
printf 'File is "%s"\n' "$file"
echo some text in it >&3

另外,您在fd 3上打开了文件进行写入。

使用bash-4.4+,您可以将其制作成一个函数,如下所示:

create() { # fd base [suffix [max]]]
  local fd="$1" base="$2" suffix="${3-}" max="${4-}"
  local n= file
  local - # ash-style local scoping of options in 4.4+
  set -o noclobber
  REPLY=
  until
    file=$base${n:+-$n}$suffix
    eval 'command exec '"$fd"'> "$file"' 2> /dev/null
  do
    ((n++))
    ((max > 0 && n > max)) && return 1
  done
  REPLY=$file
}

可用于以下场景:

create 3 somefile .ext || exit
printf 'File: "%s"\n' "$REPLY"
echo something >&3
exec 3>&- # close the file
max值可用于防止无限循环,当文件无法创建的原因不仅仅是noclobber时。

请注意,noclobber仅适用于>运算符,而不适用于>><>

剩余竞态条件

实际上,在所有情况下,noclobber并未消除竞态条件。它只能防止覆盖常规文件(不包括其他类型的文件,例如cmd > /dev/null不会失败),并且在大多数shell中本身存在竞态条件。

Shell首先对文件执行stat(2)以检查它是否为常规文件(FIFO、目录、设备...)。只有当文件不存在(尚未存在)或是常规文件时,3> "$file"才使用O_EXCL标志来保证不覆盖该文件。

因此,如果该名称下存在一个FIFO或设备文件,并且可以以只写模式打开,那么它将被使用,并且如果在stat(2)open(2)之间替换了一个FIFO/设备/目录...,则可能会覆盖常规文件而创建新的文件!

更改

  { command exec 3> "$file"; } 2> /dev/null

to

  [ ! -e "$file" ] && { command exec 3> "$file"; } 2> /dev/null

建议避免使用已存在的非正则文件,但不解决竞争条件问题。

现在,只有在面对恶意攻击者想让您在文件系统上覆盖任意文件时才会真正担心这个问题。它确实消除了同一脚本的两个实例同时运行的正常情况下的竞争条件。因此,在这方面,它比仅事先检查文件是否存在的方法更好,如 [ -e "$file" ]

要完全消除竞争条件的可工作版本,可以使用 zsh shell 而不是 bash,它具有 sysopen 内置于 zsh/system 模块中的 open() 原始接口。

zmodload zsh/system

name=some-file

n=
until
  file=$name${n:+-$n}.ext
  sysopen -w -o excl -u 3 -- "$file" 2> /dev/null
do
  ((n++))
done
printf 'File is "%s"\n' "$file"
echo some text in it >&3

这是最好的、唯一可接受的答案,因为它是唯一一个处理竞态条件的方法。 - Maëlan
如果我理解正确,有两个问题。除了您所说的不太可能发生的stat(2)open(2)之间的竞争条件之外,还存在非常规文件的问题,这些文件恰好是可写的(fifo、设备...或指向这些类型之一的符号链接)。如果"$file"已经存在并且属于此类,则循环将停止而不是尝试另一个文件名。这种情况不应该通过将试探性重定向与测试&& [ -f "$file" ]结合处理(在重定向成功之后进行)吗?或者调用open(2)是否仍会失败? - Maëlan

7
尝试像这样做:

试试这个方法

name=somefile
path=$(dirname "$name")
filename=$(basename "$name")
extension="${filename##*.}"
filename="${filename%.*}"
if [[ -e $path/$filename.$extension ]] ; then
    i=2
    while [[ -e $path/$filename-$i.$extension ]] ; do
        let i++
    done
    filename=$filename-$i
fi
target=$path/$filename.$extension

这应该是被接受的答案,因为它确实照顾到了所有事情,包括在扩展名之前添加后缀! - sorin
1
@sorin,对于以“-”开头的$name值仍然会失败。在//file是特殊的系统上,像/file这样的name值也可能失败。它不能保护那些已经损坏的符号链接的$name值。并且存在TOCTOU竞争条件(请注意,在zsh中,$path是绑定到$PATH的特殊数组,因此它会中断)。 - Stephane Chazelas
当上面的内容被复制到backup.sh中时,以下命令不起作用:$ ./backup.sh blah.txt。我期望会生成一个名为“blah2.txt”或类似的新文件。 - Tom Russell

5

使用 touch 或其他任何命令代替 echo

echo file$((`ls file* | sed -n 's/file\([0-9]*\)/\1/p' | sort -rh | head -n 1`+1))

表达式的各部分解释如下:

  • 按模式列出文件:ls file*
  • 每行仅提取数字部分:sed -n 's/file\([0-9]*\)/\1/p'
  • 应用反向人类排序:sort -rh
  • 只取第一行(即最大值):head -n 1
  • 将所有内容组合在管道中并增加(完整的表达式如上所述)

2
尝试像这样做(未经测试,但你可以得到想法):
filename=$1

# If file doesn't exist, create it
if [[ ! -f $filename ]]; then
    touch $filename
    echo "Created \"$filename\""
    exit 0
fi

# If file already exists, find a similar filename that is not yet taken
digit=1
while true; do
    temp_name=$filename-$digit
    if [[ ! -f $temp_name ]]; then
        touch $temp_name
        echo "Created \"$temp_name\""
        exit 0
    fi
    digit=$(($digit + 1))
done

根据你的操作需要,用相应的代码替换touch调用,以创建你正在使用的文件。


1
我使用的创建目录增量的方法要好得多。它也可以用于文件名。
LAST_SOLUTION=$(echo $(ls -d SOLUTION_[[:digit:]][[:digit:]][[:digit:]][[:digit:]] 2> /dev/null) | awk '{ print $(NF) }')
if [ -n "$LAST_SOLUTION" ] ; then
    mkdir SOLUTION_$(printf "%04d\n" $(expr ${LAST_SOLUTION: -4} + 1))
else
    mkdir SOLUTION_0001
fi

这里存在许多不幸的反模式,包括无用的echo使用和将私有变量命名为大写字母,这些变量应该保留供系统使用。 - tripleee

0
一个将choroba的答案简单打包成通用函数的方法:
autoincr() {
    f="$1"
    ext=""

    # Extract the file extension (if any), with preceeding '.'
    [[ "$f" == *.* ]] && ext=".${f##*.}"

    if [[ -e "$f" ]] ; then
        i=1
        f="${f%.*}";

        while [[ -e "${f}_${i}${ext}" ]]; do
            let i++
        done

        f="${f}_${i}${ext}"
    fi
    echo "$f"
}

touch "$(autoincr "somefile.ext")"

如已在@Choroba的答案中评论的那样,[[ -e .... ]]执行了stat(2),你需要使用[[ -e ... || -L ... ]]来搜索文件是否存在,但即使这样仍存在竞态条件,更好的方法是使用O_EXCL打开文件,以确保系统不会使用已经存在的文件(或更糟糕的是敏感文件的符号链接)。 - Stephane Chazelas

0

不使用循环,也不使用正则表达式或shell expr。

last=$(ls $1* | tail -n1)
last_wo_ext=$($last | basename $last .ext)
n=$(echo $last_wo_ext | rev | cut -d - -f 1 | rev)
if [ x$n = x ]; then
    n=2
else
    n=$((n + 1))
fi
echo $1-$n.ext

更简单,不需要扩展和例外的“-1”。

n=$(ls $1* | tail -n1 | rev | cut -d - -f 1 | rev)
n=$((n + 1))
echo $1-$n.ext

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接