user3179490 user3179490 - 1 month ago 16
Git Question

How to edit files in an old tag without deleting current changes

I worked for a while on a project for university on the master branch.

At each step we are supposed to add a TAG. However i found out recently that i have to fix a little thing in a previous tag.

I searched and i found many solutions (based on reset --hard or something) but i dont want to lose everything i made since especially that the edit i have to make is very minor.

I know that we are not supposed to change anything in a tag but the edit i have to make is minor.

So what is the best way to edit a file in an old commit (made a tag) without losing changes after that commit ?

Thank you

Answer

You can't. There is something you can do (below the line), but what you are asking for is literally impossible. The reason is simple enough, although it comes in two parts:

  1. A tag is just a name for a commit. (It may be a name for an "annotated tag object" that in turn names a commit, but this ultimately amounts to the same thing.)

    Each commit—really, each Git object—is uniquely identified by its hash ID. The command git rev-parse turns names (such as branch and tag names) into these hash IDs:

    $ git rev-parse origin/master
    659889482ac63411daea38b2c3d127842ea04e4d
    $ git rev-parse v2.2.1
    7c56b20857837de401f79db236651a1bd886fbbb
    

    The main reason to use a tag (such as v2.2.1) instead of the hash ID is ... well, who can remember 7c5b208-aw-the-heck-with-it, and how will we ever compare that to v2.5.2? I know: the computer can remember this for us! Let's have the computer remember the hash ID, and call that a "tag".

    The difference between a tag name and a branch name is that a tag is supposed to stay the same forever, and a branch is supposed to move, in a "forward one step" fashion as you make commits—Git does this for you automatically—or sometimes in a big "fast forward over many commits" fashion (as when you pick up new updates from elsewhere).

  2. The raw hash ID of a commit is a cryptographic checksum of the contents of that commit. This is in fact true for all Git objects: hashes are SHA-1 checksums of contents. If you change even one single bit in anything you have Git store, you get a new, different hash ID.

    The contents of any commit depend on all the files in that commit, plus all the history leading up to that commit. I find it instructive to view an actual commit:

    $ git cat-file -p origin/master | sed 's/@/ /'
    tree 3424a8d14d91a3aaf7b9d5c962f5dd36ff90953c
    parent 720265749d905ab8da275f18abc42b2dac751dff
    parent 23415c26fef155f2fa9aebf8a48a6ae457b68c7b
    author Junio C Hamano <gitster pobox.com> 1476737546 -0700
    committer Junio C Hamano <gitster pobox.com> 1476737546 -0700
    
    Sync with maint
    
    * maint:
      l10n: de.po: translate 260 new messages
      l10n: de.po: fix translation of autostash
      l10n: ru.po: update Russian translation
    

    It's actually the tree line (which refers to a Git "tree" object) that lets the commit record a snapshot of the source. The tree object, and its ID, changes if you change the source, and that means that a new commit, one that uses the new-and-different tree object, is different from the old commit.

    Because the ID is a cryptographic checksum of the contents, no Git object can ever change. Git normally only adds new objects to the repository; existing objects stay unchanged forever, and objects are hardly ever deleted (we'll see more on this in a bit).

All that said—that you cannot edit files in an existing commit—you can make a new and different commit*, and then make a name for it. What if you remove the old name-to-ID mapping for the tag, and install this new, different name-to-ID mapping? That's called "re-tagging", and Linus Torvalds has advice for the re-tagger, right in the git tag documentation:

What should you do when you tag a wrong commit and you would want to re-tag?

If you never pushed anything out, just re-tag it. Use "-f" to replace the old one. And you’re done.

But if you have pushed things out (or others could just read your repository directly), then others will have already seen the old tag. In that case you can do one of two things:

  1. The sane thing. Just admit you screwed up, and use a different name. Others have already seen one tag-name, and if you keep the same name, you may be in the situation that two people both have "version X", but they actually have different "X"'s. So just call it "X.1" and be done with it.

  2. The insane thing. ...

(Follow the link to read the rest.)


If you want to make a new commit that's like the old commit but with a few things changed, you have some options. Let's start with this: Just make the new commit as usual, on a branch as usual.

Start by getting yourself a branch name pointing to the original commit:

before:
...--o--o--o--o--o--o   <-- master
           ^
           |
      tag: v2.5.2

$ git checkout -b newbranch v2.5.2

now:         o--o--o    <-- master
            /
...--o--o--o            <-- newbranch
           ^
           |
      tag: v2.5.2

(all we did here was shove the chain up a bit for master, and add a new branch label, newbranch, pointing to the same commit as the tag v2.5.2).

Now commits made on newbranch will extend newbranch as usual:

             o--o--o    <-- master
            /
...--o--o--o--o--o      <-- newbranch
           ^
           |
      tag: v2.5.2

When you're done extending newbranch with however many commits you want, you can either re-tag (forcing v2.5.2 to point to the new tip commit), or create a new tag. Use Linus's advice to know which is sensible.

Besides this, you may have read about git commit --amend. This seems to change a commit. That's a lie—a white lie in the good cases, and sort of a grey or dun one in some others; and it will bite you viciously in the bad cases if you are not aware of what it really does.

What git commit --amend does is modify the normal process of making a new commit. The normal process is that the new commit is added on to the current branch:

before:
...--A--B--C   <-- branch

$ git commit ...

after:
...--A--B--C--D   <-- branch

What git commit --amend does is to shove the current commit aside, so that the "after" part of this picture looks like this instead:

          C    [abandoned]
         /
...--A--B--D   <-- branch

Note that commit C, the previous tip of branch, is completely unmodified. It lives on inside your repository, intact and unchanged, until a later "garbage collection" pass (git gc) finds out if it really is abandoned. Something called a reflog hangs on to it for at least 30 days by default, so that you can get it back if you discover you need it after all; and if there is some other branch or tag name, through which Git can find commit C some other way, those hang on to the commit "forever" (or until those branch and/or tag names are all deleted).

Let's take a look at what happens if we use the "shove the commit aside" method with newbranch in the "after" picture that looked like this:

             o--o--o    <-- master
            /
...--o--o--o            <-- newbranch
           ^
           |
      tag: v2.5.2

We'll git checkout newbranch if needed, so that we're on it. Then we'll make some changes in our work-tree, git add, and git commit --amend. This will shove the tagged commit aside, but master still chains right back through it, and the tag still points to it. The drawing is going to get messy, so let's put one-letter names on each commit, and stop drawing in the tag for the moment. Here's the "before amend" picture again, with the letter names:

             D--E--F    <-- master
            /
...--A--B--C            <-- newbranch

Now we do the "amend", which pushes C aside. However, D still points back to C, so we draw new commit G in where C was, but move C up out of the way and have D point to it. Note that C also still points back to B:

          C--D--E--F    <-- master
         /
...--A--B--G            <-- newbranch

Now we can draw the tag again—it still points to commit C, so let's draw it in from above:

     tag: v2.5.2
          |
          v
          C--D--E--F    <-- master
         /
...--A--B--G            <-- newbranch

In fact, it might be better to draw this another way entirely:

      tag: v2.5.2
           |
           v
...--A--B--C--D--E--F   <-- master
         \
          G             <-- newbranch

And now it's really clear how --amend did not amend anything at all. What it did instead was wind newbranch back one step while making new commit G, so that G points back to B, instead of pointing to C.

Again, at this point, you can either "re-tag" your old tag (if that's a sane thing to do), or just make a new tag pointing to commit G.

Comments