How can I fast-forward a single git commit, programmatically?
Asked Answered
G

3

17

I periodically get message from git that look like this:

Your branch is behind the tracked remote branch 'local-master/master' 
by 3 commits, and can be fast-forwarded.

I would like to be able to write commands in a shell script that can do the following:

  1. How can I tell if my current branch can be fast-forwarded from the remote branch it is tracking?

  2. How can I tell how many commits "behind" my branch is?

  3. How can I fast-forward by just one commit, so that for example, my local branch would go from "behind by 3 commits" to "behind by 2 commits"?

(For those who are interested, I am trying to put together a quality git/darcs mirror.)

Granophyre answered 23/5, 2010 at 4:18 Comment(0)
I
10

The remote branch can be fast-forwarded to the local branch if the current commit is the ancestor of the remote branch head. In other words, if the "one-branch history" of the remote branch contains the current commit (because if it does, it is sure that the new commits were committed "onto" the current commit)

So a safe way to determine whether the remote branch can be fast-forwarded:

# Convert reference names to commit IDs
current_commit=$(git rev-parse HEAD)
remote_commit=$(git rev-parse remote_name/remote_branch_name)

# Call git log so that it prints only commit IDs
log=$(git log --topo-order --format='%H' $remote_commit | grep $current_commit)

# Check the existence of the current commit in the log
if [ ! -z "$log" ]
  then echo 'Remote branch can be fast-forwarded!'
fi

Note that git log was called without the --all parameter (which would list all branches), so it is not possible that the current commit is on a "side branch" and is still printed on the output.

The number of commits ahead of the current commit equals the number of rows in $log before $current_commit.

If you want to fast-forward only one commit, you take the row previous to the current commit (with grep -B 1, for example), and reset the local branch to this commit.

UPDATE: you can use git log commit1..commit2 to determine the number of fast-forwarding commits:

if [ ! -z "$log" ]
then
  # print the number of commits ahead of the current commit
  ff_commits=$(git log --topo-order --format='%H' \
    $current_commit..$remote_commit | wc -l)
  echo "Number of fast-forwarding commits: $ff_commits"

  # fast-forward only one commit
  if [ $ff_commits -gt 1 ]
  then
    next_commit=$(git log --topo-order --format='%H' \
      $current_commit..$remote_commit | tail -1)
    git reset --hard $next_commit
  fi
fi

Of course, you can do this with one git log call if you save the result of the first call into a file.

Inverter answered 28/5, 2010 at 22:53 Comment(4)
Thanks; this approach looks fairly principled. The man page for git-rev-parse is one of the worst I've seen... +1Granophyre
Well, the short description of the program ("Pick out and massage parameters") is really not too informative :D It is used above to convert a reference name (HEAD, origin/master) into a commit ID. I added some comments to the code, so it is probably easier to understand now.Inverter
Thanks for the explanation. What if instead of git reset --hard $next_commit, we had git merge $next_commit? Or am I missing something?Flood
git reset --hard was exactly what I was looking for to fast-forward a few commits and not the whole branch. Thanks.Handley
L
12

Alternate Approaches

You mention that you are working on some sort of mirror for Git and Darcs. Instead of dragging a working tree through history, you might instead look at the git fast-import and git fast-export commands to see if they offer a better way to manage the data you need to extract/provide.

How to Tell Whether a Branch Can Fast-Forward to Its Upstream Branch

There are two parts to this. First, you have to either know or determine which branch is the current branch’s “upstream”. Then, once you know how to refer to the upstream, you check for the ability to fast-forward.

Finding the Upstream for a Branch

Git 1.7.0 has a convenient way to query which branch a branch tracks (its “upstream” branch). The @{upstream} object specification syntax can be used as a branch specifier. As a bare name, it refers to the upstream branch for the branch that is currently checked out. As a suffix, it can be used to find the upstream branch for branches that are not currently checked out.

For Gits earlier than 1.7.0, you will have to parse the branch configuration options yourself (branch.name.remote and branch.name.merge). Alternatively, if you have a standard naming convention, you can just use that to determine a name for the upstream branch.

In this answer I will write upstream to refer to the commit at the tip of the branch that is upstream of the current branch.

Checking for Ability to Fast-Forward

A branch at commit A can be fast-forwarded to commit B if and only if A is an ancestor of B.

gyim shows one way to check for this condition (list all the commits reachable from B and check for A in the list). Perhaps a simpler way to check for this condition is to check that A is the merge base of A and B.

can_ff() {
    a="$(git rev-parse "$1")" &&
    test "$(git merge-base "$a" "$2")" = "$a"
}
if can_ff HEAD local-master/master; then
    echo can ff to local-master/master
else
    echo CAN NOT ff to local-master/master
fi

Finding the Number of “Commits Behind”

git rev-list ^HEAD upstream | wc -l

This does not require that HEAD can fast-forward to upstream (it only counts how far HEAD is behind upstream, not how far upstream is behind HEAD).

Move Forward by One Commit

In general, a fast-forward-able history may not be linear. In the history DAG below, master could fast-forward to upstream, but both A and B are “one commit forward” from master on the way towards upstream.

