vcssync: API and command to squash Git commits (bug 1357597) draft
authorGregory Szorc <gps@mozilla.com>
Fri, 21 Apr 2017 16:47:38 -0700
changeset 10855 ef7942ef042c74c6825e40bb27d5a9363db80aa7
parent 10854 11f3682b65fa2ee7aceb39cc4bf9dd736a8b95db
child 10856 cf1a3a0439719aa4e7d8273bbe702a8c9e41b01f
push id1638
push userbmo:gps@mozilla.com
push dateSat, 22 Apr 2017 00:35:48 +0000
bugs1357597
vcssync: API and command to squash Git commits (bug 1357597) Many pull requests on GitHub don't practice the Mozilla-preferred style of linear, commit-level bisectable history. Furthermore, getting people to change is hard, especially when most GitHub projects seem to practice an "anything goes" commit authoring convention. Rather than fight that battle, let's concede defeat and accept that many GitHub pull requests that we'll integrate into various repos will need to be heavily normalized before they are imported. This commit implements a brute normalization hammer in the form of commit squashing. Given two Git commits, it computes a merge base between the two, the set of commits in the source DAG head, and produces a new, single commit representing the "squashed" result of all the individual commits. The added CLI command and ref-based function likely won't be used in production. Its main purpose at this juncture is to facilitate testing. So, the command will only be installed if an environment variable is present during package installation. The important parts of this commit are the new generic Python APIs for finding merge bases and performing a squash. Subsequent commits will build on top of these primitives to implement more advanced functionality. MozReview-Commit-ID: 1gRwYueo1Br
testing/vcttesting/environment.py
vcssync/mozvcssync/cli.py
vcssync/mozvcssync/gitrewrite/squash.py
vcssync/mozvcssync/gitutil.py
vcssync/setup.py
vcssync/tests/test-squash-git-ref-linear.t
vcssync/tests/test-squash-git-ref-merges.t
vcssync/tests/test-squash-git-ref-no-divergence.t
--- a/testing/vcttesting/environment.py
+++ b/testing/vcttesting/environment.py
@@ -70,24 +70,29 @@ def create_virtualenv(name):
 def process_pip_requirements(venv, requirements):
     args = [
         venv['pip'], 'install', '--upgrade', '--require-hashes',
         '-r', os.path.join(ROOT, requirements),
     ]
     subprocess.check_call(args)
 
 
-def install_editable(venv, relpath):
+def install_editable(venv, relpath, extra_env=None):
     args = [
         venv['pip'], 'install', '--no-deps', '--editable',
         os.path.join(ROOT, relpath)
     ]
-    subprocess.check_call(args)
+
+    env = dict(os.environ)
+    env.update(extra_env or {})
+
+    subprocess.check_call(args, env=env)
 
 
 def create_vcssync():
     """Create an environment used for testing VCSSync."""
     venv = create_virtualenv('vcssync')
     process_pip_requirements(venv, 'vcssync/test-requirements.txt')
     install_editable(venv, 'testing')
-    install_editable(venv, 'vcssync')
+    install_editable(venv, 'vcssync',
+                     extra_env={'VCSSYNC_ENABLE_TESTING_COMMANDS': '1'})
 
     return venv
--- a/vcssync/mozvcssync/cli.py
+++ b/vcssync/mozvcssync/cli.py
@@ -18,16 +18,19 @@ from .git2hg import (
 )
 from .gitrewrite import (
     RewriteError,
     commit_metadata_rewriter,
 )
 from .gitrewrite.linearize import (
     linearize_git_repo,
 )
+from .gitrewrite.squash import (
+    squash_git_ref,
+)
 from .overlay import (
     overlay_hg_repos,
     PushRaceError,
     PushRemoteFail,
 )
 
 
 logger = logging.getLogger(__name__)
