Unix find -exec: Why do the following behaviors differ? - bash

The following works as intended:
$ find . -name .git -exec dirname '{}' \;
./google/guava
./JetBrains/intellij-community
./zsh-users/zsh-syntax-highlighting
But the following only returns dots:
$ find . -name .git -exec echo "$(dirname '{}')" \;
.
.
.
Why is that, and how can I use $(dirname '{}') in a find -exec command?
Please note, I am asking about UNIX find (OS X and FreeBSD, specifically), not GNU.

Reason for behaviour difference
Your shell is evaluating the $(dirname) before find, leading to this command being executed :
find . -name .git -exec echo . ;
Other ways to do this
You can of course use shell expansion inside find, by calling another shell yourself (or better, calling a script using the shell you want as shebang).
In other words:
find . -name .git -exec sh -c 'dirname {}' \;
Solution without dirname (POSIX, faster, one less subprocess to call) :
find . -name .git -exec sh -c 'path={}; echo "${path%/*}" ' \;
Combing /u/tripleee's answer (upvote him not me) with the find optimization :
find . -name .git -exec sh -c 'for f; do echo "${f%/*}"; done' _ {} \+

If you're using gnu find then you can ditch dirname and use printf:
find . -name .git -printf "%h\n"

The general answer is to run -exec sh (or -exec bash if you need Bash features).
find . -name .git -exec sh -c 'for f; do dirname "$f"; done' _ {} \+
dirname can easily be replaced with something simpler, but in the general case, this is a useful pattern for when you want a shell to process the results from find.
-exec ... \+ is running the shell on as many matches as possible, instead of executing a separate shell for each match. This optimization is not available in all versions of find.
If you have completely regular file names (no newlines in the results, etc) you might be better off with something like
find . -name .git | sed 's%/[^/]*$%%'
However, assuming that file names will be regular is a huge recurring source of bugs. Don't do that for a general-purpose tool.

Related

Find command output to echo without variable assignment, in one line

