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 f = tempfile.NamedTemporaryFile(prefix="wizardResolve", delete=False)
80 shell.call("git", "cat-file", "blob", "%s:%s" % (rev, name), stdout=f, log=False)
82 nl = get_newline(f.name)
87 def get_newline(filename):
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
93 f = open(filename, 'U')
97 def convert_newline(filename, dest_nl):
99 Replaces the detected newline style of ``filename`` with
100 ``dest_nl`` type newlines.
102 contents = open(filename, "U").read().replace("\n", dest_nl)
103 open(filename, "wb").write(contents)
105 def merge(common_id, theirs_id,
107 resolve_conflicts=None):
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.
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
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.
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.
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):
150 # A case of incompetent upstream, unfortunately
151 logging.warning("Canonical version (theirs) of %s has mixed newline style, forced to \\n", file)
153 logging.debug("Canonical version (theirs) of %s had no newline style, using \\n", file)
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):
161 theirs_nl = get_theirs_newline(file)
162 ours_nl = get_newline(file) # current checkout is ours_id
163 except (IOError, shell.CallError): # hack
165 if theirs_nl != ours_nl:
167 logging.debug("File had no newlines, ignoring newline style")
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):
179 theirs_nl = get_theirs_newline(file)
180 common_nl = get_newline(file) # current checkout is common_id
181 except (IOError, shell.CallError): # hack
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)
194 shell.call("git", "reset", "--hard", ours_virtual_id)
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")
204 # post-merge operations
205 result_tree = shell.eval("git", "write-tree")
206 logging.info("Resolution tree is %s", result_tree)
209 class Error(wizard.Error):
210 """Base error class for merge"""
213 class MergeError(Error):
214 """Merge terminated with a conflict, oh no!"""