1

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.)

4

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.

  • man. I wish I knew about rename before I wrote that 80+ character oneliner. nice tip. – strugee May 10 '16 at 21:22
  • rename is a sort of underdog command. – lgaggini May 10 '16 at 21:25
  • as much as I'd like to accept my own answer, this is clearly more elegant. (plus, I won't get rep, but you will.) kudos. – strugee May 10 '16 at 21:26
  • 1
    Haven't tested this, but would the 'directory' find need -depth if there are subdirectories named etckeeper/etckeeper ? – Jeff Schaller May 10 '16 at 23:43
  • 2
    +1. but I'd use {} + rather than {} \; - forking a perl script like rename multiple times (once per file/dir) is expensive. – cas May 11 '16 at 1:34
1

Your original question is actually pretty easy to answer: xargs (at least on OS X) has an -I option, too, which does not require that the replacement string be a unique argument. So, the invocation simply becomes:

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

Easy peasy. Now, let's rename in the correct order. As it turns out, find will print directories first (because it has to process them before it descends into them), so all we need to do is reverse the order of the filenames:

% find . -path '*etckeeper*' -print | tail -r | xargs -n 1 -I % bash -c 'echo mv % $(echo % | sed "s/etckeeper/usrkeeper/g" )'

Note that I've switched find -print0 | xargs -0 for find -print | xargs. Why? Because tail -r reverses based on newlines, not null characters. If you didn't switch it, tail -r would print out the same thing you piped into it! So the script is more correct now, but also it will break on filenames that contain e.g. newlines.

Note that if you don't have tail -r, you should try tac. See: How can I reverse the order of lines in a file? on Stack Overflow.

Now, the issue is that the sed command is too aggressive. For example, with a tree like:

.
├── etckeeper-foo
│   └── etckeeper-bar.md
└── some-other-directory
    └── etckeeper-baz.md

You'll get mv invocations that look like:

mv ./some-other-directory/etckeeper-baz.md ./some-other-directory/usrkeeper-baz.md
mv ./etckeeper-foo/etckeeper-bar.md ./usrkeeper-foo/usrkeeper-bar.md
mv ./etckeeper-foo ./usrkeeper-foo

The first one's fine, but the second one's clearly not - since we haven't yet done mv ./etckeeper-foo ./usrkeeper-foo!

Let's only replace in the last pathname component. We can do this by using basename and dirname:

% find . -name '*etckeeper*' -print | tail -r | xargs -n 1 -I % bash -c 'echo mv % $(dirname %)/$(basename % | sed "s/etckeeper/usrkeeper/g" )'

Note that the other change I've made is using find -name, not find -path.

This produces the correct mv invocations:

mv ./some-other-directory/etckeeper-baz.md ./some-other-directory/usrkeeper-baz.md
mv ./etckeeper-foo/etckeeper-bar.md ./etckeeper-foo/usrkeeper-bar.md
mv ./etckeeper-foo ./usrkeeper-foo

Finally. Remember, once again, that this will fail on esoteric filenames. Note also that if your filenames are particularly long, xargs will not work properly because the arguments to mv become too long. You can (at least on OS X) pass -x to xargs to tell it to immediately fail if this is the case.

0

Another solution is to use a small script and do a for loop on the find results and a mv with a bash string replacement on the files found :

IFS=$'\n'
for files in $(find . -name "*etckeeper*"); 
do 
  mv "$files ${files/etckeeper/usrkeeper}"
done

If you don't use it in the script, then better save the original IFS and restore it at the end of the loop.

  • 3
    Avoid using the for f in $(find...) construct (it will fail if file names contain IFS characters). – don_crissti May 10 '16 at 21:31
  • yup i figured out, if we set IFS=$'\n' before the script, it won't fail. updated the script too. – mazs May 10 '16 at 21:39

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.