Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Make a snapshot of working directory with git

I sometimes need to make a snapshot of the current (possibly dirty) working directory. git stash save -u is very similar to what I need but there are two problems:

  1. I want my working directory to stay in the same state (keep untracked files untracked)
  2. In case I need to come back to the saved state (maybe a month later), it would not be easy to git stash apply because I first need to find the state before git stash.

I currently have the following sequence of commands that works for me but I would like to know if there is a more elegant way of doing it.

# on branch master
git add .
git commit -m "Temporary commit on the original branch"
git checkout -b snapshot_of_master_yyyy-mm-dd-hh-mm-ss HEAD~
git merge master
git checkout master
git reset HEAD^

Thank you everyone for the answers and explanations! I am going to do something like this mostly based on @XavierGuihot's answer

git stash -u # -u to include untracked files
git checkout -b snapshot_of_master_yyyy-mm-dd-hh-mm-ss
git stash apply
git add --all
git commit -m "Snapshot"
git checkout master
git stash pop --index # --index to recover the state of indexed files
like image 508
Ryota Tomioka Avatar asked Feb 18 '18 21:02

Ryota Tomioka


4 Answers

You can stash your changes, create a new branch, apply your changes, perform the commit only on the new branch and then checkout master again before finally applying your changes again.

git stash
git checkout -b snapshot_of_master_yyyy-mm-dd-hh-mm-ss
git stash apply
git add --all
git commit -m "Snapshot"
git checkout master
git stash pop
like image 196
Xavier Guihot Avatar answered Oct 07 '22 03:10

Xavier Guihot


Most efficient with no worktree churn is to do it directly with core commands, assuming you don't have any inflight merge conflicts or intent-to-add markers in the index it's

statenow=`git write-tree`
stashedindex=`git commit-tree -p @ -m "index snap" $statenow`
git add -f .
git tag snap-`date +%Y-%m-%dT%H.%M.%S` \
            $(git commit-tree -p @ -p $stashedindex \
                    -m "worktree snap" \
                    $(git write-tree)
            )
git read-tree $statenow

but the simplest way if you don't care about ignored files or no-effect worktree churn will be

