Commits in Git form a Directed Acyclic Graph or DAG.
In any graph, we have an adjacency list (since a graph is defined as G = (V, E) where V and E are the vertex and edge sets respectively): any given vertex / node in the graph is either one hop away from some other vertex, through some edge, or is further away or is not connected at all:
C--D
/
B------E G--H
\ /
A F
Node A connects to B (and vice versa), B connects to C and E, and so on. G and H connect to each other, but not to the rest of the graph, so this graph consists of two disjoint sub-graphs. (These edges do not have directions.)
In a directed graph, there is a notion of predecessor and successor as each connection has an arrow:
A->B->C
|
v
D
Here A connects to B, and B connects to both C and D. So B is the successor of A, and C and D are both successors of B (but are not connected to each other). Put in terms of predecessors, A is the predecessor of B, and B is the predecessor of both C and D.
If the graph has no loops—is acyclic—we can perform a transitive closure operation using the predecessor/successor operations. Git's graph is both directed and acyclic—it's a DAG—so we can do this iwth Git.
Inside Git, the arrows are actually backwards (for implementation reasons), but we still perform the same transitive closure. When we do so, we find that A is an ancestor of B, C, and D:
A <-B <-C
^
|
D
because we can start from any of these commits and work our way back to A. B is an ancestor of C and D. The opposite relationship is descendant: D is a descendant of A.
One must be a bit careful here. There is no ancestor or descendant relationship between C and D. That is, C is not an ancestor of D, but C is not a descendant of D either. You cannot just say "not ancestor therefore descendant": the two may simply be not relatable. This is also true for some commits if the DAG has disjoint subgraphs, which is allowed:
A <-B <-C <-- master1
D <-E <-- master2
While D is an ancestor of E, D has no relationship to any of A, B, or C.
In any case, the Git command that tests for "ancestor-ness" is:
git merge-base --is-ancestor
which takes two commit hash IDs (or other commit specifiers) and answers, as a true or false query (via exit status with 0 meaning "yes, is an ancestor"), the question: is the commit named by the first argument an ancestor of the commit named by the second. That is:
hash1=$(git rev-parse $thing1^{commit}) || die "$thing1 does not name a git commit"
hash2=$(git rev-parse $thing2^{commit}) || die "$thing2 does not name a git commit"
if git merge-base --is-ancestor $hash1 $hash2; then
echo "$thing1 is an ancestor of $thing2"
else
if git merge-base --is-ancestor $hash2 $hash1; then
echo "$thing1 is a descendant of $thing2"
else
echo "$thing1 and $thing2 are not relatable"
fi
fi
The "not relatable" answer emerges because, as we saw above, we get only a partial order, not a total order, from our predecessor/successor notion.
(For Git's purposes, a commit is its own ancestor, so git merge-base --is-ancestor $hash1 $hash1
is always true.)