jimmy keen

on .NET, C# and unit testing

Reset a file with Git command line (Windows)

August 22, 2016 | tags: git windows powershell xargs

I use bunch of Git commands and aliases during my daily work. Although I am quite happy with my current setup, one thing has been lacking for a while – a simple command to undo changes made to file. Even though Git offers a default solution, I never really liked it. Recently, I finally managed to create a viable replacement.

To stage or to unstage

Before we begin, it is important to understand what happens when you modify a file tracked by Git. First, it is marked as unstaged which means this file has changed compared to its previous version but it is not commit ready yet. When you run git status it is mentioned explicitly:

> git status
On branch master
Your branch is up-to-date with 'origin/master'.
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)

        modified:   CsharpKatas/Xml/Creation.cs

no changes added to commit (use "git add" and/or "git commit -a")

Also note that commiting at this point produces no commit as there is nothing to include in it:

> git commit -m "Fix"
On branch master
Your branch is up-to-date with 'origin/master'.
Changes not staged for commit:
        modified:   CsharpKatas/Xml/Creation.cs

no changes added to commit

We have been already hinted as to what to do next which is git add <file>. Most of the times you are probably using git add -A to include all unstaged files in soon-to-be commit.

Once a file is added with git add it becomes staged – or in other words – commit ready. This file will be included in the next commit you create. As expected, status command recognizes this fact:

> git add -A
> git status
On branch master
Your branch is up-to-date with 'origin/master'.
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

        modified:   CsharpKatas/Xml/Creation.cs

Quick recap:

Commands mentioned above constitute to vast majority of what Git shell user executes daily. My usual workflow (and I can imagine many other people’s) is:

  1. Modify files
  2. Verify: git status
  3. Commit: git add -A then git commit -m "Fixed"
  4. Push git push origin HEAD

These commands are so common that I use very short aliases for them: git s, git c "Fixed" (this also does git add -A) and git up. This makes interaction with shell very smooth.

Sins of automation

Unfortunately, this approach has one major flaw. It is a bulk-operation thanks to git add -A + git commit combo. Imagine you modified 10 files but changes in one of them need to be discarded. Documentation suggest to use checkout command:

git checkout -- <path-to-file>

This is quite some typing. However:

What I would like instead is simple undo command:

git undo <file-name>

Putting together all the pieces

There is a bunch of Git commands and tools to help us here. What needs to be done is:

  1. With git diff we can get unstaged files paths (using --name-only switch)
  2. Filter them to the one matching pattern using unix grep
  3. Feed this path to git checkout -- using unix xargs

Last two commands don’t occur naturally in Windows environment but luckily Windows Git distribution includes them. Just make sure you have Git bin directory in PATH.

Alright, assume that our working directory contains two modifed files, like this:

> git status
On branch master
Your branch is up-to-date with 'origin/master'.
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)

        modified:   CsharpKatas/Xml/Creation.cs
        modified:   CsharpKatas/Xml/Querying.cs

no changes added to commit (use "git add" and/or "git commit -a")

Let’s try and match Quotation.cs with git diff and grep:

> git diff --name-only
CsharpKatas/Xml/Creation.cs
CsharpKatas/Xml/Querying.cs
> git diff --name-only | grep Query
CsharpKatas/Xml/Querying.cs

Almost there. All we need to do left is somehow pass this to git checkout --. Regular piping does not work and we need a special command, the xargs. What it does is simply pass its arguments to a command of choice. The final command looks like this:

git diff --name-only | grep Query | xargs git checkout --

Running this we got:

> git diff --name-only | grep Query | xargs git checkout --
' did not match any file(s) known to git..cs

Come again?

Story behind xargs

Judging by the strange output, could it be that Windows’ carriage return and line feed (aka new line) is causing problems? Let’s investigate what xargs does with --verbose switch:

> git diff --name-only | grep Query | xargs --verbose git checkout --
 it checkout -- CsharpKatas/Xml/Querying.cs
' did not match any file(s) known to git..cs

Yep, it seems we are on the right track with \r symbol obfuscating output. New line confusion is a common issue with xargs which uses whitespaces and newlines to separate arguments. Unix commands deal with this problem with -print0 switch which changes default argument separator to \0, end-of-string character. On Windows we do not have such option.

To mitigate this problem we could simply say that both \r\n are argument separator for xargs. Let’s see where this takes us:

> git diff --name-only | grep Query | xargs -d \r\n --verbose git checkout --
git checkout -- CsharpKatas/Xml/Querying.cs

error: pathspec '
' did not match any file(s) known to git.

Closer. It looks like xargs seems to think we have two arguments, path and the newline. But we only want to pass one argument to git checkout invocation and xargs has a switch to do just that:

> git diff --name-only | grep Query | xargs --verbose -d \r\n -n 1 git checkout --
git checkout -- CsharpKatas/Xml/Querying.cs
git checkout --

error: pathspec '
' did not match any file(s) known to git.

What happened here? The -n 1 switch changed single invocation with two arguments (path and newline) to two invocations with single argument each (one with path, one with newline). And this means we managed to successfully undo changes to one file. While second invocation is definitely excessive, I was not able to get rid of it.

Note that all this was tested with Powershell 5. To see what version you run, use $PSVersionTable.PSVersion. For example, Powershell 3 seems to work with \r\n differently and requires slightly modified combination:

git diff --name-only | grep Query | tr -s '\r\n' '\0' | xargs -d '\0' -n 1 git checkout --

In brief, it is a simple emulation of unix approach where end-of-string character is used to separate arguments.

Wrapping up

There is one more thing we need to do – enclose this command as git alias. And this causes yet another issues. The behavior regarding \r\n changes once again with inline function. Powershell 3 version of the command seems to be the most robust and this is the one I use. Remembering few Powershell tricks we arrive at:

> git status
On branch master
Your branch is up-to-date with 'origin/master'.
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)

        modified:   CsharpKatas/Xml/Querying.cs

> git config --global alias.undo "!f() { git diff --name-only | grep `$1 | tr -s '\r\n' '\0' | xargs -d '\0' -n 1 git checkout -- ; }; f"
>
> git undo Query
> git status
On branch master
Your branch is up-to-date with 'origin/master'.
nothing to commit, working directory clean

We have an alias. In a similar fashion we can create unstage command to degrade once staged files (using diff with --staged switch and git reset HEAD --).