列出Linux中所有叶子子目录

21

在Linux下,有没有一种简单的方法可以列出给定目录下的只有目录而无文件?更好地解释一下,我可以这样做:

find mydir -type d

这将得到:

mydir/src
mydir/src/main
mydir/bin
mydir/bin/classes

我希望得到的是:

mydir/src/main
mydir/bin/classes

我可以使用一个循环遍历每一行并删除前一行,如果下一行包含该路径,但我想知道是否有一种不使用bash循环的更简单的方法。


1
通过你的例子,find mydir -mindepth 2 -type d 可以工作,但是当你有多个最大深度时它就不会工作。你是想列出只包含其他目录的目录,还是想查看特定级别的目录结构? - Cascabel
是的,谢谢您澄清 - 我的例子是一个简单的例子。我确实正在寻找一个更通用的解决方案。 此外,我只是想查看一般的目录结构,不真正关心目录中的文件(所以在这种情况下,“叶子”意味着“叶子目录”)。谢谢。 - amol
9个回答

17

如果你只想获取叶子目录(不包含任何子目录的目录),请参考这个问题。答案也解释了,简而言之:

find . -type d -links 2

1
在Mac上,链接包括当前、父级和子目录,但也包括文件。因此,这个解决方案只适用于空的叶子目录。 - Pimin Konstantin Kefaloukos
1
我刚刚注意到它在SMB挂载上也不起作用。但是在NFS挂载上可以。但在它能够工作的情况下(本地或NFS Linux目录),这肯定是最简单的解决方案。 - mivk

15
find . -type d | sort | awk '$0 !~ last "/" {print last} {last=$0} END {print last}'

谢谢,这正是我在寻找的解决方案 :) - amol
这将产生正确但未排序的结果,没有sort(因此如果您想要,可以将其放在最后)。 - Dennis Williamson
如果您有两个目录:foo/bar和foo/bar_baz,则此方法无法正常工作。foo/bar将不会被打印。 - mattismyname
1
这似乎完美地运行,除了当我尝试将输出保存在bash变量中(mydirs="$(find . ... | awk '$0 !~ ....)")时。出于某种原因,我不断收到错误awk: (FILENAME=- FNR=1) fatal: division by zero attempted,而且FILENAME项有时会扩展到随机目录或位置。也许awk在输出中返回了一个换行符或其他破坏命令替换的东西?或者连接管道进程存在问题? - user5359531
你会如何用别名(alias)来包装它?我尝试过使用以下别名:alias listall='find . -type d | sort | awk '$0 !~ last "/" {print last} {last=$0} END {print last}'',但是Bash对转义引号非常挑剔。 - michel

5

如果您需要可视化的内容,tree -d 是不错的选择。

