45

I am trying to rename many files in my application and need to be able to do a rename in all subdirectories from the app root through git (i.e. git mv %filenamematch% %replacement%) that only replaces the matching text. I'm no good with bash scripting though.

update: would be good it if also renamed directories that match as well!

fasked
  • 3,555
  • 1
  • 19
  • 36
Jonathan
  • 10,936
  • 8
  • 64
  • 79
  • Do you have an example? What '%filenamematch%' like; is it always at the beginning or end or middle or what? What does the %replacement% look like? – GoZoner Apr 03 '12 at 03:44
  • It could person.rb or lookitisa_person_here.html. In both cases the person needs to be matched and changed from that to something else. It should also be case sensitive – Jonathan Apr 03 '12 at 11:44

9 Answers9

47

This should do the trick:

for file in $(git ls-files | grep %filenamematch% | sed -e 's/\(%filenamematch%[^/]*\).*/\1/' | uniq); do git mv $file $(echo $file | sed -e 's/%filenamematch%/%replacement%/'); done

To follow what this is doing, you'll need to understand piping with "|" and command substitution with "$(...)". These powerful shell constructs allow us to combine several commands to get the result we need. See Pipelines and Command Substitution.

Here's what's going on in this one-liner:

  1. git ls-files: This produces a list of files in the Git repository. It's similar to what you could get from ls, except it only outputs Git project files. Starting from this list ensures that nothing in your .git/ directory gets touched.

  2. | grep %filenamematch%: We take the list from git ls-files and pipe it through grep to filter it down to only the file names containing the word or pattern we're looking for.

  3. | sed -e 's/\(%filenamematch%[^/]*\).*/\1/': We pipe these matches through sed (the stream editor), executing (-e) sed's s (substitute) command to chop off any / and subsequent characters after our matching directory (if it happens to be one).

  4. | uniq: In cases where the match is a directory, now that we've chopped off contained directories and files, there could be many matching lines. We use uniq to make them all into one line.

  5. for file in ...: The shell's "for" command will iterate through all the items (file names) in the list. Each filename in turn, it assigns to the variable "$file" and then executes the command after the semicolon (;).

  6. sed -e 's/%filenamematch%/%replacement%/': We use echo to pipe each filename through sed, using it's substitute command again--this time to perform our pattern replacement on the filename.

  7. git mv: We use this git command to mv the existing file ($file) to the new filename (the one altered by sed).

One way to understand this better would be to observe each of these steps in isolation. To do that, run the commands below in your shell, and observe the output. All of these are non-destructive, only producing lists for your observation:

  1. git ls-files

  2. git ls-files | grep %filenamematch%

  3. git ls-files | grep %filenamematch% | sed -e 's/\(%filenamematch%[^/]*\).*/\1/'

  4. git ls-files | grep %filenamematch% | sed -e 's/\(%filenamematch%[^/]*\).*/\1/' | uniq

  5. for file in $(git ls-files | grep %filenamematch% | sed -e 's/\(%filenamematch%[^/]*\).*/\1/' | uniq); do echo $file; done

  6. for file in $(git ls-files | grep %filenamematch% | sed -e 's/\(%filenamematch%[^/]*\).*/\1/' | uniq); do echo $file | sed -e 's/%filenamematch%/%replacement%/'; done

mrTroback
  • 3
  • 3