---o---o                      master
       |\
       | A--o--o--o--o--o--o  upstream
        \                 /
         B---o---o---o---o

You can follow one side as if it was a linear history, but only up to the immediate ancestor of the merge commit.

The revision walking commands have a --first-parent option that makes it easy to follow only the commits that lead to the first parent of merge commits. Combine this with git reset and you can effectively drag a branch “forward, one commit at a time”.

git reset --hard "$(git rev-list --first-parent --topo-order --reverse ^HEAD upstream | head -1)"

In a comment on another answer, you express from fear of git reset. If you are worried about corrupting some branch, then you can either use a temporary branch or use a detached HEAD as an unnamed branch. As long as your working tree is clean and you do not mind moving a branch (or the detached HEAD), git reset --hard will not trash anything. If you are still worried, you should seriously look into using git fast-export where you do not have to touch the working tree at all.

Following a different parent would be more difficult. You would probably have to write your own history walker so that you could give it advice as to “which direction” you wanted to go for each merge.

When you have moved forward to a point just short of the merge, the DAG will look like this (the topology is the same as before, it is only the master label that has moved):

---o---o--A--o--o--o--o--o    master
       |                  \
       |                   o  upstream
        \                 /
         B---o---o---o---o

At this point if you “move forward one commit”, you will move to the merge. This will also “bring in” (make reachable from master) all the commits from B up to the merge commit. If you assume that “moving forward one commit” will only add one commit to the history DAG, then this step will violate that assumption.

You will probably want to carefully consider what you really want to do in this case. It is OK to just drag in extra commits like this, or should there be some mechanism for “going back” to the parent of B and moving forward on that branch before you process the merge commit?

Longoria answered 29/5, 2010 at 6:22 Comment(1)
Lots of good info here, especially about git-merge-base, thanks. Most of it is 'traps and pitfalls' that I'd like to avoid. I think git-fast-export is fundamentally at odds with the way darcs does business, and that in fact the only way to get accurate info is to step through history one piece at a time. I hope that the way I'm using the repo will ensure that I avoid this problematic cases. +1Granophyre
I
10

The remote branch can be fast-forwarded to the local branch if the current commit is the ancestor of the remote branch head. In other words, if the "one-branch history" of the remote branch contains the current commit (because if it does, it is sure that the new commits were committed "onto" the current commit)

So a safe way to determine whether the remote branch can be fast-forwarded:

# Convert reference names to commit IDs
current_commit=$(git rev-parse HEAD)
remote_commit=$(git rev-parse remote_name/remote_branch_name)

# Call git log so that it prints only commit IDs
log=$(git log --topo-order --format='%H' $remote_commit | grep $current_commit)

# Check the existence of the current commit in the log
if [ ! -z "$log" ]
  then echo 'Remote branch can be fast-forwarded!'
fi

Note that git log was called without the --all parameter (which would list all branches), so it is not possible that the current commit is on a "side branch" and is still printed on the output.

The number of commits ahead of the current commit equals the number of rows in $log before $current_commit.

If you want to fast-forward only one commit, you take the row previous to the current commit (with grep -B 1, for example), and reset the local branch to this commit.

UPDATE: you can use git log commit1..commit2 to determine the number of fast-forwarding commits:

if [ ! -z "$log" ]
then
  # print the number of commits ahead of the current commit
  ff_commits=$(git log --topo-order --format='%H' \
    $current_commit..$remote_commit | wc -l)
  echo "Number of fast-forwarding commits: $ff_commits"

  # fast-forward only one commit
  if [ $ff_commits -gt 1 ]
  then
    next_commit=$(git log --topo-order --format='%H' \
      $current_commit..$remote_commit | tail -1)
    git reset --hard $next_commit
  fi
fi

Of course, you can do this with one git log call if you save the result of the first call into a file.

Inverter answered 28/5, 2010 at 22:53 Comment(4)
Thanks; this approach looks fairly principled. The man page for git-rev-parse is one of the worst I've seen... +1Granophyre
Well, the short description of the program ("Pick out and massage parameters") is really not too informative :D It is used above to convert a reference name (HEAD, origin/master) into a commit ID. I added some comments to the code, so it is probably easier to understand now.Inverter
Thanks for the explanation. What if instead of git reset --hard $next_commit, we had git merge $next_commit? Or am I missing something?Flood
git reset --hard was exactly what I was looking for to fast-forward a few commits and not the whole branch. Thanks.Handley
C
9

This is probably not the most elegant, but it works:

$ git fetch
$ git status | sed -n 2p
# Your branch is behind 'origin/master' by 23 commits, and can be fast-forwarded.
$ git reset origin/master~22 > /dev/null
$ git status | sed -n 2p
# Your branch is behind 'origin/master' by 22 commits, and can be fast-forwarded.
Convalescent answered 23/5, 2010 at 7:31 Comment(1)
Thanks +1. I'm a little fearful of the git reset---I had better be very sure the thing can be fast-forwarded. It's a bit sad that this behavior of git status is undocumented.Granophyre

© 2022 - 2024 — McMap. All rights reserved.