饮料
|-- 可口可乐
|   |-- 樱桃味
|   `-- 健怡可乐
|       |-- 无咖啡因
|       `-- 樱桃味
|-- 果汁
|   `-- 橙汁
|       `-- 家庭风味
|           `-- 夸脱装
`-- 百事可乐
    |-- 透明可乐
    `-- 健怡可乐

谢谢,这也很有用(虽然不是为了解决我目前的问题,但很好知道)。 我在想,树是最近加入的吗?我记得大约一年前在RHEL上需要这样的东西(现在在Ubuntu上),最终使用 http://centerkey.com/tree 上的脚本。 - amol
对于 OS X:brew install tree - gatorback

4

我想不到任何不需要循环就可以完成这个任务的方法,所以这里提供一些循环的代码:

以下代码将显示当前目录下所有叶子目录,无论它们的深度如何:

for dir in $(find -depth -type d); do [[ ! $prev =~ $dir ]] && echo "$dir" ; prev="$dir"; done

此版本可以正确处理包含空格的目录名称:
saveIFS=$IFS; IFS=$'\n'; for dir in $(find -depth -type d ); do [[ ! $prev =~ $dir ]] && echo "${dir}" ; prev="$dir"; done; IFS=$saveIFS

这里有一个使用Jefromi建议的版本:
find -depth -type d | while read dir;  do [[ ! $prev =~ $dir ]] && echo "${dir}" ; prev="$dir"; done

OP 问是否有比循环输出更简单的方法,而不是如何编写循环。话虽如此,最好使用 find .... | while read dir 而非 for dir in $(...),因为前者在打印任何内容之前无需执行整个查找。 - Cascabel
@Jefromi:将“find”管道传递到“while”中,还可以免费正确处理带空格的名称。 - Dennis Williamson
避免使用循环显然不是一个难以满足的“要求”,但我希望有一种更简单直观的方法来避免使用循环。感谢您提供的信息。 - amol
@Dennis:确实!有什么 <cmd> | while read 做不了的吗? - Cascabel
@Jefromi:这个存在一个问题,如果你有目录foofoo_bar,那么只会输出foo_bar。可以通过在测试中添加斜杠来解决这个问题:find -depth -type d | while read dir; do [[ ! $prev =~ $dir/ ]] && echo "${dir}" ; prev="$dir"; done - Richard Whitehead

2
在大多数文件系统(不包括btrfs),简单的答案是:
find . -type d -links 2

https://unix.stackexchange.com/questions/497185/how-to-find-only-directories-without-subdirectories中有一个适用于btrfs的解决方案,但它非常丑陋:

find . -type d \
    \( -exec sh -c 'find "$1" -mindepth 1 -maxdepth 1 -type d -print0 | grep -cz "^" >/dev/null 2>&1' _ {} \; -o -print \)

有一种名为rawhide(rh)的替代方法,可以使这个过程更加容易:

rh 'd && "[ `rh -red %S | wc -l` = 0 ]".sh'

一个稍微更短、更快的版本是:
rh 'd && "[ -z \"`rh -red %S`\" ]".sh'

以上命令搜索目录,然后列出它们的子目录,并且只有在没有子目录时才匹配(第一个通过计算输出行数,第二个通过检查每个目录是否有任何输出来实现)。
如果您不需要对 btrfs 的支持,它更像是 find,但较短:
rh 'd && nlink == 2'

为了实现在所有文件系统上尽可能高效地工作的版本:

rh 'd && (nlink == 2 || nlink == 1 && "[ -z \"`rh -red %S`\" ]".sh)'

在普通(非btrfs)文件系统上,这将无需为每个目录添加任何额外的进程即可工作,但在btrfs上,它将需要它们。如果您有不同的文件系统混合使用,包括btrfs,则这可能是最好的选择。

Rawhide(rh)可从https://raf.org/rawhidehttps://github.com/raforg/rawhide获取。它至少可以在Linux、FreeBSD、OpenBSD、NetBSD、Solaris、macOS和Cygwin上运行。

免责声明:我是rawhide的当前作者


2
使用awk的解决方案很好、简单,但如果目录名包含在形成正则表达式模式时被认为是特殊字符,则会失败。这也会影响Bash中的~!=测试。下面的方法似乎适用于BSD和GNU查找:
find . -type d | sed 's:$:/:' | sort -r | while read -r dir;do [[ "${dir}" != "${prev:0:${#dir}}" ]] && echo "${dir}" && prev="${dir}”;done
  • find .更改为您想要开始搜索的任何目录。
  • sed命令向每个由find返回的目录添加正斜杠。
  • sort -r按字母顺序相反的方式对目录列表进行排序,这有利于首先列出离根最远的目录,这正是我们想要的。
  • 然后,while read循环逐行读取此列表,其中-r选项进一步防止将某些字符与其他字符区分对待。
  • 然后,我们需要将当前行与上一行进行比较。由于我们不能使用!=测试,并且中间目录的路径将短于相应叶目录的路径,因此我们的测试将当前行与截断为当前行长度的上一行进行比较。如果匹配,则可以将此行视为非叶目录进行丢弃,否则我们将打印此行并将其设置为previous行以准备下一次迭代。请注意,在测试语句中需要引用字符串,否则可能会产生一些误报。
哦,如果你不想使用find...
shopt -s nullglob globstar;printf "%s\n" **/ | sort -r | while read -r dir;do [[ "${dir}" != "${prev:0:${#dir}}" ]] && echo "${dir}" && prev="${dir}";done;shopt -u nullglob globstar

更新(2020年6月3日):这里有一个脚本,我希望它能够有所帮助。显然,你可以随意改进/调整/指出明显的问题...

#!/usr/bin/env bash

# leaf: from a given source, output only the directories
#       required ('leaf folders' ) to recreate a full
#       directory structure when passed to mkdir -p 

usage() {
    echo "Usage: ${0##*/} [-f|-g|-h|-m <target>|-s|-S|-v] <source>" 1>&2
}

# Initial variables...
dirMethod=0 # Set default method of directory listing (find -d)
addSource=0 # Set default ouput path behaviour

# Command options handling with Bash getopts builtin
while getopts ":fghm:sSv" options; do
    case "${options}" in
        f) # use depth-first find method of directory listing
            dirMethod=0 # set again here if user sets both f and g
            ;;
        g) # Use extended globbing and sort method of directory listing
            dirMethod=1
            ;;
        h) # Help text
            echo "Leaf - generate shortest list of directories to (optionally)"
            echo "       fully recreate a directory structure from a given source"
            echo 
            echo "Options"
            echo "======="
            usage
            echo
            echo "Directory listing method"
            echo "------------------------"
            echo "-f           Use find command with depth-first search [DEFAULT]"
            echo "-g           Use shell globbing method"
            echo
            echo "Output options"
            echo "--------------"
            echo "-m <target>  Create directory structure in <target> directory"
            echo "-v           Verbose output [use with -m option]"
            echo "-s           Output includes source directory"
            echo "-S           Output includes full given path of <source> directory"
            echo
            echo "Other options"
            echo "-------------"
            echo "-h           This help text"
            exit 0 # Exit script cleanly
            ;;
        m) # make directories in given location
            destinationRootDir="${OPTARG}"
            ;;
        s) # Include source directory as root of output paths/tree recreation
            addSource=1
            ;;
        S) # Include full source path as root of output paths/tree recreation
            addSource=2
            ;;
        v) # Verbose output if -m option given
            mdOpt="v"
            ;;
        *) # If no options... 
            usage
            exit 1 # Exit script with an error
            ;;
    esac
done
shift $((OPTIND-1))

# Positional parameters handling - only one (<source>) expected
if (( $# == 1 )); then
    if [[ $1 == "/" ]]; then # Test to see if <source> is the root directory /
        (( dirMethod == 0 )) && sourceDir="${1}" || sourceDir=
            # Set sourceDir to '/' if using find command dir generation or null if bash globbing method
    else
        sourceDir="${1%/}" # Strip trailing /
    fi
else
    usage  # Show usage message and...
    exit 1 # Quit with an error
fi

# Generate full pre-filtered directory list depending on requested method
if (( dirMethod == 0 )); then # find command method
    dirList=$(find "${sourceDir}" -depth -type d 2>/dev/null | sed -e 's:^/::' -e '/^$/ ! s:$:/:')
        # find command with depth-first search should eliminate need to sort directories
        # sed -e 's:^/::' -e '/^$/ ! s:$:/:' - strip leading '/' if present and add '/'
        #                                      to all directories except root
else
    shopt -s nullglob globstar dotglob
    # nullglob - don't return search string if no match
    # globstar - allow ** globbing to descend into subdirectories. '**/' returns directories only
    # dotglob  - return hidden folders (ie. those beginning with '.') 
    dirList=$(printf "%s\n" "${sourceDir}"/**/ | sed -e 's:^/::' | sort -r)
    # sort command required so filtering works correctly
fi

# Determine directory stripping string. ie. if given path/to/source[/] as the
# source directory (src), should the output be just that of the contents of src,
# src and its contents or the path/to/src and contents?
sourceDir="${sourceDir#/}"
case "${addSource}" in
    0) strip="${sourceDir}/";; # Set 'strip' to <source> 
    1) [[ "${sourceDir}" =~ (\/?.+)\/.+$ ]] && strip="${BASH_REMATCH[1]}/" || strip="/"
       # To strip down to <source> only, check to see if matched by regex and only return matched part
       # If not found, behave like -S
       ;;
    2) strip="/";; # Set 'strip' to nothing but a forward slash
esac

# Main loop
# Feed the generated dirList into this while loop which is run line-by-line (ie. directory by directory)
while read -r dir;do
    if [[ "${dir}" != "${prev:0:${#dir}}" ]]; then
        # If current line is not contained within the previous line then that is a valid directory to display/create 
        if [[ -v destinationRootDir ]]; then # If destinationRootDir is set (-m) then create directory in <target>
            mkdir -p${mdOpt} "${destinationRootDir%/}/${dir#$strip}"
            # -p - create intermediate directories if they don't exist. The filtered list means no unnecessary mkdir calls
            # if mdOpt is set, it is 'v', meaning mkdir will output each created directory path to stdin
            # ${dir#$strip} removes the set strip value from the line before it is displayed/created
        else
            echo "${dir#$strip}" # Same as above but no directories created. Displayed only, so -v ignored here
        fi
        prev="${dir}" # Set prev to this line before the loop iterates again and the next line passed to dir
    fi
done <<<"${dirList}" # This is a here string

1

尝试以下一行代码(在Linux和OS X上测试通过):

find . -type d -execdir sh -c 'test -z "$(find "{}" -mindepth 1 -type d)" && echo $PWD/{}' \;

0

这仍然是一个循环,因为它在sed中使用了分支命令:

find -depth -type d |sed 'h; :b; $b; N; /^\(.*\)\/.*\n\1$/ { g; bb }; $ {x; b}; P; D'

根据info sed中的脚本(类似于uniq的工作方式)。 编辑 这是带有注释的sed脚本(从info sed中复制并修改):
# copy the pattern space to the hold space
h 

# label for branch (goto) command
:b
# on the last line ($) goto the end of 
# the script (b with no label), print and exit
$b
# append the next line to the pattern space (it now contains line1\nline2
N
# if the pattern space matches line1 with the last slash and whatever comes after
# it followed by a newline followed by a copy of the part before the last slash
# in other words line2 is different from line one with the last dir removed
# see below for the regex
/^\(.*\)\/.*\n\1$/ {
    # Undo the effect of
    # the n command by copying the hold space back to the pattern space
    g
    # branch to label b (so now line2 is playing the role of line1
    bb
}
# If the `N' command had added the last line, print and exit
# (if this is the last line then swap the hold space and pattern space
# and goto the end (b without a label) 
$ { x; b }