@@ -196,16 +199,45 @@ def linearize_git_to_hg():
             git_commit_rewriter_args=rewriter_args)
     except RewriteError as e:
         logger.error('abort: %s' % str(e))
         sys.exit(1)
     except subprocess.CalledProcessError:
         sys.exit(1)
 
 
+def squash_git():
+    parser = argparse.ArgumentParser()
+    parser.add_argument('git_repo', help='Path to Git repository to operate on')
+    parser.add_argument('base_ref', help='ref that will be used to determine '
+                                         'common ancestor of ref to be '
+                                         'squashed')
+    parser.add_argument('squash_ref', help='ref to select commits that will '
+                                           'be squashed')
+    parser.add_argument('--message',
+                        help='Commit message to use for squashed commit')
+
+    args = parser.parse_args()
+
+    configure_logging()
+
+    repo = dulwich.repo.Repo(args.git_repo)
+
+    def rewriter(merge_info, commit):
+        if args.message:
+            commit.message = args.message
+
+    try:
+        squash_git_ref(repo, args.base_ref, args.squash_ref,
+                       commit_rewriter=rewriter)
+    except RewriteError as e:
+        logger.error('abort: %s' % str(e))
+        sys.exit(1)
+
+
 def overlay_hg_repos_cli():
     # Unbuffer stdout.
     sys.stdout = os.fdopen(sys.stdout.fileno(), 'w', 1)
 
     parser = argparse.ArgumentParser()
     parser.add_argument('--hg', help='hg executable to use')
     parser.add_argument('--into', required=True,
                         help='Subdirectory into which changesets will be '
new file mode 100644
--- /dev/null
+++ b/vcssync/mozvcssync/gitrewrite/squash.py
@@ -0,0 +1,107 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+"""Functionality for squashing Git commits into a single commit."""
+
+from __future__ import absolute_import, unicode_literals
+
+import logging
+
+from . import (
+    RewriteError,
+)
+from ..gitutil import (
+    calculate_merge_properties,
+    MergeResolveError,
+)
+
+
+logger = logging.getLogger(__name__)
+
+
+def squash_commits_from_merge_info(repo, merge_info, commit_rewriter=None):
+    """Squash commits down to a single commit from merge properties.
+
+    Given a ``dulwich.repo.Repo`` and a result from
+    ``calculate_merge_properties``, squash all commits from the ``incoming``
+    set in the merge properties into a single commit.
+
+    The new commit will have its parent set to the ``merge_base`` field of
+    the merge properties.
+
+    ``commit_rewriter`` is a function that will be called before the new
+    squashed commit is saved. The function receives as arguments the passed
+    merge properties and the squashed commit object.
+    """
+    dest_commit = merge_info['from'].copy()
+
+    # Creating our new commit is relatively straightforward. Just set the parent
+    # to the merge base and then call our rewriter function. The tree object
+    # is preserved because the repo content of the commit should not be
+    # altered during squashing.
+    dest_commit.parents = [merge_info['merge_base'].id]
+
+    if commit_rewriter:
+        commit_rewriter(merge_info, dest_commit)
+
+    repo.object_store.add_object(dest_commit)
+
+    return dest_commit
+
+
+def squash_git_ref(repo, base_ref, squash_ref, commit_rewriter=None):
+    """Squash commits in a ref down to a single commit.
+
+    Given a ``dulwich.repo.Repo`` and a pair of refs, ``base_ref`` and
+    ``squash_ref``, finds commits in ``squash_ref`` that aren't reachable
+    from ``base_ref`` and squash them down to a single Git commit.
+
+    The function attempts to calculate an appropriate parent for the squashed
+    commit using ``git merge-base`` via ``calculate_merge_properties()``. If
+    an unambiguous "best" merge base cannot be computed, an exception will be
+    raised.
+    """
+    real_base_ref = b'refs/%s' % base_ref
+    real_squash_ref = b'refs/%s' % squash_ref
+
+    base_commit_id = repo[real_base_ref].id
+    squash_head_commit = repo[real_squash_ref]
+
+    # This is an arbitrary restriction. But merges at the tip of a DAG branch
+    # to squash is really weird and shouldn't be encouraged.
+    if len(squash_head_commit.parents) != 1:
+        raise RewriteError('%s (%s) does not have a single parent; cannot '
+                           'continue' % (squash_ref, squash_head_commit.id))
+
+    logger.warn('squashing %s (%s) into %s (%s)' % (
+        squash_ref, squash_head_commit.id,
+        base_ref, base_commit_id))
+
+    try:
+        merge_info = calculate_merge_properties(repo,
+                                                squash_head_commit.id,
+                                                base_commit_id)
+    except MergeResolveError as e:
+        raise RewriteError(str(e))
+
+    parent_commit_id = merge_info['merge_base'].id
+
+    logger.warn('identified %s as most appropriate squash base' %
+                parent_commit_id)
+
+    logger.warn('found %d commits to squash' % len(merge_info['incoming']))
+    for commit in merge_info['incoming']:
+        logger.warn('%s %s' % (commit.id, commit.message.splitlines()[0]))
+
+    dest_commit = squash_commits_from_merge_info(
+        repo, merge_info, commit_rewriter=commit_rewriter)
+
+    logger.warn('squashed commit as %s' % dest_commit.id)
+
+    return {
+        'source_commit': squash_head_commit,
+        'squashed_commit': dest_commit,
+        'squashed_ref': squash_ref,
+        'squashed_commits': merge_info['incoming'],
+    }
--- a/vcssync/mozvcssync/gitutil.py
+++ b/vcssync/mozvcssync/gitutil.py
@@ -3,16 +3,18 @@
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 """Utility functions for performing various Git functionality."""
 
 from __future__ import absolute_import, unicode_literals
 
 import subprocess
 
+import dulwich.walk
+
 
 def update_git_refs(repo, reason, *actions):
     """Update Git refs via reflog writes.
 
     Accepts a ``dulwich.repo.Repo``, a bytes ``reason`` describing why this
     was done, and 1 or more tuples describing the update to perform. Tuples
     have the form:
 
@@ -40,8 +42,120 @@ def update_git_refs(repo, reason, *actio
                          stdin=subprocess.PIPE,
                          cwd=repo.path)
     p.stdin.write(b'\0'.join(commands))
     p.stdin.close()
     res = p.wait()
     # TODO could use a more rich exception type that captures output.
     if res:
         raise Exception('failed to update git refs')
+
+
+class MergeResolveError(Exception):
+    """Represents an error attempting to resolve merge information."""
+
+
+def calculate_merge_properties(repo, from_revision, into_revision):
+    """Determine which commits would be merged from one ref into another.
+
+    Given a ``dulwich.repo.Repo`` and revisions ``from_revision`` and
+    ``into_revision`` (which can be anything that ``repo[x]`` can resolve,
+    including commit IDs and refs), calculate what a merge between those two
+    revisions would look like.
+
+    Returns a dict with the following keys:
+
+    from
+       Commit object representing the commit that was resolved from
+       ``from_revision``.
+
+    into
+       Commit object representing the commit that was resolved from
+       ``into_revision``.
+
+    merge_base
+       Commit of the best merge base between the given revisions. This would
+       also be an appropriate parent commit if commits from ``from_revision``
+       are being squashed into a single commit.
+
+    incoming
+       List of commit objects representing commits in ancestry of
+       ``from_revision`` that aren't in the ancestry of ``into_revision``.
+       Commits are ordered oldest to newest and are grouped topologically.
+    """
+    # Calculating the set of commits in one DAG head but not another is easy.
+    #
+    # Determining a single base commit between them can be hard. For linear
+    # history, it is simple: just walk ancestry of commits until you find a
+    # common in both. But with merge commits in play, things quickly become more
+    # complex. Take the following history for example:
+    #
+    # o g from_revision
+    # o f
+    # |\   o e into_revision
+    # | o / d
+    # o |/ c
+    # | o b
+    # |/
+    # o a
+    #
+    # In this DAG, ``{c, d, f, g}`` are all unique to ``from_revision``. In that
+    # set, ``c`` and ``d`` are roots and ``a`` and ``b`` are shared ancestors
+    # that are parents of ``c`` and ``d``, respectively. So, there is no
+    # single parent to choose.
+    #
+    # Things become every more complicated if the set of commits only in
+    # ``into_revision`` contains merges and ancestors of those merges chain
+    # up to different roots in ``from_revision``'s set. This can occur when
+    # there is a criss-cross merge for example.
+    #
+    # Fortunately, Git has a solution for this problem via ``git merge-base``
+    # (which is also exposed via the ``...`` operator in ``git rev-list``).
+    # This command takes a series of commits and finds "as good common
+    # ancestors as possible for a merge." Since Git already has an answer to
+    # this question for us, we just ask Git.
+    from_commit = repo[from_revision]
+    into_commit = repo[into_revision]
+
+    try:
+        out = subprocess.check_output([
+            # --all will display all *best* merge bases instead of picking one
+            # arbitrary. It allows us to detect ambiguous cases.
+            b'git', b'merge-base', b'--all', from_commit.id, into_commit.id],
+            cwd=repo.path)
+    except subprocess.CalledProcessError as e:
+        raise MergeResolveError('error calling `git merge-base`: %s' % e.output)
+
+    merge_bases = out.rstrip().splitlines()
+    for commit in merge_bases:
+        assert len(commit) == 40
+
+    if len(merge_bases) != 1:
+        raise MergeResolveError('unable to determine best merge base; '
+                                'candidates: %s' % ', '.join(merge_bases))
+
+    merge_base = merge_bases[0]
+
+    # And determine commits that will be integrated into the destination commit.
+    # We could get this from `git rev-list`. But we like avoiding subprocesses
+    # when possible.
+    walker = dulwich.walk.Walker(repo.object_store,
+                                 include=[from_commit.id],
+                                 exclude=[into_commit.id],
+                                 reverse=True,
+                                 order=dulwich.walk.ORDER_TOPO)
+
+    incoming = [c.commit for c in walker]
+
+    incoming_nodes = set(c.id for c in incoming)
+    incoming_parents = set()
+    for c in incoming:
+        for p in c.parents:
+            if p not in incoming_nodes:
+                incoming_parents.add(p)
+
+    return {
+        'from': from_commit,
+        'into': into_commit,
+        'merge_base': repo[merge_base],
+        'incoming': incoming,
+        'incoming_parents': incoming_parents,
+    }
--- a/vcssync/setup.py
+++ b/vcssync/setup.py
@@ -1,18 +1,27 @@
+import os
+
 from setuptools import setup, find_packages
 
 console_scripts = [
     'linearize-git=mozvcssync.cli:linearize_git',
     'linearize-git-to-hg=mozvcssync.cli:linearize_git_to_hg',
     'overlay-hg-repos=mozvcssync.cli:overlay_hg_repos_cli',
     'servo-overlay=mozvcssync.servo:overlay_cli',
     'servo-pulse-listen=mozvcssync.servo:pulse_daemon',
 ]
 
+# These commands are really only useful for testing. So don't expose them by
+# default.
+if 'VCSSYNC_ENABLE_TESTING_COMMANDS' in os.environ:
+    console_scripts.extend([
+        'squash-git-ref=mozvcssync.cli:squash_git',
+    ])
+
 setup(
     name='mozvcssync',
     version='0.1',
     description='Synchronize changes across VCS repositories',
     url='https://mozilla-version-control-tools.readthedocs.io/',
     author='Mozilla',
     author_email='dev-version-control@lists.mozilla.org',
     license='MPL 2.0',
new file mode 100644
--- /dev/null
+++ b/vcssync/tests/test-squash-git-ref-linear.t
@@ -0,0 +1,49 @@
+  $ . $TESTDIR/vcssync/tests/helpers.sh
+
+Create a Git repo with 2 refs
+
+  $ git init -q repo
+  $ cd repo
+  $ echo initial > foo
+  $ git add foo
+  $ git commit -q -m initial
+  $ git branch feature-branch
+  $ echo master-2 > foo
+  $ git commit -q --all -m 'master 2nd commit'
+  $ git checkout -q feature-branch
+  $ echo feature-1 > foo
+  $ git commit -q --all -m 'feature 1st commit'
+  $ echo feature-2 > foo
+  $ git commit -q --all -m 'feature 2nd commit'
+
+  $ git log --graph --format=oneline --all
+  * a98678724fbd89ecea4822ceb5b0453fb903a615 feature 2nd commit
+  * 24ccd5cb8d1ecddc08e2ea671c8646a86b393c9a feature 1st commit
+  | * dc70c11fd24fb60d37c662707bd47d6555c97b51 master 2nd commit
+  |/  
+  * 012a4dc7f592eee52cae9b04770a2c752746d04f initial
+
+  $ git cat-file -p refs/heads/feature-branch
+  tree 919e4e448c70113342175a8e1290b4465bdfe1e5
+  parent 24ccd5cb8d1ecddc08e2ea671c8646a86b393c9a
+  author test <test@example.com> 0 +0000
+  committer test <test@example.com> 0 +0000
+  
+  feature 2nd commit
+
+  $ squash-git-ref . heads/master heads/feature-branch --message 'squashed feature'
+  squashing heads/feature-branch (a98678724fbd89ecea4822ceb5b0453fb903a615) into heads/master (dc70c11fd24fb60d37c662707bd47d6555c97b51)
+  identified 012a4dc7f592eee52cae9b04770a2c752746d04f as most appropriate squash base
+  found 2 commits to squash
+  24ccd5cb8d1ecddc08e2ea671c8646a86b393c9a feature 1st commit
+  a98678724fbd89ecea4822ceb5b0453fb903a615 feature 2nd commit
+  squashed commit as 78a3b2702a360f9979e4db1d763a68f1eb78406d
+
+  $ git cat-file -p 78a3b2702a360f9979e4db1d763a68f1eb78406d
+  tree 919e4e448c70113342175a8e1290b4465bdfe1e5
+  parent 012a4dc7f592eee52cae9b04770a2c752746d04f
+  author test <test@example.com> 0 +0000
+  committer test <test@example.com> 0 +0000
+  
+  squashed feature (no-eol)
+
new file mode 100644
--- /dev/null
+++ b/vcssync/tests/test-squash-git-ref-merges.t
@@ -0,0 +1,134 @@
+  $ . $TESTDIR/vcssync/tests/helpers.sh
+
+Squashing a ref that points to a merge commit is not allowed
+
+  $ git init -q merge-head
+  $ cd merge-head
+  $ echo initial > foo
+  $ git add foo
+  $ git commit -q -m initial
+
+  $ echo master-2 > foo
+  $ git commit -q --all -m master-2
+  $ git checkout -q -b feature HEAD~1
+  $ echo feature-1 > bar
+  $ git add bar
+  $ git commit -q --all -m feature-1
+  $ git merge -q master
+
+  $ git log --graph --format=oneline
+  *   6337ea53962288cd3a70e4288f538339c5df477c Merge branch 'master' into feature
+  |\  
+  | * b2479bb0df2d8089539b4c11c10a64e0ec7d6095 master-2
+  * | 80fbf1b469e557e42b36b8ad349b9e6465b40786 feature-1
+  |/  
+  * 012a4dc7f592eee52cae9b04770a2c752746d04f initial
+
+  $ squash-git-ref . heads/master heads/feature
+  abort: heads/feature (6337ea53962288cd3a70e4288f538339c5df477c) does not have a single parent; cannot continue
+  [1]
+
+  $ cd ..
+
+Squashing a ref containing a merge is allowed if Git can resolve a "best" merge base
+
+  $ git init -q merge-base
+  $ cd merge-base
+  $ echo initial > foo
+  $ git add foo
+  $ git commit -q -m initial
+  $ echo master-2 > foo
+  $ git commit -q --all -m master-2
+  $ echo master-3 > foo
+  $ git commit -q --all -m master-3
+  $ git checkout -q -b feature HEAD~2
+  $ echo feature-1 > bar
+  $ git add bar
+  $ git commit -q -m feature-1
+  $ git merge -q -m 'merge master into feature' master~1
+  $ echo feature-2 > bar
+  $ git commit -q --all -m feature-2
+
+  $ git log --graph --format=oneline --all
+  * eacb40b5f83add54e68cb68477d0a974e293ae02 feature-2
+  *   4fb9f3754e7251ded8fdc415114d18271a73cd0e merge master into feature
+  |\  
+  * | 80fbf1b469e557e42b36b8ad349b9e6465b40786 feature-1
+  | | * f067669912190524703c71f2fe82bd34854e6c90 master-3
+  | |/  
+  | * b2479bb0df2d8089539b4c11c10a64e0ec7d6095 master-2
+  |/  
+  * 012a4dc7f592eee52cae9b04770a2c752746d04f initial
+
+  $ git cat-file -p refs/heads/feature
+  tree 997f9bdaa14c2c091cb62b7c76d20d1b03b687e1
+  parent 4fb9f3754e7251ded8fdc415114d18271a73cd0e
+  author test <test@example.com> 0 +0000
+  committer test <test@example.com> 0 +0000
+  
+  feature-2
+
+  $ squash-git-ref . heads/master heads/feature --message 'squashed feature'
+  squashing heads/feature (eacb40b5f83add54e68cb68477d0a974e293ae02) into heads/master (f067669912190524703c71f2fe82bd34854e6c90)
+  identified b2479bb0df2d8089539b4c11c10a64e0ec7d6095 as most appropriate squash base
+  found 3 commits to squash
+  80fbf1b469e557e42b36b8ad349b9e6465b40786 feature-1
+  4fb9f3754e7251ded8fdc415114d18271a73cd0e merge master into feature
+  eacb40b5f83add54e68cb68477d0a974e293ae02 feature-2
+  squashed commit as 839f4e730cc8fb5bad9c31411908fbd584dcf316
+
+  $ git cat-file -p 839f4e730cc8fb5bad9c31411908fbd584dcf316
+  tree 997f9bdaa14c2c091cb62b7c76d20d1b03b687e1
+  parent b2479bb0df2d8089539b4c11c10a64e0ec7d6095
+  author test <test@example.com> 0 +0000
+  committer test <test@example.com> 0 +0000
+  
+  squashed feature (no-eol)
+
+  $ cd ..
+
+Inability to resolve best merge base results in error
+(We use a criss-cross merge to trigger this.)
+
+  $ git init -q crisscross
+  $ cd crisscross
+
+  $ echo initial > foo
+  $ touch bar baz
+  $ git add foo bar baz
+  $ git commit -q -m initial
+  $ echo master-1 > bar
+  $ git commit -q --all -m master-1
+  $ git checkout -q -b feature HEAD~1
+  $ echo feature-1 > baz
+  $ git commit -q --all -m feature-1
+  $ git merge -q -m 'merge master into feature' master
+  $ echo feature-2 > baz
+  $ git commit -q --all -m feature-2
+  $ git checkout -q master
+  $ git merge -q -m 'merge feature into master' feature~2
+  $ echo master-2 > bar
+  $ git commit -q --all -m master-2
+
+  $ git log --graph --format=oneline --all
+  * 2406727baeb0658de37e2d4094ea1b10c60ebda4 feature-2
+  *   0551edc5132216ca336d28c77c23ec7f17a9b21f merge master into feature
+  |\  
+  | | * fc9992d5080c23ddfdcdaa9e8a7c9d7608be55e4 master-2
+  | | *   122cab7c1b649a7af1c2f638a80d6afdb272322c merge feature into master
+  | | |\  
+  | |/ /  
+  | | /   
+  | |/    
+  |/|     
+  * | ae7c3ccea16f1569c306492cef38d91256494c7c feature-1
+  | * 5ee8b125f3585849e918d5fe59992bb2e62a3382 master-1
+  |/  
+  * b9b4ce5db0e3457927c32c3a62589b70095fe24a initial
+
+  $ squash-git-ref . heads/master heads/feature
+  squashing heads/feature (2406727baeb0658de37e2d4094ea1b10c60ebda4) into heads/master (fc9992d5080c23ddfdcdaa9e8a7c9d7608be55e4)
+  abort: unable to determine best merge base; candidates: ae7c3ccea16f1569c306492cef38d91256494c7c, 5ee8b125f3585849e918d5fe59992bb2e62a3382
+  [1]
+
+  $ cd ..
new file mode 100644
--- /dev/null
+++ b/vcssync/tests/test-squash-git-ref-no-divergence.t
@@ -0,0 +1,46 @@
+  $ . $TESTDIR/vcssync/tests/helpers.sh
+
+Create a Git repo with 2 refs but linear history
+
+  $ git init -q repo
+  $ cd repo
+  $ echo initial > foo
+  $ git add foo
+  $ git commit -q -m initial
+  $ echo master-2 > foo
+  $ git commit -q --all -m 'master 2nd commit'
+  $ git checkout -q -b feature-branch
+  $ echo feature-1 > foo
+  $ git commit -q --all -m 'feature 1st commit'
+  $ echo feature-2 > foo
+  $ git commit -q --all -m 'feature 2nd commit'
+
+  $ git log --graph --format=oneline --all
+  * e56bbe873c733037ec463d895b9e56c8fde8e734 feature 2nd commit
+  * ea35f9f0c6dd7e9d034ea673ed5b7017a8efea28 feature 1st commit
+  * dc70c11fd24fb60d37c662707bd47d6555c97b51 master 2nd commit
+  * 012a4dc7f592eee52cae9b04770a2c752746d04f initial
+
+  $ git cat-file -p refs/heads/feature-branch
+  tree 919e4e448c70113342175a8e1290b4465bdfe1e5
+  parent ea35f9f0c6dd7e9d034ea673ed5b7017a8efea28
+  author test <test@example.com> 0 +0000
+  committer test <test@example.com> 0 +0000
+  
+  feature 2nd commit
+
+  $ squash-git-ref . heads/master heads/feature-branch --message 'squashed feature'
+  squashing heads/feature-branch (e56bbe873c733037ec463d895b9e56c8fde8e734) into heads/master (dc70c11fd24fb60d37c662707bd47d6555c97b51)
+  identified dc70c11fd24fb60d37c662707bd47d6555c97b51 as most appropriate squash base
+  found 2 commits to squash
+  ea35f9f0c6dd7e9d034ea673ed5b7017a8efea28 feature 1st commit
+  e56bbe873c733037ec463d895b9e56c8fde8e734 feature 2nd commit
+  squashed commit as 17f5efbb720f021b7a92f5049721ec48eed4222e
+
+  $ git cat-file -p 17f5efbb720f021b7a92f5049721ec48eed4222e
+  tree 919e4e448c70113342175a8e1290b4465bdfe1e5
+  parent dc70c11fd24fb60d37c662707bd47d6555c97b51
+  author test <test@example.com> 0 +0000
+  committer test <test@example.com> 0 +0000
+  
+  squashed feature (no-eol)