git stash -a -u
git tag snap-`date +%Y-%m-%dT%H.%M.%S stash
git stash pop

then for either, to recover the state you do e.g.

git clean -dfx
git checkout snap-2018-02-18T14.19.15           # to move HEAD + worktree there
# or
git read-tree -um @ snap-2018-02-18T14.19.15    # just the worktree

git read-tree @ snap-2018-02-18T14.19.15^2      # then restore the index
like image 20
jthill Avatar answered Oct 07 '22 02:10

jthill


TL;DR

Use jthill's answer, which he wrote while I was away from the keyboard between stages of writing the long answer below.

Long

Before you settle on any one answer to this question (see jthill's answer for the set I think are in some sense "the best"), consider this: Whenever you have a state that isn't just "everything tracked in the work-tree exactly matches HEAD", there are three versions of each file to worry about.

That is, when you first run:

git clone <url>

or, from a totally clean (no untracked files, no modified files, etc.) state do:

git checkout <somebranch>

you start out with three copies of each file, such as README and Makefile and so on, available right now:

  • One in HEAD (the tip commit of whichever branch you have checked out): this one is read-only and of course matches the one in HEAD, because it is the one in HEAD. This HEAD:README file is stored in the special Git-only format that Git uses. (Use git show HEAD:README to see it.)

  • One in the index. The index is where you will build your next commit, but right now, it just contains a copy of everything that is in the current commit. So :0:README—you can use git show :0:README to see this copy—exactly matches HEAD:README. This extra copy is also stored in the special Git-only format, which means it takes essentially no space at all. The difference between :0:README and HEAD:README is that you can overwrite this one: git add README copies the work-tree README into :0:README, for instance. (The copy you make here will take some space, but will then be what goes into the next git commit, after which they'll share that frozen / read-only version, until you copy yet another one in.)

  • The last copy of each file, such as README, is in the work-tree. This file is in its normal everyday format, so that all programs can read and write it. There's no need to view it with git show because it's just a normal file!

Initially, all three versions match, and there are no untracked files.

So:

I sometimes need to make a snapshot of the current (possibly dirty) working directory.

for every file with path P except for untracked files, we have:

  • the version of P that is in the HEAD commit: you never need to save this one, as it's already saved, permanently, by being in a commit;
  • the version of P that is in the index: do you want to save this one?; and
  • the version of P that is in the work-tree: you no doubt do want to save this one.

That also leaves the question of what to do with the untracked files.

Let's note here that an untracked file is simply a file in the work-tree that does not exist in the index. (This includes files that are in HEAD but that you carefully removed from the index—they're currently untracked, as long as the work-tree version of that file exists.)

git stash save -u is very similar to what I need ...

That is a good clue, because git stash save -u saves:

  • the current index (as one commit);
  • the current work-tree (as another commit, tracked files only); and
  • the untracked files (as a third commit). Note that the third commit omits files that are untracked-and-ignored (there is no such thing a as a file that is both tracked and ignored; "ignored" applies only to files that are already untracked). If you want the ignored files as well you must use -a instead of -u here.

... but there are two problems: [1. git stash -u removes the untracked files after committing them, and 2. git stash -u makes it rather hard to un-stash again later]

Note that git stash -u simply runs git clean to remove the untracked files. You would have to do a git reset --hard && git clean -df to get back to the state to unstash (but see the problem case I mention below).

Now, the main problem with making a commit—any commit—is that you do it by copying files into the index. But we just noted that there could be index versions of various files, like :0:README and :0:path/to/important.data, that differ from their HEAD: counterparts and from their work-tree counterparts. If you do anything to save the work-tree counterparts, you must do it by overwriting the index copy!

If this is okay, you could possibly have a path forward that's less complicated than using git stash or equivalent. But you still have a problem with untracked files! If it's not OK, you must save the index first, just like git stash does, in which case you might want to just use git stash.

We already noted, above, that an untracked file is a file that's in the work-tree—has some path U—that is not also in the index: there's no :0:U. This creates something of a problem: to save those files, we have to copy them into the index. This, of course, destroys (overwrites) anything we carefully staged that's different from both HEAD and work-tree version. This is where the all the complications come from.

If you do want to preserve the index state for any reason, and the index state records which files should be tracked and untracked later, then we have our solution (which is jthill's solution as well), which is much like git stash, but slightly modified:

  • Write out the current index state: git write-tree.
  • Use the result to make a commit (permanent until it has no name, when it can be garbage collected): git commit-tree. This commit can have the current commit (HEAD or @) as its parent, although that's not actually required.
  • Add to the index all the untracked files (possibly including ignored files): git add -A, or git add -f -A, etc., depending on what precisely you want in this second commit.
  • Write this updated index, then use the result to make a second commit whose parent is the saved index state, and give this second commit a name to make it permanent. (In jthill's answer, just as git stash does, he has the second commit store both commits as parents, with the index commit as the second parent of the second commit. That forces us to use the suffix ^2 notation later, which has the advantage that it works with the git stash script.)

Having written all of these out, we must immediately put the index back into its original state—the one we saved as the first step. Otherwise all the previously-untracked files are now tracked files!

To restore one of these things, we have the same problem you have already with git stash save -u: the files in the second commit we made (to hold the untracked files) are going to become, at least temporarily, tracked files. If there are files with those same names in the work-tree right now, Git will be very reluctant to overwrite them—so we need a git clean -df or git clean -dfx to destroy them. There's a bit of a problem here because this will also remove files that aren't in that second commit: for instance, suppose when you saved everything, there was an untracked file named important-1 but nothing named important-2. Now there's an important-2.

If you just naively run git clean -df or git clean -dfx right now, Git will remove both of these untracked important-* files. Then we'll instruct Git to extract, from the second commit, all the files, including the previously-untracked important-1. Git will copy the file into both the index and the work-tree. Since there is no saved important-2, Git won't copy that file.

This is the rather large defect to using:

git clean -dfx
git checkout snap-2018-02-18T14.19.15

and is why:

git read-tree -um @ snap-2018-02-18T14.19.15
git read-tree @ snap-2018-02-18T14.19.15^2

is better. The first step, git read-tree -um @ snap-..., does a merge-and-update to bring in the second commit we made (holding all the work-tree state) into the index and update the work-tree. That way important-2 won't be destroyed.

The second step is needed to fix up the index afterward, because reading all those untracked files from the second commit causes them to become tracked files. We'd like to restore the index to the way it was when we made the snapshot, or at least, pull out of the index any files that are in it now that should not be in it.

We have exactly that index state: it's in the first commit we made, which is snap-...^2 (the second parent of the snapshot or the stash). We can either read that directly into the index:

git read-tree snap-2018-02-18T14.19.15^2

(note the lack of @ / HEAD here), or do a two-tree merge to leave modifications we've made to the index in place where possible:

git read-tree @ snap-2018-02-18T14.19.15^2

Note that we could, instead, just reset the index to match the commit you are currently on:

git reset HEAD

or perhaps reset it to the parent of the saved first commit:

git read-tree snap-2018-02-18T14.19.15^1

if you didn't really want to save the index state. Either way, the untracked files are once again untracked, now that they're no longer in the index.

like image 2
torek Avatar answered Oct 07 '22 01:10

torek


Another clean and simple approach. Even no branch changing nor stash needed:

git commit
git branch name-of-my-snapshot-branch
git reset HEAD^

With the commit just commit everything you want to snapshot, in whichever way you prefer to do this.

The reset points your branch to the former commit and so you are back to where you started.

like image 1
flori Avatar answered Oct 07 '22 01:10

flori