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)
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.info("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:
166 logging.info("Converting our file (3) from %s to %s newlines", repr(ours_nl), repr(theirs_nl))
167 convert_newline(file, theirs_nl)
168 shell.eval("git", "add", file)
169 prepare_config() # for Wizard, this usually genericizes config files
170 ours_tree = shell.eval("git", "write-tree")
171 logging.info("Merge wrote virtual tree for ours: %s", ours_tree)
172 # operations on the common tree
173 shell.call("git", "reset", "--hard", common_id)
174 for file in git_diff_text(common_id, theirs_id):
176 theirs_nl = get_theirs_newline(file)
177 common_nl = get_newline(file) # current checkout is common_id
178 except (IOError, shell.CallError): # hack
180 if theirs_nl != common_nl:
181 logging.info("Converting common file (1) from %s to %s newlines", repr(common_nl), repr(theirs_nl))
182 convert_newline(file, theirs_nl)
183 shell.eval("git", "add", file)
184 common_tree = shell.eval("git", "write-tree")
185 logging.info("Merge wrote virtual tree for common: %s", common_tree)
186 # construct merge commit graph
187 common_virtual_id = git_commit_tree(common_tree)
188 ours_virtual_id = git_commit_tree(ours_tree, common_virtual_id)
189 theirs_virtual_id = git_commit_tree(theirs_tree, common_virtual_id)
191 shell.call("git", "reset", "--hard", ours_virtual_id)
193 shell.call("git", "merge", theirs_virtual_id)
194 except shell.CallError as e:
195 logging.info("Merge failed with these message:\n\n" + e.stderr)
196 if resolve_conflicts():
197 logging.info("Resolved conflicts automatically")
198 shell.call("git", "commit", "-a", "-m", "merge")
201 # post-merge operations
202 result_tree = shell.eval("git", "write-tree")
203 logging.info("Resolution tree is %s", result_tree)
206 class Error(wizard.Error):
207 """Base error class for merge"""
210 class MergeError(Error):
211 """Merge terminated with a conflict, oh no!"""