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 sh
s, 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.