# The lines are different; print the first and go
# back working on the second.
# print up to the first newline of the pattern space
P
# delete up to the first newline in the pattern space, the remainder, if any,
# will become line1, go to the top of the loop
D

这是正则表达式的作用:

  • / - 开始一个模式
  • ^ - 匹配行的开头
  • \( - 开始捕获组(反向引用子表达式)
  • .* - 零个或多个 (*) 任意字符 (.)
  • \) - 结束捕获组
  • \/ - 斜杠 (/) (使用 \ 转义)
  • .* - 零个或多个任意字符
  • \n - 换行符
  • \1 - 复制反向引用(在本例中是行开头和最后一个斜杠之间的任何内容)
  • $ - 匹配行的结尾
  • / - 结束模式

你的建议非常好,但我觉得有点难理解。还是谢谢你,我会尝试弄清楚sed选项的含义。 - amol

0
我认为你可以查看所有目录,然后重定向输出并使用xargs来计算每个子目录中的文件数量。当没有子目录时(xargs find SUBDIR -type d | wc -l ...类似这样,我现在无法测试),你就找到了一个叶子节点。
不过,这仍然是一个循环。

啊..也许我没有很准确地解释“叶子”是什么意思。我指的是“叶子目录”,所以如果一个目录只有文件,它仍然符合我的问题的“叶子”定义。 - amol
哦,我累了,如果你在下面查找目录并且 wc -l == 0,那应该就可以了... - LB40

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