Jonathan Camenisch
  • 3,472
  • 1
  • 20
  • 9
  • Any chance you could explain what this is doing? I don't understand. Also, I updated my comment on my question, the filename match could be a part of the filename to be changed, because it could be some_person.html or person.rb, in both cases only the 'person' part should be matched and changed – Jonathan Apr 03 '12 at 11:55
  • 1
    Sure. This is one of those bash one-liners that might look a little daunting. Maybe others would have suggestions on making it more concise or readable. I'll add some explanatory notes. – Jonathan Camenisch Apr 03 '12 at 13:37
  • 1
    Note: this command does have a flaw: if you have any directories that match your replacement pattern, they will cause an error. In other words, it will try to do a move with `git mv path/person/file.rb path/alien/file.rb`, which will probably fail if the target directory doesn't exist. To fix that, we can add a little logic to the patterns we're searching for, to ensure the matched string has no slashes after it. – Jonathan Camenisch Apr 03 '12 at 14:41
  • One additional thing which I overlooked is it should work for directories too! Any chance this can be included? This would then be super effective! update: Yes I noticed that, in this case - it would actually also be beneficial that the directories matching are also renamed – Jonathan Apr 03 '12 at 14:44
  • 1
    The more robust pattern would look about like this: `for file in $(git ls-files | grep '%filenamematch%[^/]*$); git mv $file $(echo $file | sed -e 's/%filenamematch%/%replacement%[^/]*$/')` However, I'd need to test to make sure I'm using grep and sed's regex syntax properly. They're not always exactly the same as what I'm used to. – Jonathan Camenisch Apr 03 '12 at 14:47
  • Ah, OK. The only problem then is that the directory renames might error out if the target directories don't exist. I'm not sure how git mv handles that. – Jonathan Camenisch Apr 03 '12 at 14:48
  • The great thing about Git is that you can try this, and always revert if it does any damage. – Jonathan Camenisch Apr 03 '12 at 14:49
  • If the directory doesn't exist, I believe need to create it first. `git mv -f` won't. – Gregg Lind Apr 03 '12 at 14:53
  • Fantastic intro answer to stackoverflow Jonathan, this saves so much time faffing over a naming mistake in a large app IMO. I think this script will be really useful. It would be more so if it could do dir renames too – Jonathan Apr 03 '12 at 14:58
  • Ah, that truly would be a script. :) We'll have to write a little Git porcelain command. Hmm. – Jonathan Camenisch Apr 03 '12 at 15:04
  • OK, I added a little more sed magic to handle moving of directories as well as files. `git mv [directory]` works in my tests. We just need to chop it down to the directory to make that work. – Jonathan Camenisch Apr 03 '12 at 15:33
  • The weekness left in that is if you have path/match/.../match/.... Only the first match will get moved. After that, you could run the command again until no stragglers are left--as long as your new filenames didn't contain your old ones. Because nothing, of course, is allowed to be simple. :) – Jonathan Camenisch Apr 03 '12 at 15:33
  • 1
    On that, it turns out you might not need `sed` at all: `do git mv $file ${file//old/new}`. See: http://mywiki.wooledge.org/BashFAQ/100 – Gregg Lind Apr 03 '12 at 15:58
  • That's very cool. I guess that's Bash only, right? Not that my answer is tested across shells, but most of it should work in sh, I think. Thanks for the tip. – Jonathan Camenisch Apr 04 '12 at 04:21
  • 7
    I had to surround the git move statement with ``do %git mv ....%; done`` to make it work as described at http://www.cyberciti.biz/faq/linux-unix-bash-for-loop-one-line-command/ – dmeu Jun 03 '14 at 10:39
  • @feos et al: see [the answer by Gregg Lind](https://stackoverflow.com/a/9984757/1575353) which has a _bash_ option – Sᴀᴍ Onᴇᴌᴀ Jun 06 '19 at 21:38
20

Rename the files with a regular expression using the command rename:

rename 's/old/new/' *

Then register the changes with Git by adding the new files and deleting the old ones:

git add .
git ls-files -z --deleted | xargs -0 git rm

In newer versions of Git you can use the --all flag instead:

git add --all .
Flimm
  • 136,138
  • 45
  • 251
  • 267
  • Is this the same as `git mv`ing a file? Is the history preserved? – R.D. Jan 12 '17 at 21:16
  • Yes, it's the same. `git mv` is always the same as `git rm` and `git add` together, Git does not store additional metadata for moves the way other version control systems do. – Flimm Jan 16 '17 at 08:54
  • Nice approach and easy enough to understand. For me, though, it was `rename old new *` where the first occurrence of `new` in each filename (`*`) is replaced with `old`. – Chris K Mar 31 '17 at 07:34
  • 1
    TIL `--all` flag. I think this is the clearest way to do this. – Kyle Heironimus Nov 27 '17 at 20:26
  • This is exactly what I was looking for. `rename` already provides a clean way to rename multiple files. I was looking for something like a `git rename` instead of `git mv`, and this does effectively the same job by using `rename` followed by a simple `git add --all`. Easily the best solution here. – Praveen Oct 13 '18 at 17:45
16

Late to the party but, this should work in BASH (for files and directories, but I'd be careful regarding directories):

find . -name '*foo*' -exec bash -c 'file={}; git mv $file ${file/foo/bar}' \;
GoZoner
  • 67,920
  • 20
  • 95
  • 145
  • Would be cool if you explained where before and after goes in the script. Between `*foo*`, and `/foo/bar` foo=before bar=after is my assumption – Jonathan Apr 03 '12 at 16:23
  • 2
    Yes. Before is 'foo' (person in your earlier comment). After is 'bar'. The BASH shell syntax ${variable/pattern-to-match/replace-with} is used to generate the new name. – GoZoner Apr 03 '12 at 17:36
  • 1
    I get `sh: 1: Bad substitution` on Ubuntu 14.04 unless I replaced `sh` with `bash`, see [here](https://gist.github.com/dukedave/2de1c6a93548bcaffd8a). – davetapley Aug 15 '15 at 00:36
  • @GoZoner oh, is `${file/foo/bar}` the ["shell parameter expansion" technique?](https://ss64.com/bash/syntax-expand.html) – Nate Anderson Feb 18 '18 at 00:41
9

git mv inside a shell loop?

What's the purpose of git-mv?

(Assuming you are on a platform with a reasonable shell!)

Building on the answer by @jonathan-camenish:

# things between backticks are 'subshell' commands.  I like the $() spelling over ``
# git ls-files     -> lists the files tracked by git, one per line
# | grep somestring -> pipes (i.e., "|") that list through a filter
#     '|' connects the output of one command to the input of the next
# leading to:  for file in some_filtered_list
# git mv  f1 f2  ->  renames the file, and informs git of the move.
# here 'f2' is constructed as the result of a subshell command
#     based on the sed command you listed earlier.

for file in `git ls-files | grep filenamematch`; do git mv $file `echo $file | sed -e 's/%filenamematch%/%replacement%/'`; done

Here is a longer example (in bash or similar)

mkdir blah; cd blah; 
touch old_{f1,f2,f3,f4} same_{f1,f2,f3}
git init && git add old_* same_* && git commit -m "first commit"
for file in $(git ls-files | grep old); do git mv $file $(echo $file | sed -e 's/old/new/'); done
git status
# On branch master
# Changes to be committed:
#   (use "git reset HEAD <file>..." to unstage)
#
#   renamed:    old_f1 -> new_f1
#   renamed:    old_f2 -> new_f2
#   renamed:    old_f3 -> new_f3
#   renamed:    old_f4 -> new_f4
#

see also: Ad Hoc Data Analysis From The Unix Command Line

Sᴀᴍ Onᴇᴌᴀ
  • 8,218
  • 8
  • 36
  • 58
Gregg Lind
  • 20,690
  • 15
  • 67
  • 81
4

This worked well for my use case:

ls folder*/*.css | while read line; do git mv -- $line ${line%.css}.scss; done;

Explanation:

  • ls folder*/*.css - Uses ls to get a list of all directories with CSS files that match the glob pattern. (Directories starting with folder and containing files with .css extensions)
  • while read line - Reading in the resulting output of the ls command line-by-line
  • do git mv -- $line ${line%.css}.css - Execute git mv on the line-by-line output ($line variable contains each line) while matching the beginning of each filename and excluding the .css extension (with ${line% and adding a new .scss extension (-- is used to prevent ambiguity between filenames and flags)

Code below can be used for a "dry run" (won't actually execute git mv):

ls variant*/*.css | while read line; do echo git mv $line to ${line%.css}.scss; done;
Alex W
  • 37,233
  • 13
  • 109
  • 109
1

I solved this for myself by using https://github.com/75lb/renamer - worked perfectly :-) Doesn't explicitly do a git mv but git seemed to deal with the results perfectly anyway.

When I followed all the steps in the top answer I got stuck at the final step, when my console responded with

for pipe quote>

If anyone can explain this I'd appreciate it!

samjewell
  • 1,068
  • 11
  • 20
1

Here's the general approach for doing it in PowerShell using foreach().

  1. Use foreach($varname in Get-ChildItem …) {…} to iterate the files using the variable $varname. Here I'm using Get-ChildItem because that's the official name of the cmdlet, but you can use dir or ls or whatever custom alias you've assigned to Get-ChildItem.
  2. Use [io.path]::ChangeExtension($varname, "new-ext")) to change the extension. (Thanks to https://stackoverflow.com/a/12120352 .)
  3. Use $(…) to evaluate the sub-expression in place in your new command, e.g. as an argument to git mv.

Thus if you want use git to rename all *.bat files to *.cmd, you would use the following. I've formatted it nicely, but you can also type it all on one line.

foreach ($bat in Get-ChildItem *.bat) {
  git mv $bat $([io.path]::ChangeExtension($bat, "cmd"))
}
Garret Wilson
  • 18,219
  • 30
  • 144
  • 272
  • doesnt work for changing files that end in .pre.py to files that end in .py. probably because it counts only the second dot for the extension. – timwaagh Apr 23 '23 at 10:37
0

Here is how I renamed all my .less files inside ./src folder to .scss

find ./src -type f |grep "\.less$" | while read line; do git mv -- $line ${line%.less}.scss; done;
Loi Nguyen Huynh
  • 8,492
  • 2
  • 29
  • 52
0

I am usually using NetBeans to do this type of stuff because I avoid the command line when there is a easier way. NetBeans has some support for git, and you can use it on arbitrary directory/file via the "Favorites" tab.

Vincent Cantin
  • 16,192
  • 2
  • 35
  • 57