Search code examples
gitgit-mergegithooksgit-stash

Is there a way to `git stash` every file in the project (not just changes)?


I've run into an issue where I believe the best solution would be to capture the current state of the project and apply it to a new commit in a new branch.

Here's an overview of my git workflow:

  • I keep a local copy of our origin/master branch on my machine which I'll refer to as local/master.
  • I use local/master for comparison when debugging changes in my own local/feature branches. For instance, if I notice a bug in my local/feature branch I might switch back to the local/master branch to see if it reproduces there. This lets me know if the bug was pre-existing or caused by my own changes.
  • I also use local/master as an intermediate staging branch when performing merges between origin/master and my local/feature branches. This prevents the source branch (master) from being a moving target in case I need to abandon the merge and start over.
  • I only update local/master by pulling from origin/master. This is done whenever other devs merge their own changes into origin/master.
  • I submit my code by pushing from local/feature to origin/feature and submitting a merge request from origin/feature into origin/master.

Here are the chronological steps that led to this situation:

  1. I forked a branch from local/master as local/feature1.
  2. I made several commits to local/feature1.
  3. A feature was merged into origin/master which moved some .pem files (public/private keys) from one directory to another.
  4. A hook was added to origin (a private Gitlab instance) which prevented any subsequent .pem files from being checked in.
  5. I pulled from origin/master into local/master.
  6. I merged from local/master into local/feature1.
  7. I made several more commits to local/feature1.
  8. I attempted to push my code from local/feature1 into origin as origin/feature1.
  9. origin complained that I wasn't allowed to check in .pem files.

I believe this is happening because the commit history in local/feature1 now contains a merge commit which included the .pem file changes. This file was added to origin/master before the hook was implemented, so it wasn't subject to the hook restrictions. However, the merge commit into my branch is being submitted afterwards along with my code changes, so it's getting flagged by the hook.

At this point I'd like to remove the .pem files from local/feature1's commit history without actually removing the files. Rebasing and squashing the commits at this point won't solve the problem since the .pem files are already there. Deleting the .pem files won't help since they actually need to be in the project (unrelated story). Reverting my branch to an unmerged state isn't an option either since the uploaded origin/feature would then be too far out of date with origin/master for the review to make sense.

The obvious solution seems to be:

# Copy current state of the project to a backup directory
cp -r . ../backup
rm -rf ../backup/.git

# Reset project to latest official state
git checkout local/master
git pull

# Regenerate local branch so the merged changes aren't included as local commits
git branch -D local/feature1
git checkout -b local/feature2

# Copy the desired project state back in and commit it
cp -rf ../backup/. .
git add --all
git commit -m "Regenerating feature branch"
rm -rf ../backup

# Upload without being restricted by hook
git push --set-upstream origin feature2

I feel like there ought to be a way to do this natively with git without the need to mess with the file system directly. Is there some magic trick to doing this? Perhaps via git stash?


Solution

  • There's no need for git stash here. git stash already saves complete snapshots, but so do all commits. No commit is a diff. Git turns a commit into a diff by comparing it to another complete snapshot: whatever is different in the two snapshots, that's the diff. Many commands, including git stash, do this a lot, by comparing the commit to its parent(s). For instance, git cherry-pick compares the to-be-picked commit to its parent, to see what changed, and then applies those changes to wherever you are now (doing a second diff as well and using the merge engine).

    A git commit makes its snapshot from the contents of the index, not from what's in the work-tree. So, if you want a new commit on some existing branch that exactly matches any existing commit, all you have to do is delete everything in the index and replace it all with everything from some other commit. (The work-tree comes along for the ride, so that you can see what happens.) There's an obvious, simple way to do this:

    $ git checkout br1
    $ git rm -r .             # from the top level: remove everything
    $ git checkout br2 -- .   # extract everything from commit at tip of br2
    $ git commit
    

    This makes a new commit, which adds to the tip of br1, whose contents are from br2. (No untracked files come along for the ride since untracked files are by definition not in the index, but do be careful about files tracked in the extracted commit that were untracked in the old br1 tip, and vice versa.)

    There's a shorter version of the same:

    $ git read-tree -m -u br2
    $ git commit
    

    which causes less churn in the work-tree (sometimes important for, e.g., make): read-tree reads the commit into the index, with the -u making Git update the work-tree to match. Since there's only one argument to git read-tree here, this throws out the current index contents, removing any file that is in it (and in the work-tree) that's not in the br2 commit. As with the longer variant, this doesn't affect untracked files.