I'm trying to write one line of code that finds all .sh files in the current directory and its subdirectories, and print them without the .sh extension (preferably without the path too).
I think I got the find command down. I tried using the output of
find . -type f -iname "*.sh" -print
as input for echo, and formatting it along these lines
echo "${find_output%.sh}"
However, I cannot get it to work in one line, without variable assigment.
I got inspiration from this answer on stackoverflow https://stackoverflow.com/a/18639136/15124805
to use this line:
echo "${$( find . -type f -iname "*.sh" -print)%.sh}"
But I get this error:
ash: ${$( find . -type f -iname "*.sh" -print)%.sh}: bad substitution
I also tried using xargs
find . -type f -iname "*.sh" -print |"${xargs%.sh}" echo
But I get a "command not found error" -probably I didn't use xargs correctly, but I'm not sure how I could improve this or if it's the right way to go.
How can I make this work?
That's the classic useless use of echo. You simply want
find . -type f -iname "*.sh" -exec basename {} .sh \;
If you have GNU find, you can also do this with -printf.
However, basename only matches .sh literally, so if you really expect extensions with different variants of capitalization, you need a different approach.
For the record, the syntax you tried to use for xargs would attempt to use the value of a variable named xargs. The correct syntax would be something like
find . -type f -iname "*.sh" -print |
xargs -n 1 sh -c 'echo "${1%.[Ss][Hh]}"' _
but that's obviously rather convoluted. In some more detail, you need sh because the parameter expansion you are trying to use is a feature of the shell, not of echo (or xargs, or etc).
(You can slightly optimize by using a loop:
find . -type f -iname "*.sh" -print |
xargs sh -c 'for f; do
echo "${f%.[Ss][Hh]}"
done' _
but this is still not robust for all file names; see also https://mywiki.wooledge.org/BashFAQ/020 for probably more than you realized you needed to know about this topic. If you have GNU find and GNU xargs, you can use find ... -print0 | xargs -r0)

Bash script to return all elements given an extension, without using print flags

I want to create shell script that search inside all folders of the actual directory and return all files that satisfy some condition, but without using any print flag.
(Here the condition is to end with .py)
What I have done:
find . -name '*.py'| sed -n 's/\.py$//p'
The output:
./123
./test
./abc/dfe/test3
./testing
./test2
What I would like to achieve:
123
test
test3
testing
test2
Use -exec:
find . -name '*.py' -exec sh -c 'for f; do f=${f%.py}; echo "${f##*/}"; done' sh {} +
If GNU basename is an option, you can simplify this to
find . -name '*.py' -exec basename -s .py {} +
POSIX basename is a little more expensive, as you'll have to call it on every file individually:
find . -name '*.py' -exec basename {} .py \;
Using GNU grep instead of sed:
find . -name '*.py' | grep -oP '[^/]+(?=\.py$)'
If portability is not a concern, this is a very readable option:
find . -name '*.py' | xargs basename -a
This is also differentiated from chepner's answer in that it retains the .py file ending in the output.
I'm not familiar with the -exec flag, and I'm sure his one-liners can be customized to do the same, but I couldn't do so off the top of my head.
Chepner's version achieves the same with the small modification:
find . -name '*.py' -exec basename {} \;
if you want the literal output from find and didn't intend to drop the file endings when you used dummy variables (123,test, etc.) in your question.
find shows entries relative to where you ask it to search, you can simply replace the . with a *:
find * -name '*.py'| sed -n 's/\.py$//p'
(Be aware that this skips top level hidden directories)
This might work for you (GNU parallel):
find . -name '*.py*' 2>/dev/null | parallel echo "{/.}"

Find and rename files by pattern works in Debian, but not in CentOS7

I need to find and rename files with question mark in names.
Example: "style.css?ver=111" should become "style.css"
I use this command
find . -type f -name "*\?*" -exec rename 's/\?.*//' '{}' \;
In Debian all works fine, but in CentOS7 I get and error that "rename: not enough arguments
"
Any ideas why?
For a reliable option that should work in any POSIX-compliant system, you may use
find . -type f -name "*\?*" -exec sh -c 'mv -- "$1" "${1%%\?*}"' findshell {} \;
$1 is the name of each file found and ${1%%\?*} is a construct that strips the substring starting from the question mark.
That should be enough if you have a few matching files. If you need it, a more efficient alternative is
find . -type f -name "*\?*" -exec sh -c '
for file in "$#"; do
mv -- "$file" "${file%%\?*}"
done
' findshell {} +

Bash '.' vs proper dir string

I have been running a number of find commands and have noticed something that seems odd about how bash handles the . vs a dir inputted as a string.
find . -type f -exec sh -c 'cd $(dirname "$0") && aunpack "$0"' {} \;
acts completely differently to
find [current dir] -type f -exec sh -c 'cd $(dirname "$0") && aunpack "$0"' {} \;
What gives?
Does bash treat '.' and a string specified directory path differently. Isn't '.' a substitute for the current dir?
What find does is append the rest of the path to the location passed as an argument.
Ie: if you are in dir "/home/user/find":
find .
Prints:
.
./a
./b
But if you try:
find /home/user/find
It prints:
/home/user/find
/home/user/find/a
/home/user/find/b
So find appends the rest of the path (/a, /b...) to the argument (. or /home/user/find).
You could use the pwd command instead of the . and it will behave the same.
find "`pwd`" -type f -exec sh -c 'cd $(dirname "$0") && aunpack "$0"' {} \;
#arutaku has pinpointed the source of the problem; let me point out another possible solution. If your version of find supports it, the -execdir primary does what you want very simply: it cd's to the directory each file is in, then executes the command with just the filename (no path):
find . -type f -execdir aunpack {} \;
Bash has nothing to do with it, it's the logic of find. It does not try to expand or normalize the path(s) you give, it just uses them verbatim: not only for . but for any path specification (e.g. ../../my/other/project).
I find it reasonable because any conversion would be more complicated than the current behavior. At least we would have to remember if symbolic links are resolved during conversion. And whenever we want a relative path for some reason, we would have to relativize it again.

recursively rename directories in bash

I'd like to recursively rename all directories containing the string foo by replacing that part of the string with Bar. I've got something like this so far, but it doesn't quite work. I'd also like foo to be searched case-insensitive.
find . -type d -exec bash -c 'mv "$1" "${1//foo/Bar}"' -- {} \;
Are there any elegant one-liners that might be better than this attempt? I've actually tried a few but thought I'd defer to the experts. Note: i'm doing this on a Mac OS X system, and don't have tools like rename installed.
Try the following code using parameter expansion
find . -type d -iname '*foo*' -depth -exec bash -c '
echo mv "$1" "${1//[Ff][Oo][Oo]/BAr}"
' -- {} \;
But your best bet will be the prename command (sometimes named rename or file-rename)
find . -type d -iname '*foo*' -depth -exec rename 's#Foo#Bar#gi' {} +
And if you are using bash4 or zsh (** mean recursive):
shopt -s globstar
rename -n 's#Foo#Bar#gi' **/*foo*/
If it fit your needs, remove the -n (dry run) switch to rename for real.
SOME DOC
rename was originally written by Perl's dad, Larry Wall himself.
I suspect the problem is getting it to work with mkdir -p foo/foo/foo.
In this regard, I think a solution based on find will likely not work because the list of paths is probably predetermined.
The following is in no way elegant, and stretches the definition of a one-liner, but works for the above test.
$ mkdir -p foo/foo/foo
$ (shopt -s nullglob && _() { for P in "$1"*/; do Q="${P//[Ff][Oo][Oo]/bar}"; mv -- "$P" "$Q"; _ "$Q"; done } && _ ./)
$ find
.
./bar
./bar/bar
./bar/bar/bar
find . -type d -iname '*foo*' -exec bash -O nocasematch -c \
'[[ $1 =~ (foo) ]] && mv "$1" "${1//${BASH_REMATCH[1]}/Bar}"' -- {} \;
Pro: Avoids sed.
Con: Will not find all matches if there are multiple in different cases.
Con: Is ridiculous.
Thanks #Gilles Quenot and Wenli: The following worked for me. I based it on both of your solutions.
find . -depth -type d -name 'ReplaceMe*' -execdir bash -c 'mv "$1" "${1/ReplaceMe/ReplaceWith}"' -- {} \;
The -execdir seems to be key on linux Red hat 7.6
I've been searching similar answers and this one worked:
find . -depth -name 'foo' -execdir bash -c 'mv "$0" ${0//foo/Bar}"' {} \;

Resources