Skip to content

Instantly share code, notes, and snippets.

@zlrc
Last active June 9, 2024 07:28
Show Gist options
  • Save zlrc/f08a356955d03d19c7cced0494862fed to your computer and use it in GitHub Desktop.
Save zlrc/f08a356955d03d19c7cced0494862fed to your computer and use it in GitHub Desktop.
Git command aliases for backing up uncommitted changes to a new branch.
[alias]
# Creates a snapshot of the current working tree state and saves it to a new branch.
backup = "!f() { \
BACKUP_SOURCE_BRANCH=$(git symbolic-ref --short HEAD) \
&& BACKUP_TARGET_BRANCH=backup/$BACKUP_SOURCE_BRANCH-$(date +%Y_%m_%d_%H%M%S) \
&& git checkout -b $BACKUP_TARGET_BRANCH \
&& (git commit --no-verify --allow-empty -m \"Index (Staged Changes)\" \
&& git add -A \
&& git commit --no-verify --allow-empty -m \"Working Tree (Unstaged and Untracked)\" \
&& git checkout --detach HEAD \
&& git reset HEAD~1 \
&& git reset --soft $BACKUP_SOURCE_BRANCH); \
git checkout $BACKUP_SOURCE_BRANCH; \
echo Saved uncommitted changes to branch: $BACKUP_TARGET_BRANCH; \
unset BACKUP_SOURCE_BRANCH; \
unset BACKUP_TARGET_BRANCH; \
}; \
f"
# Lists all backups.
backup-list = branch --list "backup/*"
# Removes a backup branch by it's name (without the "backup/" prefix).
backup-remove = "!f() { git branch -d backup/$1; }; f"
# Restores branch to a saved backup, which is referenced by the backup's numeric suffix only.
backup-restore = "!f() { \
git reset --hard backup/$(git symbolic-ref --short HEAD)-$1 \
&& git clean -id \
&& git reset HEAD~1 \
&& git reset --soft HEAD~1 \
&& echo Restored from backup/$(git symbolic-ref --short HEAD)-$1; \
}; \
f"
# Shorthands for the above backup aliases.
backup-ls = backup-list
backup-rm = backup-remove
backup-res = backup-restore

Warning

It's best to only perform these backups on a branch that only you are working on, and are otherwise comfortable performing history-changing actions with (such as this or a rebase).

The intended use for these is to provide a way to back up your workspace to the remote without having to add unstable and incomplete commits to your branch history (while avoiding a rebase).

If you just need to create a quick temporary checkpoint before trying something out, consider staging your current changes without committing, instead. Any additional changes to the working tree can then be easily tracked and discarded, see here.

Manual Instructions

Creating the Backup

If running as an alias or shell script, you might wanna declare variables for the name of the current branch ("source") and the backup branch ("target"):

BACKUP_SOURCE_BRANCH=$(git symbolic-ref --short HEAD)
BACKUP_TARGET_BRANCH=backup/$BACKUP_SOURCE_BRANCH-$(date +%Y_%m_%d_%H%M%S)

git symbolic-ref is used here to obtain the name of the current branch. The ref is usually a full path to the branch inside the hidden .git directory (e.g. refs/heads/main), the --short flag truncates that path down to just the name of the branch (e.g. main).

Next, create a new branch and make separate commits for the staged and unstaged changes (this includes untracked files):

git checkout -b $BACKUP_TARGET_BRANCH
git commit --no-verify --allow-empty -m "Index (Staged Changes)"
git add -A
git commit --no-verify --allow-empty -m "Working Tree (Unstaged and Untracked)"

The --no-verify flag skips any pre-commit checks that might be present (since it is assumed these are incomplete and unstable changes, which could trip up any automated tests you might have set up).

The --allow-empty flag allows an empty commit to be created even if there are no staged changes. The reason why an empty commit is desired is to assure there will always be these two commits sitting at the top of this backup branch, making the branch state predictable for the following steps.

If we go back to the source branch now, you'll notice there are no longer any changes to commit (since you just committed all of them to the backup branch!). What needs to be done next is, while still on the backup branch that was created, enter a detached HEAD state and reset the two commits:

git checkout --detach HEAD
git reset HEAD~1
git reset --soft $BACKUP_SOURCE_BRANCH

What entering a detached HEAD state with checkout --detach HEAD does is point to the HEAD commit (in this case: "Working Tree (Unstaged and Untracked)") without pointing to the whole branch, itself. This allows certain changes to be made to the repository without affecting what's saved on any of the branches.

For example, the git reset HEAD~1 command above resets the "Working Tree (Unstaged and Untracked)" commit that we created earlier by going back 1 commit from the HEAD, and unstages all those changes. While in a detached HEAD state however, the backup and source branch history remain intact.

The second reset command above resets the "Index (Staged Changes)" commit by reverting back to the most recent commit on the source branch (without actually checking out the branch itself, since it's still in a detached HEAD state). A --soft flag is used so that changes on that "Index" commit are automatically staged after the reset, and the "Working Tree" commit remains as unstaged changes.

Next step is to leave the detached HEAD state and return to the source branch with the current index and working tree state:

git checkout $BACKUP_SOURCE_BRANCH

Now we are back to the exact state we were in before making the backup. You can verify the branch commit history remains unchanged by typing git log --oneline.

If you created variables for the source and target branch names, be sure to unset those too:

unset BACKUP_SOURCE_BRANCH
unset BACKUP_TARGET_BRANCH 

With this backup saved to its own branch, it can now be pushed to GitHub (or some other remote repository) where you don't have to worry about losing it in the event of hard drive failure. Deleting the backup is also as simple as just deleting the branch, too!

Restoring From Backup

This involves steps similar to what needed to be done while in the detached HEAD state during the backup-creation process, only now we are actually modifying the history of the branch in order to restore it from backup:

git reset --hard <name_of_backup_branch>

Note

For the aliases, the names of backup branches include a timestamp suffix. When using the backup-restore alias, only that suffix must be entered to reference the backup to restore from (with the prefix inferred to be the current branch name). Other than to save a little time typing out the branch name, this also serves as a small safety measure to prevent backups from being accidentally restored to the wrong branch.

The --hard flag discards files and changes made after the referenced commit (the backup branch, in this case) rather than keeping them like in the resets done prior. Some untracked files might still be left behind though, and will need to be handled separately using git clean:

git clean -id

The -d flag also assures that untracked directories will be recursively deleted, as well.

Occasionally, an ignored file might get caught by this command if a .gitignore rule was created after the backup. To prevent accidentally deleting ignored files that you might want to keep, the -i is used to activate interactive mode, which prompts the user with a list of files to be deleted and asks for confirmation.

After that, restore the working tree and index states like what was done in the prior section:

git reset HEAD~1
git reset --soft HEAD~1

Congrats! You successfully restored the branch from a backup. Be aware that a force push will be required next time you try syncing this with the remote repository, and anyone else who might be working on this branch will need to be notified of the change, too.

References

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment