]> scripts.mit.edu Git - wizard.git/blob - wizard/merge.py
Rewrite parametrize to use new parametrizeWithVars
[wizard.git] / wizard / merge.py
1 """
2 Advanced merge tools for git rerere, sloppy commits and parametrization.
3
4 Wizard requires infrastructure for reusing merge resolutions across
5 many repositories, due to the number of user installs and highly
6 repetitive conflict resolution process.  This environment results
7 in a number of unique challenges:
8
9     1. Users are commonly very sloppy with their text editors and
10        will frequently change the line-ending style of their file.
11        Because Git respects newline choice when core.autocrlf is
12        off, a file that flips newline style will result in a full
13        merge conflict.
14
15     2. We version user configuration files, which means that there
16        will always be a set of changes between upstream and ours.
17        Since Git refuses to automatically merge changes that are
18        too close to each other, these frequently result in spurious
19        merge commits.
20
21 Furthermore, both of these make it difficult to reuse rerere resolutions
22 across installations.  Thus, an advanced Wizard merge has the following
23 properties:
24
25     1. Wizard will perform a full scan of all files that were
26        different between common and ours, filter out those that
27        are binary (using as close to the Git heuristic as possible)
28        and then check among common, ours and theirs if the newlines
29        match.  The newline style of theirs always takes precedence.
30
31     2. Wizard will genericize the ours copy so that it matches
32        common and theirs, and reparametrize it once the merge
33        is finished.  Consumers of this function should be careful
34        to appropriately reparametrize if there are conflicts
35        (we can't do it any earlier, because we want clean rerere
36        postimages).
37
38 Usage of this functionality is primarily through the :func:`merge` function;
39 see that function more usage information.  While the ``git`` and ``newline``
40 functions published by this module are public, use of these functions outside
41 of this module is discouraged.
42 """
43
44 import logging
45 import itertools
46 import tempfile
47 import os
48
49 import wizard
50 from wizard import shell
51
52 def git_commit_tree(tree, *parents):
53     """
54     Convenience wrapper for ``git commit-tree``.  Writes an empty log.
55     """
56     parent_args = itertools.chain(*(["-p", p] for p in parents))
57     commit = shell.eval("git", "commit-tree", tree,
58             *parent_args, input="", log=True)
59     return commit
60
61 def git_diff_text(a, b):
62     """
63     Returns a list of files that are text and are different between
64     commits ``a`` and ``b``.
65     """
66     lines = shell.eval("git", "diff", "--numstat", a, b).strip().split("\n")
67     files = []
68     for line in lines:
69         added, deleted, name = line.split()
70         if added != "-" and deleted != "-":
71             files.append(name)
72     return files
73
74 def git_newline_style(rev, name):
75     """
76     Returns the newline style for a blob, identified by Git revision
77     ``rev`` and filename ``name``.
78     """
79     f = tempfile.NamedTemporaryFile(prefix="wizardResolve", delete=False)
80     shell.call("git", "cat-file", "blob", "%s:%s" % (rev, name), stdout=f, log=False)
81     f.close()
82     nl = get_newline(f.name)
83     os.unlink(f.name)
84     return nl
85
86 # only works on Unix
87 def get_newline(filename):
88     """
89     Determines the newline style of ``filename``.  This will be a
90     string if only one newline style was find, or a tuple of newline
91     types found.
92     """
93     f = open(filename, 'U')
94     f.read()
95     return f.newlines
96
97 def convert_newline(filename, dest_nl):
98     """
99     Replaces the detected newline style of ``filename`` with
100     ``dest_nl`` type newlines.
101     """
102     contents = open(filename, "U").read().replace("\n", dest_nl)
103     open(filename, "wb").write(contents)
104
105 def merge(common_id, theirs_id,
106           prepare_config=None,
107           resolve_conflicts=None):
108     """
109     Performs a merge.  Throws a :class:`MergeError` if the merge fails
110     (and leaves the current working directory in a state amenable
111     to manual conflict resolution), or returns a tree id of the successful
112     merge (the directory state is undefined and should not be relied upon).
113     This function is not responsible for actually coming
114     up with the real merge commit, because it can fail.
115
116     If ``prepare_config`` is used, you are expected to reverse the effects
117     of this on whatever the final tree is; otherwise you will lose
118     those changes.
119
120     Arguments:
121
122         * ``common_id``: base commit to calculate merge off of
123         * ``theirs_id``: changes to merge in from commit
124         * ``prepare_config``: function that removes any user-specific
125           values from files.  This function is expected to ``git add``
126           any files it changes.
127         * ``resolve_conflicts``: this function attempts to resolve
128           conflicts automatically.  Returns ``True`` if all conflicts
129           are resolved, and ``False`` otherwise.  It is permitted
130           to resolve some but not all conflicts.
131
132     .. note::
133
134         Wizard has a strange idea of repository topology (due to lack of
135         rebases, see documentation about doctoring retroactive commits),
136         so we require the common and theirs commits, instead of
137         using the normal Git algorithm.
138     """
139     if prepare_config is None:
140         prepare_config = lambda: None
141     if resolve_conflicts is None:
142         resolve_conflicts = lambda: False
143     ours_id = shell.eval("git", "rev-parse", "HEAD")
144     theirs_newline_cache = {}
145     def get_theirs_newline(file):
146         if file not in theirs_newline_cache:
147             nl = git_newline_style(theirs_id, file)
148             if not isinstance(nl, str):
149                 if nl is not None:
150                     # A case of incompetent upstream, unfortunately
151                     logging.warning("Canonical version (theirs) of %s has mixed newline style, forced to \\n", file)
152                 else:
153                     logging.debug("Canonical version (theirs) of %s had no newline style, using \\n", file)
154                 nl = "\n"
155             theirs_newline_cache[file] = nl
156         return theirs_newline_cache[file]
157     theirs_tree = shell.eval("git", "rev-parse", "%s^{tree}" % theirs_id)
158     # operations on the ours tree
159     for file in git_diff_text(ours_id, theirs_id):
160         try:
161             theirs_nl = get_theirs_newline(file)
162             ours_nl = get_newline(file) # current checkout is ours_id
163         except (IOError, shell.CallError): # hack
164             continue
165         if theirs_nl != ours_nl:
166             if ours_nl == None:
167                 logging.debug("File had no newlines, ignoring newline style")
168             else:
169                 logging.info("Converting our file (3) from %s to %s newlines", repr(ours_nl), repr(theirs_nl))
170                 convert_newline(file, theirs_nl)
171                 shell.eval("git", "add", file)
172     prepare_config() # for Wizard, this usually genericizes config files
173     ours_tree = shell.eval("git", "write-tree")
174     logging.info("Merge wrote virtual tree for ours: %s", ours_tree)
175     # operations on the common tree
176     shell.call("git", "reset", "--hard", common_id)
177     for file in git_diff_text(common_id, theirs_id):
178         try:
179             theirs_nl = get_theirs_newline(file)
180             common_nl = get_newline(file) # current checkout is common_id
181         except (IOError, shell.CallError): # hack
182             continue
183         if theirs_nl != common_nl:
184             logging.info("Converting common file (1) from %s to %s newlines", repr(common_nl), repr(theirs_nl))
185             convert_newline(file, theirs_nl)
186             shell.eval("git", "add", file)
187     common_tree = shell.eval("git", "write-tree")
188     logging.info("Merge wrote virtual tree for common: %s", common_tree)
189     # construct merge commit graph
190     common_virtual_id = git_commit_tree(common_tree)
191     ours_virtual_id   = git_commit_tree(ours_tree, common_virtual_id)
192     theirs_virtual_id = git_commit_tree(theirs_tree, common_virtual_id)
193     # perform merge
194     shell.call("git", "reset", "--hard", ours_virtual_id)
195     try:
196         shell.call("git", "merge", theirs_virtual_id)
197     except shell.CallError as e:
198         logging.info("Merge failed with these message:\n\n" + e.stderr)
199         if resolve_conflicts():
200             logging.info("Resolved conflicts automatically")
201             shell.call("git", "commit", "-a", "-m", "merge")
202         else:
203             raise MergeError
204     # post-merge operations
205     result_tree = shell.eval("git", "write-tree")
206     logging.info("Resolution tree is %s", result_tree)
207     return result_tree
208
209 class Error(wizard.Error):
210     """Base error class for merge"""
211     pass
212
213 class MergeError(Error):
214     """Merge terminated with a conflict, oh no!"""
215     pass