# conflictinfo.py # # Copyright 2017 Facebook, Inc. # # This software may be used and distributed according to the terms of the # GNU General Public License version 2 or any later version. """ introduces `--tool=internal:dumpjson` to `resolve` to output conflict info N.B.: This is an extension-ized version but we hope to land this upstream and then delete this extension. Normally, `hg resolve` takes the user on a whistle-stop tour of each conflicted file, pausing to launch the editor to resolve the conflicts. This is an alternative workflow for resolving many conflicts in a random-access fashion. It doesn't change/replace the default behavior. This commit adds `--tool=internal:dumpjson`. It prints, for each conflict, the "base", "other", and "ours" versions (the contents, as well as their exec/link flags), and where the user/tool should write a resolved version (i.e., the working copy) as JSON. The user will then resolve the conflicts at their leisure and run `hg resolve --mark`. """ from __future__ import absolute_import import copy from mercurial.filemerge import absentfilectx from mercurial.i18n import _ from mercurial import ( commands, extensions, merge as mergemod, scmutil, ) from mercurial import error from mercurial import util testedwith = 'ships-with-fb-hgext' # `unfinishedstates` would be ideal for this except it does not include merge, # and doesn't expose the command to run to resume by itself (it instead exposes # a help string) # Note: order matters (consider rebase v. merge). CONFLICTSTATES = [ ['graftstate', {'cmd': 'graft', 'to_continue': 'graft --continue', 'to_abort': 'graft --abort'}], ['rebasestate', {'cmd': 'rebase', 'to_continue': 'rebase --continue', 'to_abort': 'rebase --abort'}], ['shelvedstate', {'cmd': 'unshelve', 'to_continue': 'unshelve --continue', 'to_abort': 'unshelve --abort'}], ['histedit-state', {'cmd': 'histedit', 'to_continue': 'histedit --continue', 'to_abort': 'histedit --abort'}], # updatestate should be after all other commands, but before mergestate, # since some of the above commands run updates which concievably could be # interrupted. See coment for mergestate. ['updatestate', {'cmd': 'update', 'to_continue': 'update', 'to_abort': 'update --clean'}], # Check for mergestate last, since other commands (shelve, rebase, histedit, # etc.) will leave a statefile of their own, as well as a mergestate, if # there were conflicts. The command-level statefile should be considered # first. ['merge/state', {'cmd': 'merge', 'to_continue': 'merge --continue', 'to_abort': 'update --clean'}], ] def extsetup(ui): extensions.wrapcommand(commands.table, 'resolve', _resolve) # Returns which command got us into the conflicted merge state. Since these # states are mutually exclusive, we can use the existence of any one statefile # as proof of culpability. def _findconflictcommand(repo): for path, data in CONFLICTSTATES: if repo.vfs.exists(path): return data return None # To become a block in commands.py/resolve(). def _resolve(orig, ui, repo, *pats, **opts): # This block is duplicated from commands.py to maintain behavior. flaglist = 'all mark unmark list no_status'.split() all, mark, unmark, show, nostatus = \ [opts.get(o) for o in flaglist] if (show and (mark or unmark)) or (mark and unmark): raise error.Abort(_("too many options specified")) if pats and all: raise error.Abort(_("can't specify --all and patterns")) if not (all or pats or show or mark or unmark): raise error.Abort(_('no files or directories specified'), hint=('use --all to re-merge all unresolved files')) # if not show and opts.get('tool', '') == "internal:dumpjson": formatter = ui.formatter('resolve', {'template': 'json'}) mergestate = mergemod.mergestate.read(repo) matcher = scmutil.match(repo[None], pats, opts) workingctx = repo[None] fileconflicts = [] pathconflicts = [] for path in mergestate: if not matcher(path): continue info = _summarizefileconflicts(mergestate, path, workingctx) if info is not None: fileconflicts.append(info) info = _summarizepathconflicts(mergestate, path) if info is not None: pathconflicts.append(info) cmd = _findconflictcommand(repo) formatter.startitem() formatter.write('conflicts', '%s\n', fileconflicts) formatter.write('pathconflicts', '%s\n', pathconflicts) formatter.write('command', '%s\n', _findconflictcommand(repo)) if cmd: formatter.write('command', '%s\n', cmd['cmd']) # Deprecated formatter.write('command_details', '%s\n', cmd) else: formatter.write('command', '%s\n', None) # For BC formatter.end() return 0 return orig(ui, repo, *pats, **opts) # To become merge.summarizefileconflicts(). def _summarizefileconflicts(self, path, workingctx): # 'd' = driver-resolved # 'r' = marked resolved # 'pr', 'pu' = path conflicts if self[path] in ('d', 'r', 'pr', 'pu'): return None stateentry = self._state[path] localnode = stateentry[1] ancestorfile = stateentry[3] ancestornode = stateentry[4] otherfile = stateentry[5] othernode = stateentry[6] otherctx = self._repo[self._other] extras = self.extras(path) anccommitnode = extras.get('ancestorlinknode') ancestorctx = self._repo[anccommitnode] if anccommitnode else None workingctx = self._filectxorabsent(localnode, workingctx, path) otherctx = self._filectxorabsent(othernode, otherctx, otherfile) basectx = self._repo.filectx(ancestorfile, fileid=ancestornode, changeid=ancestorctx) return _summarize(self._repo, workingctx, otherctx, basectx) # To become merge.summarizepathconflicts(). def _summarizepathconflicts(self, path): # 'pu' = unresolved path conflict if self[path] != 'pu': return None stateentry = self._state[path] frename = stateentry[1] forigin = stateentry[2] return { 'path': path, 'fileorigin': 'local' if forigin == 'l' else 'remote', 'renamedto': frename, } # To become filemerge.summarize(). def _summarize(repo, workingfilectx, otherctx, basectx): origfile = (None if workingfilectx.isabsent() else scmutil.origpath(repo.ui, repo, repo.wjoin(workingfilectx.path()))) def flags(context): if isinstance(context, absentfilectx): return { 'contents': None, 'exists': False, 'isexec': None, 'issymlink': None, } return { 'contents': context.data(), 'exists': True, 'isexec': context.isexec(), 'issymlink': context.islink(), } output = flags(workingfilectx) filestat = (util.filestat.frompath(origfile) if origfile is not None else None) if origfile and filestat.stat: # Since you can start a merge with a dirty working copy (either via # `up` or `merge -f`), "local" must reflect that, not the underlying # changeset. Those contents are available in the .orig version, so we # look there and mock up the schema to look like the other contexts. # # Test cases affected in test-merge-conflict-cornercases.t: #0 local = { 'contents': util.readfile(origfile), 'exists': True, 'isexec': util.isexec(origfile), 'issymlink': util.statislink(filestat.stat), } else: # No backup file. This happens whenever the merge was esoteric enough # that we didn't launch a merge tool*, and instead prompted the user to # "use (c)hanged version, (d)elete, or leave (u)nresolved". # # The only way to exit that prompt with a conflict is to choose "u", # which leaves the local version in the working copy (with all its # pre-merge properties including any local changes), so we can reuse # that. # # Affected test cases: #0b, #1, #6, #11, and #12. # # Another alternative might be to use repo['.'][path] but that wouldn't # have any dirty pre-merge changes. # # *If we had, we'd've we would've overwritten the working copy, made a # backup and hit the above case. # # Copy, so the addition of the `path` key below does not affect both # versions. local = copy.copy(output) output['path'] = repo.wjoin(workingfilectx.path()) return { 'base': flags(basectx), 'local': local, 'other': flags(otherctx), 'output': output, 'path': workingfilectx.path(), }