0

Is there some way to use the find command in macOS terminal to find all folders recursively within a given path that are virtually empty, or empty aside from a single .DS_Store file?

I've tried the following in terminal to no avail:

find TARGET_PATH -empty -type d

Apparently, these super-invisible .DS_Store files are still considered folder contents even in the absence of any other files. :/

This question is about a smaller part of a larger task I'm trying to accomplish. I've almost got it all working now except part of the script that only seems to work when I run it from terminal directly. Here's a link to the main issue in case anyone reading this question might be able to help: Service to execute series of commands on selected folders to eliminate issues with .DS_Store files in Finder

4
  • What is your ultimate goal here? You've asked several probably related questions recently about .DS_Store files, yet it's not clear what you are actually trying to achieve.
    – Tetsujin
    Commented Jul 21, 2022 at 7:00
  • It's rather complicated. Perhaps I should ask the big question, show what I've got so far, and go from there.
    – MikMak
    Commented Jul 21, 2022 at 8:41
  • Possibly. Sometimes an overall 'mission statement' can push an enquiry in a different direction. At the moment it kind of feels like you're trying to find them & set to a specific date, or remove them [either of which will work until the next time you open the window;) If you just want to bin them, such as Cocktail, Onyx, Tinker Tool all have a drag & drop 'sweeper', though interestingly, it doesn't seem to make it forget all prior parameters. I never figured out where it stores the rest.
    – Tetsujin
    Commented Jul 21, 2022 at 8:45
  • I posted the info on the main task I'm trying to accomplish here: superuser.com/questions/1732814/…
    – MikMak
    Commented Jul 21, 2022 at 17:56

1 Answer 1

2

Solution

The following find command does what I think you want:

braces={} find TARGET_PATH -type d \( -empty -o -exec sh -c '
   echo | find "$1" ! \( -type f -name .DS_Store \) -exec sh -c "
      for f do read dummy || { kill -s INT \"\$PPID\"; exit 1; } ; done
   " inner-sh "$braces" +
' outer-sh {} \; \) -print

Explanation

The command prints directories for which -empty evaluates to true or a custom -exec test evaluates to true. The custom test is designed to ignore regular files named .DS_Store and to tell if it sees a given directory as empty then.

The custom test is an sh process named outer-sh. From the main find it gets exactly one pathname of a directory. It runs another find with the directory as a single starting point. This inner find is designed to ignore regular files with the name .DS_Store. It finds at least its starting point, possibly more files. It passes them to another custom "test", an sh process named inner-sh. This time it's -exec … +, so possibly more than one pathname is provided to sh. I call it "test" (with quotes) because it's not really a test in the context of find; its job is not to test something for the find that runs it; its job is to kill this find in some circumstances.

The inner custom "test" is designed to tell if it got more than one positional argument. It could simply check its $#, but the inner find is allowed to decide that even two pathnames generate a command line that is too long, it is allowed to invoke sh with one argument, leaving remaining pathnames (if any) for separate sh process(es). Here a trick with echo kicks in.

The trick with echo is this sole echo piped to the inner find. Sole echo prints exactly one empty line (i.e. one newline character). The inner find does not read from its stdin, but our inner sh "test(s)" may; and they do. For each pathname read dummy is performed. The first pathname is the starting point of the inner find, read dummy will consume the line from echo, it will succeed. The second pathname may or may not be provided (depending if the directory is non-empty or empty after ignoring possible .DS_Store); if it's provided then there will be another read dummy and it will fail (regardless if performed by the same sh as the first one or not; this is the trick).

If read dummy fails then the shell performing the inner "test" will kill its parent and exit. The parent is the inner find. Killing it makes it stop processing files and spawning more shs, even if otherwise it was going to.

In other words, if in the directory there is a file other than .DS_Store then there will be read dummy that will fail and this will lead to killing the inner find, so the exit status from the inner find seen by the outer sh will be non-zero.

In case the inner find notices the demise of the inner sh before noticing the signal, and if there's nothing more for it to do, it may(?) exit by itself. The inner sh exits with failure (exit 1) after the kill, so even then the inner find will exit with a non-zero exit status (-exec utility … + is always true as a test, but if the utility fails then find shall return non-zero exit status). I doubt this scenario of find exiting before it's killed (when it's going to be killed) is possible, I suppose it should react to the signal first. exit 1 is just in case I'm wrong.

So if in the directory there is a file other than .DS_Store, the outer sh will see the inner find failing, it will itself exit with a non-zero exit status. This will be interpreted by the outer find as the custom test evaluated as false, so -print will not be performed.

If in the directory there is no file other than .DS_Store then there will be exactly one inner sh and exactly one read dummy. There will be no kill. Several things will succeed in sequence: read, the inner sh, the inner find, the outer sh; and then the outer find will consider the custom test evaluated as true, so -print will be performed.


Notes

  • I deliberately kept everything inside the main find (as opposed to e.g. find … | awk …), so now you can add something (-exec …, -delete, …) after, before or instead of -print.

  • About braces={}. If I literally used {} where the inner find … -exec … expects it, it might be expanded by the outer find (depending on implementation) and totally break the code. By storing {} as a variable in the environment and letting the right shell expand it, I hid this {} from the outer find.

  • The custom test runs several processes per tested directory. Do not expect the whole command to perform as well as find TARGET_PATH -type d -empty.

  • You used -empty, so I know your find supports it. In general -empty is not a portable operand. Our custom test allows you to remove -empty -o from the code (and then the outermost \( \) pair is not needed, it may stay though). The custom test itself detects truly empty directories. This means you can use the solution with implementations of find that don't support -empty. Still, thanks to how -o works (-test1 -o -test2 skips -test2 if -test1 returns true), if you can use -empty then use it because for truly empty directories it speeds things up.

  • In the inner find I could use -exec … \; instead of -exec … +. A dilemma: is it better to use + and let find query the filesystem ahead to build a possibly large list to provide to the inner sh, when at most two pathnames suffice? or is it better to use \; and often spawn two inner sh processes, but without a need of reading from the filesystem ahead (and without the for loop)? The trick with echo allows us to use \; and you can test which variant is better for you. In my tests on a reasonably large directory tree + was overall slightly faster, but your mileage may vary.

    If you ever need to use the solution with some old find that doesn't support -exec … +, convert to -exec … \; freely.

  • To tell if a directory is empty, -empty needs a read permission on the directory. If reading is not permitted and therefore -empty cannot know if the directory is empty then it will assume non-empty and evaluate as false. As far as I tested, our custom test behaves in the same way; so regardless if you use it with or without -empty -o, you should get results you would get from -empty if you could tell it to ignore .DS_Store files.

    The difference is our code prints (to stderr) more "copies" of permission denied per directory without read permission. I don't expect this to ever be a problem.

  • To tell if a directory is empty, -empty does not need an execute permission on the directory. I deliberately crafted the solution without cd and without -execdir, so neither our custom test needs execute permission.

  • Change -empty -o to ! -empty and you will find directories with lone .DS_Store files, but not truly empty directories.

1
  • Wow! This is an impressive and elegant way to perform this operation. I also really appreciate the detailed explanation so that I can understand how the solution works instead of just knowing that it does. 🙂
    – MikMak
    Commented Jul 31, 2022 at 1:39

You must log in to answer this question.

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