2 Advanced merge tools for git rerere, sloppy commits and parametrization.
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:
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
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
21 Furthermore, both of these make it difficult to reuse rerere resolutions
22 across installations. Thus, an advanced Wizard merge has the following
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.
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
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.
50 from wizard import shell
52 def git_commit_tree(tree, *parents):
54 Convenience wrapper for ``git commit-tree``. Writes an empty log.
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)
61 def git_diff_text(a, b):
63 Returns a list of files that are text and are different between
64 commits ``a`` and ``b``.
66 lines = shell.eval("git", "diff", "--numstat", a, b).strip().split("\n")
69 added, deleted, name = line.split()
70 if added != "-" and deleted != "-":
74 def git_newline_style(rev, name):
76 Returns the newline style for a blob, identified by Git revision
77 ``rev`` and filename ``name``.
79 # XXX This is really expensive
80 f = tempfile.NamedTemporaryFile(prefix="wizardResolve", delete=False)
81 shell.call("git", "cat-file", "blob", "%s:%s" % (rev, name), stdout=f, log=False)
83 nl = get_newline(f.name)
88 def get_newline(filename):
90 Determines the newline style of ``filename``. This will be a
91 string if only one newline style was find, or a tuple of newline
94 f = open(filename, 'U')
98 def convert_newline(filename, dest_nl):
100 Replaces the detected newline style of ``filename`` with
101 ``dest_nl`` type newlines.
103 contents = open(filename, "U").read().replace("\n", dest_nl)
104 open(filename, "wb").write(contents)
106 def merge(common_id, theirs_id,
108 resolve_conflicts=None):
110 Performs a merge. Throws a :class:`MergeError` if the merge fails
111 (and leaves the current working directory in a state amenable
112 to manual conflict resolution), or returns a tree id of the successful
113 merge (the directory state is undefined and should not be relied upon).
114 This function is not responsible for actually coming
115 up with the real merge commit, because it can fail.
117 If ``prepare_config`` is used, you are expected to reverse the effects
118 of this on whatever the final tree is; otherwise you will lose
123 * ``common_id``: base commit to calculate merge off of
124 * ``theirs_id``: changes to merge in from commit
125 * ``prepare_config``: function that removes any user-specific
126 values from files. This function is expected to ``git add``
127 any files it changes.
128 * ``resolve_conflicts``: this function attempts to resolve
129 conflicts automatically. Returns ``True`` if all conflicts
130 are resolved, and ``False`` otherwise. It is permitted
131 to resolve some but not all conflicts.
135 Wizard has a strange idea of repository topology (due to lack of
136 rebases, see documentation about doctoring retroactive commits),
137 so we require the common and theirs commits, instead of
138 using the normal Git algorithm.
140 if prepare_config is None:
141 prepare_config = lambda: None
142 if resolve_conflicts is None:
143 resolve_conflicts = lambda: False
144 ours_id = shell.eval("git", "rev-parse", "HEAD")
145 theirs_newline_cache = {}
146 def get_theirs_newline(file):
147 if file not in theirs_newline_cache:
148 nl = git_newline_style(theirs_id, file)
149 if not isinstance(nl, str):
151 # A case of incompetent upstream, unfortunately
152 logging.warning("Canonical version (theirs) of %s has mixed newline style, forced to \\n", file)
154 logging.debug("Canonical version (theirs) of %s had no newline style, using \\n", file)
156 theirs_newline_cache[file] = nl
157 return theirs_newline_cache[file]
158 theirs_tree = shell.eval("git", "rev-parse", "%s^{tree}" % theirs_id)
159 # XXX Should be able to skip stats if file doesn't exist
160 # operations on the ours tree
161 for file in git_diff_text(ours_id, theirs_id):
163 theirs_nl = get_theirs_newline(file)
164 ours_nl = get_newline(file) # current checkout is ours_id
165 except (IOError, shell.CallError): # hack
167 if theirs_nl != ours_nl:
169 logging.debug("File had no newlines, ignoring newline style")
171 logging.info("Converting our file (3) from %s to %s newlines", repr(ours_nl), repr(theirs_nl))
172 convert_newline(file, theirs_nl)
173 shell.eval("git", "add", file)
174 prepare_config() # for Wizard, this usually genericizes config files
175 ours_tree = shell.eval("git", "write-tree")
176 logging.info("Merge wrote virtual tree for ours: %s", ours_tree)
177 # operations on the common tree (pretty duplicate with the above)
178 shell.call("git", "reset", "--hard", common_id)
179 for file in git_diff_text(common_id, theirs_id):
181 theirs_nl = get_theirs_newline(file)
182 common_nl = get_newline(file) # current checkout is common_id
183 except (IOError, shell.CallError): # hack
185 if theirs_nl != common_nl:
186 if common_nl is None:
187 logging.debug("File had no newlines, ignoring newline style")
189 logging.info("Converting common file (1) from %s to %s newlines", repr(common_nl), repr(theirs_nl))
190 convert_newline(file, theirs_nl)
191 shell.eval("git", "add", file)
192 common_tree = shell.eval("git", "write-tree")
193 logging.info("Merge wrote virtual tree for common: %s", common_tree)
194 # construct merge commit graph
195 common_virtual_id = git_commit_tree(common_tree)
196 ours_virtual_id = git_commit_tree(ours_tree, common_virtual_id)
197 theirs_virtual_id = git_commit_tree(theirs_tree, common_virtual_id)
199 shell.call("git", "reset", "--hard", ours_virtual_id)
201 shell.call("git", "merge", theirs_virtual_id)
202 except shell.CallError as e:
203 logging.info("Merge failed with these message:\n\n" + e.stderr)
204 if resolve_conflicts():
205 logging.info("Resolved conflicts automatically")
206 shell.call("git", "commit", "-a", "-m", "merge")
209 # post-merge operations
210 result_tree = shell.eval("git", "write-tree")
211 logging.info("Resolution tree is %s", result_tree)
214 class Error(wizard.Error):
215 """Base error class for merge"""
218 class MergeError(Error):
219 """Merge terminated with a conflict, oh no!"""