kanaka
kanaka

Reputation: 73119

git commit a subset (by filename) of staged changes

I have the following situation:

$ git status --porcelain
 M A
M  B
A  C
D  D
AM E
MM F
MM G

I want to commit only the staged changes for F and G. I don't want to commit the unstaged changes for F and G and I also don't want to commit any changes for A -> E.

Things that don't work:

# commits all staged and F, G unstaged
git commit F G

# same as above
git commit -o F G

# commits all staged + unstaged F, G (basically same as above)
git commit -i F G

# interactively add more to the index, keeps all already staged
git commit -p

# as above but limits interactive choices to F, G unstaged changes
git commit -p F G

# almost, but stash pop results in merge conflicts for F, G
git stash -k
git commit F G
git stash pop

Update: I am aware that this can be done with additional direct file copies. But I'm wanting something that's less manual.

Upvotes: 3

Views: 227

Answers (4)

LeGEC
LeGEC

Reputation: 51850

Your situation can be handled manually with simple commands :

# if you care to distinguish between "content of E when it was added as a new file"
# and "new modified content of E" :
cp E E.new
git checkout E

# then :
git reset B C D E

# only F and G are still staged at this point :
git commit

# re-stage B C D E :
git add B C D E

# again, if you care about diff between E.new and E :
mv E.new E

If you are using git version 2.23 or later : "restoring the index or the working tree" can be done using git restore


If you don't have git restore, here is a systematic way to do it :

# 1. use a working branch
$ git checkout -b wip

# 2. create a commit with your currently staged changes
$ git commit
# say created commit id is aacf32b

# 3. create a commit with the non staged changes
$ git add -u
$ git commit
# say created commit id is bb3498d

# 4. go back to the branch you actually wanted to modify :
$ git checkout master

# 5. use commit created at 2. to fetch the staged content you wanted to commit :
$ git checkout aacf32b -- F G
$ git commit

# 6. restore the remaining staged content on top of this commit :
$ git checkout aacf32b -- .
# as you noted : you need to apply one special treatment for deleted files :
$ git diff --diff-filter=D --name-only HEAD aacf32b | xargs git rm
$ git commit -m "temp commit - index"

# 7. restore the modified content you saved at step 3. :
$ git checkout bb3498d -- .
# likewise : manual step to also apply "delete" actions :
$ git diff --diff-filter=D --name-only HEAD bb3498d | xargs rm
$ git reset HEAD

# 8. return to the commit you wanted (the one created at step 5.),
#    while keeping the staged content in the index :
$ git reset --soft HEAD^

# check that everything is fine, then drop the wip branch
$ git branch -D wip

Upvotes: 1

torek
torek

Reputation: 488183

Most things you do here will be fairly manual, including all the git stash methods, which really just make two commits from the current index and the current work-tree, then use git reset --hard or equivalent to put everything back to the way it appears in HEAD. The two commits from git stash are on no branch, but can be restored later using git stash apply. That's not what I would do here though. Consider first doing this manually, then—if you are doing this often—turning the manual process into a script (see example below).

One key is to note that there's only one distinguished index ("the" index), but you can have other index files. You can have as many temporary index files as you want. Set the environment variable GIT_INDEX_FILE to the name of a file that does not exist, e.g.:

export GIT_INDEX_FILE=$(realpath $(git rev-parse --git-dir))/index$$

(this depends on your shell's PID making a unique file name) or:

tf=$(mktemp); rm -f $tf; export GIT_INDEX_FILE="$tf"

You now have nothing staged in this temporary index. Fill it from the current commit so that it matches the current commit:

git read-tree HEAD

All files are now re-set to match HEAD. Now comes the hard part:

I want to commit only the staged changes for F and G

Those are blobs in the repository and/or mode changes in the (main) index. We must unset GIT_INDEX_FILE to read them. We could do this before creating the temporary index, but to do it now, we can just use a subshell in which we unset the environment variable:

(unset GIT_INDEX_FILE && git ls-file --staged F G) | git update-index --index-info

We now have files F and G in our temporary index, staged for commit, and we can run git commit with no options, or with -m or similar, to commit from this temporary index.

Having made the new commit, you need only unset GIT_INDEX_FILE (though to clean up you should also remove the temporary index, rm -f $GIT_INDEX_FILE). If you do all of this in a script, exiting the script (after removing any temporary file) suffices.

A skeleton script, quite untested but with concern for possible corner cases, looks like this:

#! /bin/sh

case $# in
0) echo "usage: git onlycommit file1 ... fileN"; exit 1;;
esac

# exit (and clean up) on error
set -e
tf=$(mktemp)
rm -f "$tf"
trap "rm -f \"$tf\"" 0 1 2 3 15

# if GIT_INDEX_FILE is already set, make our reset use that value;
# otherwise, make our reset just unset it
if [ "${GIT_INDEX_FILE+true}" = true ]; then
    reset_index="export GIT_INDEX_FILE=\"${GIT_INDEX_FILE}"\"
else
    reset_index="unset GIT_INDEX_FILE"
fi
export GIT_INDEX_FILE="$tf"

git read-tree HEAD
(eval $reset_index; git ls-files --staged "$@") | git update-index --index-info
git commit

This should be tested, and probably fancied-up too, e.g., it should probably first test that all argument files are marked "staged for commit", i.e., the current index entries differ from those in HEAD. It should probably also make sure you're not in the middle of a conflicted merge, or at least, that the selected files aren't.

If you call it git-onlycommit and put it in your $PATH you should be able to run it as git onlycommit F G.

Upvotes: 1

John Szakmeister
John Szakmeister

Reputation: 47032

Note: back up your working tree first, just in case something goes wrong here as there is the potential to misstep and lose data.

You should consider using git stash here:

# Stash all changes, but keeps the staged changes.
git stash --keep-index

# Unstage and discard the changes in the unwanted files.
git reset B C D E
git checkout B C D E

# Now you are left with the changes in F and G.
git commit

Once you're done, you can do git stash pop to re-apply all the changes you had before. Previously staged changes should be staged again, but this time with the previously staged changes in F and G being no-ops (you should not see any differences here).

It's a little convoluted, but it's the best that you can do with Git right now as there is no option for git commit to selectively commit staged changes for specific files.

Upvotes: 1

Aaron
Aaron

Reputation: 2685

The easiest way of doing this is as follows:

$ git stash                     # (1)
$ git checkout stash@{0}^2 F G  # (2)
# The index now contains only the cached changes for F & G... commit them as desired
$ git stash pop --index         # (3)

After stashing at (1) above, if you do a git log --oneline --graph --all --decorate you can see that the stash command creates two commits. The top-most commit is the state of the working copy prior to the the stash. This commit has two parents. The first is the previous HEAD that the stash was based on and the second (our ^2 on (2) above) which has the state from the index.

Following the checkout in (2) you have only the index modifications for the files specified, F and G.

The last command,(3), re-applies the stash, keeping the prior separation of the cached state in the index and the working copy state. This is an optional resumption of your previous state.

Upvotes: 1

Related Questions