2

I'm trying to write a shell script that deletes all empty directories as well as any directory that contains only the .DS_Store file that Mac generates.  I can do the former pretty easily with

find -depth -type d -empty

but I can't figure how to find directories that contain only .DS_Store.

Is there an easy way of doing this without writing my own recursive search function?

4 Answers 4

6

POSIX sh + find

Here's a solution that relies only on POSIX find and POSIX sh. List all directories, then filter those that only contain an entry called .DS_Store.

find . -type d -exec sh -c '
    cd "$0" &&
    for x in * .[!.]* ..?*; do
      if [ "$x" = ".DS_Store" ]; then continue; fi;
      if [ -e "$x" ] || [ -L "$x" ]; then exit 1; fi;
    done' {} \; -print
  • I use find to enumerate all directories recursively.
  • On each directory, I call sh to run some shell code.
  • The for loop enumerates all the files in the directory.
  • The body of the loop skips .DS_Store.
  • Each of the three patterns is left unchanged if it doesn't match any file. [ -e "$x" ] || [ -L "$x" ] captures any file including broken symbolic links; the only way they don't match is if a pattern was left unchanged.
  • Therefore the shell snippet runs exit 1 if there is a file other than .DS_Store, and returns 0 for success otherwise.
  • Change -print to -exec … if you want to do something other than printing the names.

Zsh

Here's a solution in zsh. Change echo to whatever command you want to run.

setopt extended_glob
echo **/*(/DNe\''a=($REPLY/^.DS_Store(DNY1)); ((!#a))'\')
  • **/* enumerates all files recursively.
  • With the glob qualifier /, **/*(/) enumerates all directories recursively.
  • The glob qualifier N ensures that you get an empty list if there are no matches (by default zsh signals an error).
  • The glob qualifier D causes dot files to be included.
  • The glob qualifier e\''CODE'\' runs CODE on each matching file name and limits the matches to those for which CODE succeeds. CODE can use the variable $REPLY to refer to the file name.
  • ^.DS_Store matches files that are not called .DS_Store.
  • Thus the CODE limits the matches to those for which the number of files other than .DS_Store is zero.
  • The glob qualifier Y1 limits the matches to one (it's only an efficiency improvement).

Python

Here's a solution in Python (it works in both 2 and 3). The structure is rather clearer despite this being compressed into a one-liner.

python -c 'import os; print("\n".join([path for path, dirs, files in os.walk(".") if dirs == [] and files in ([], [".DS_Store"])]))'
  • os.walk returns a list of directories recursively under its argument. For each directory, it produces a triple containing path (the path to the directory), dirs (the list of subdirectories) and files (the list of files in the directory that aren't themselves directories).
  • [… for … in os.walk(…) if …] filters the result of os.walk.
  • The if clause keeps an element only if it has no subdirectories and no files other than .DS_Store.
  • The script prints the accepted elements, joined with a newline in between and with a final newline.
0

Easy solution: first, delete all such files:

find <path> -type f -name "*.DS_Store" -delete

then delete empty directories.

Update based on comment: In order to delete only directories that have only such files, you will need something like (caution: not tested at all, I will not be surprised if it needs to be a bit more involved):

find <path> -type d | while read dir; do
    if ! ls --ignore=*.DS_Store $dir; then
        rm -rf $dir
    fi
done

Explanation of complicated part:

ls --ignore=*.DS_Store $dir

should print files in $dir that do not end in .DS_Store. If there are none, the expression is False and the if-block is executed.

5
  • I though of this but I don't want to delete all .DS_Store files, only the ones in directories that are otherwise empty.
    – David
    Dec 17, 2018 at 20:04
  • 1
    Sorry to spoil the fun, but the standard ls on macOS doesn't know about --ignore...
    – nohillside
    Dec 17, 2018 at 20:27
  • 2
    in that case, --ignore can be emulated with suitable 'grep -v'
    – WerKater
    Dec 17, 2018 at 20:33
  • (1a) Quoting Stéphane Chazelas’s comment elsewhere on this page: “See Understanding "IFS= read -r line" and Why is looping over find's output bad practice? (1b) If you’re going to use this approach, you need a lot more quotes. (2) Why are you checking for *.DS_Store? Yes, I see that you say you’re looking for files whose names end in .DS_Store; but why? … (Cont’d) May 27, 2022 at 18:19
  • (Cont’d) … (3) Thank you for admitting that you didn’t test this, because I believe that it cannot possibly work, because ls doesn’t exit with an error just because it didn’t list any files. May 27, 2022 at 18:19
0

For Mac:

find . -type d | while read dir; do
    
    # Found empty dir if it only has:
    #   ., .. 
    #   ., .., .DS_Store
    
    if [ "$(ls -am "$dir" | sed -E 's/^., ..$|^., .., .DS_Store$//')" == "" ]; then
        echo rm -R "\"$dir\""
    fi
done
2
0

Another POSIX variant with a bit more safeguards:

find . -type d -exec sh -c '
  for dir do
    contents=$(ls -AFq "$dir") &&
      case $contents in
        ("" | .[dD][sS]_[sS][tT][oO][rR][eE]) printf "%s\n" "$dir"
      esac
  done' sh {} +

Here:

  • we bail out if ls can't list the directory contents (or can't find out the type of the files within).
  • by using ls -F, we make sure we don't list a directory that contains a .DS_Store that is not a regular file (as -F adds suffixes for other types of files).
  • With -q, we avoid false positives with files names $'.DS_Store\n\n' for instance.
  • We match case insensitively as I believe filenames are case insensitive in macos.

A zsh equivalent could be:

empty() {
  set -o localoptions -o extendedglob
  ERRNO=0
  () ((!ERRNO && !$#)) $REPLY/(#i){^.ds_store(NDY1),.ds_store(N^.)}
}
print -rC1 -- **/*(ND/+empty)

Now, if the point is to remove those directories, there's the case where dirA contains only dirB and dirA/dirB is empty or only contains a .DS_Store file. Then, we wouldn't be reporting dirA even though it would become empty after dirA/dirB is removed.

To address that, that's where you would use -depth, but you wouldn't be able to use -exec ... {} + as it's important the empty directories are deleted as soon as they're found, so we'd use -exec ... {} ';' instead:

find . -depth -type d -exec sh -c '
  contents=$(ls -AFq "$1") &&
    case $contents in
      ("" | .[dD][sS]_[sS][tT][oO][rR][eE]) exec rm -rf "$1"
    esac' sh {} ';'

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .