Support non-linear series histories

Josh Triplett created

Document the format of a git-series merge commit, which has multiple
git-series commits as parents in addition to the parents referencing
gitlinks.  (git-series does not yet generate such commits.)

Modify revision walking in "git series log" to walk a git-series merge
commit correctly.  ("git series log -p" will warn that it cannot show
diffs for such commits yet, as libgit2 doesn't support generating merge
diffs yet.)

Change summary

INTERNALS.md | 48 +++++++++++++++++++++++++------------------
src/main.rs  | 59 ++++++++++++++++++++++++++++++++++++++---------------
2 files changed, 70 insertions(+), 37 deletions(-)

Detailed changes

INTERNALS.md 🔗

@@ -39,28 +39,36 @@ In this documentation, a "git-series commit" refers to a commit corresponding
 to a version of an entire patch series, as distinguished from a commit
 corresponding to one patch within a patch series.
 
-The first parent of each git-series commit always points to the previous
-version of the patch series, if any.  The remaining parents of each git-series
-commit correspond to commits referenced as gitlinks (tree entries with mode
-160000) within the commit's tree.  This ensures that git can reach all of those
-commits.  (Note that git's traversal algorithm does not follow gitlink commits
-within tree objects, so without these additional parent links, git would
-consider these gitlink commits unreachable and discard them.)
-
-The second and subsequent parents of each git-series commit do not appear in
-any particular order; do not assume that the `series` object or any other
-gitlink appears at any particular position within the parents list.  These
-parents exist only to make commits reachable and transferable by git.  Always
-look up commits via named tree entries within the git-series commit's tree
-object.
+A git-series commit can have two types of parent commits: those connecting the
+history of the patch series, and those referencing gitlink commits that also
+appear in the git-series commit's tree.  A git-series commit can have any
+number of either type of parent, but all of the parents connecting the history
+of the patch series will always appear before any of the parents referencing
+gitlink commits.
+
+The parents connecting the history of the patch series, if any, point to
+previous git-series commits representing previous versions of the patch series;
+a git-series commit with more than one such parent represents a git-series
+merge commit.  The remaining parents of each git-series commit correspond to
+commits referenced as gitlinks (tree entries with mode 160000) within the
+commit's tree; this ensures that git can reach all of those commits.  (Note
+that git's traversal algorithm does not follow gitlink commits within tree
+objects, so without these additional parent links, git would consider these
+gitlink commits unreachable and discard them.)
+
+The parents of each git-series commit that reference gitlinks in that
+git-series commit's tree do not appear in any particular order; do not assume
+that the `series` object or any other gitlink appears at any particular
+position within the parents list.  These parents exist only to make commits
+reachable and transferable by git.  Always look up commits via named tree
+entries within the git-series commit's tree object.
 
 In the root git-series commit, all the parent commits correspond to gitlinks
-within the tree.  This will not occur for any non-root commit of a git-series.
-Algorithms trying to walk from a git-series commit to its root should detect
-the root git-series commit by checking if the first parent appears in the
-git-series commit's tree.  (This does not require a recursive tree walk; the
-first parent of the git-series root will always appear in the top-level tree
-object.)
+within the git-series commit's tree.  This will not occur for any non-root
+commit of a git-series.  Algorithms trying to walk git-series commits should
+filter out parents that appear in the git-series commit's tree.  (This does not
+require a recursive tree walk; the gitlinks within the git-series commit's tree
+will appear in the top-level tree object.)
 
 git-series tree entries
 -----------------------

src/main.rs 🔗

@@ -1202,41 +1202,66 @@ fn log(out: &mut Output, repo: &Repository, m: &ArgMatches) -> Result<()> {
     let color_commit = try!(out.get_color(&config, "diff", "commit", "yellow"));
 
     let mut revwalk = try!(repo.revwalk());
-    revwalk.simplify_first_parent();
     try!(revwalk.push_ref(SHEAD_REF));
 
+    // Walk once before sorting, to find all the commits to hide. Revwalk doesn't support hiding on
+    // the fly when sorted.
+    let mut hidden_ids = std::collections::HashSet::new();
+    while let Some(oid) = revwalk.next() {
+        let oid = try!(oid);
+        let commit = try!(repo.find_commit(oid));
+        let tree = try!(commit.tree());
+        for parent_id in commit.parent_ids() {
+            if tree.iter().find(|entry| entry.id() == parent_id).is_some() {
+                try!(revwalk.hide(parent_id));
+                hidden_ids.insert(parent_id);
+            }
+        }
+    }
+
+    // set_sorting resets the revwalk
+    revwalk.set_sorting(git2::SORT_TOPOLOGICAL);
+    try!(revwalk.push_ref(SHEAD_REF));
+    for id in hidden_ids {
+        try!(revwalk.hide(id));
+    }
+
     let show_diff = m.is_present("patch");
 
+    let mut first = true;
     for oid in revwalk {
+        if first {
+            first = false;
+        } else {
+            try!(writeln!(out, ""));
+        }
         let oid = try!(oid);
         let commit = try!(repo.find_commit(oid));
-        let tree = try!(commit.tree());
         let author = commit.author();
 
-        let first_parent_id = try!(commit.parent_id(0).map_err(|e| format!("Malformed series commit {}: {}", oid, e)));
-        let first_series_commit = tree.iter().find(|entry| entry.id() == first_parent_id).is_some();
-
         try!(writeln!(out, "{}", color_commit.paint(format!("commit {}", oid))));
         try!(writeln!(out, "Author: {} <{}>", author.name().unwrap(), author.email().unwrap()));
         try!(writeln!(out, "Date:   {}\n", date_822(author.when())));
         for line in commit.message().unwrap().lines() {
             try!(writeln!(out, "    {}", line));
         }
+
         if show_diff {
-            try!(writeln!(out, ""));
-            let parent_tree = if first_series_commit {
-                None
-            } else {
-                Some(try!(try!(repo.find_commit(first_parent_id)).tree()))
-            };
-            let diff = try!(repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), None));
-            try!(write_diff(out, &diff));
-        }
+            let tree = try!(commit.tree());
+            let parent_ids: Vec<_> = commit.parent_ids().take_while(|parent_id| tree.iter().find(|entry| &entry.id() == parent_id).is_none()).collect();
 
-        if first_series_commit {
-            break;
-        } else {
             try!(writeln!(out, ""));
+            if parent_ids.len() > 1 {
+                try!(writeln!(out, "(Diffs of series merge commits not yet supported)"));
+            } else {
+                let parent_tree = if parent_ids.len() == 0 {
+                    None
+                } else {
+                    Some(try!(try!(repo.find_commit(parent_ids[0])).tree()))
+                };
+                let diff = try!(repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), None));
+                try!(write_diff(out, &diff));
+            }
         }
     }