Shell – How to recursively replace a string in file and directory names

findrenameshellxargs

I have a git clone of etckeeper, and I'm trying to rename all files and directories with etckeeper in the name to usrkeeper. For example, ./foo-etckeeper-bar should be renamed to ./foo-usrkeeper-bar.

Finding the relevant files is trivial:

% find . -path '*etckeeper*' -print

However, I can't figure out how to actually do the renaming. I tried combining xargs with mv:

% find . -path '*etckeeper*' -print0 | xargs -0 -n 1 -J % bash -c mv % '$(echo' % \| sed \"s/etckeeper/usrkeeper/\" \)

For readability, the non-escaped second half reads: xargs -0 -n 1 -J % bash -c mv % $(echo % | sed "s/etckeeper/usrkeeper/" ). The idea behind it is that we use $() to pipe the filename through sed, which is used to do the replacement.

The issue here is that bash -c requires the command to execute to be a single string. After that, it starts interpreting arguments as positional parameters. I could quote the whole thing:

% find . -path '*etckeeper*' -print0 | xargs -0 -n 1 -J % bash -c 'mv % $(echo % | sed "s/etckeeper/usrkeeper/g" )'

But now xargs won't replace %. How can I solve this? (Also, as a side note, the above will fail if there's a file containing etckeeper in the name in a directory containing etckeeper in the name, because the directory will be moved before the file.)

Best Answer

You can use the rename command (see edit 1).

Solution 1

For a reasonable number of files/directory, by setting bash 4 option globstar (not works on recursive name, see edit 3):

shopt -s globstar
rename -n 's/etckeeper/userkeeper/g' **

Solution 2

For a big number of files/directories using rename and find in two steps to prevent failed rename on files in just renamed directories (see edit 2):

find . -type f -exec rename 's/etckeeper/userkeeper/g' {} \;
find . -type d -exec rename 's/etckeeper/userkeeper/g' {} \;

EDIT 1:

There are two different rename commands. This answer uses the Perl-based rename command, available in Debian-based distros. To have it on a not Debian based distro you can install from cpan or grab it around.

EDIT 2:

As pointed out by Jeff Schaller in the comments the -depth find option Process each directory's contents before the directory itself so only an "ordered" find by -depth option would be enough:

find . -depth -exec rename 's/etckeeper/userkeeper/g' {} \;

EDIT 3

Solution 1 doesn't work for recursive rename targets, (es. etckeeper/etckeeper/etckeeper) becasue outer levels are processed before inner levels and pointer to inner levels become useless. (After the first rename etckeeper/etckeeper/etckeeper will be named usrkeeper/etckeeper/etckeeper so the rename for etckeeper/etckeeper/ and etckeeper/etckeeper/etckeeper will fail). The same problem fixed in find by -depth options.

EDIT4

As pointed out in the comments by cas, I'd use {} + rather than {} \; - forking a perl script like rename multiple times (once per file/dir) is expensive.

Related Question