git.py 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301
  1. #!/usr/bin/env python3
  2. """Git helper functions.
  3. Everything here should be project agnostic: it shouldn't rely on project's
  4. structure, or make assumptions about the passed arguments or calls' outcomes.
  5. """
  6. from __future__ import unicode_literals
  7. import io
  8. import os
  9. import posixpath
  10. import re
  11. import subprocess
  12. import sys
  13. SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
  14. sys.path.append(SCRIPT_DIR)
  15. from patches import PATCH_FILENAME_PREFIX, is_patch_location_line
  16. UPSTREAM_HEAD='refs/patches/upstream-head'
  17. def is_repo_root(path):
  18. path_exists = os.path.exists(path)
  19. if not path_exists:
  20. return False
  21. git_folder_path = os.path.join(path, '.git')
  22. git_folder_exists = os.path.exists(git_folder_path)
  23. return git_folder_exists
  24. def get_repo_root(path):
  25. """Finds a closest ancestor folder which is a repo root."""
  26. norm_path = os.path.normpath(path)
  27. norm_path_exists = os.path.exists(norm_path)
  28. if not norm_path_exists:
  29. return None
  30. if is_repo_root(norm_path):
  31. return norm_path
  32. parent_path = os.path.dirname(norm_path)
  33. # Check if we're in the root folder already.
  34. if parent_path == norm_path:
  35. return None
  36. return get_repo_root(parent_path)
  37. def am(repo, patch_data, threeway=False, directory=None, exclude=None,
  38. committer_name=None, committer_email=None, keep_cr=True):
  39. args = []
  40. if threeway:
  41. args += ['--3way']
  42. if directory is not None:
  43. args += ['--directory', directory]
  44. if exclude is not None:
  45. for path_pattern in exclude:
  46. args += ['--exclude', path_pattern]
  47. if keep_cr is True:
  48. # Keep the CR of CRLF in case any patches target files with Windows line
  49. # endings.
  50. args += ['--keep-cr']
  51. root_args = ['-C', repo]
  52. if committer_name is not None:
  53. root_args += ['-c', 'user.name=' + committer_name]
  54. if committer_email is not None:
  55. root_args += ['-c', 'user.email=' + committer_email]
  56. root_args += ['-c', 'commit.gpgsign=false']
  57. command = ['git'] + root_args + ['am'] + args
  58. proc = subprocess.Popen(
  59. command,
  60. stdin=subprocess.PIPE)
  61. proc.communicate(patch_data.encode('utf-8'))
  62. if proc.returncode != 0:
  63. raise RuntimeError("Command {} returned {}".format(command,
  64. proc.returncode))
  65. def import_patches(repo, ref=UPSTREAM_HEAD, **kwargs):
  66. """same as am(), but we save the upstream HEAD so we can refer to it when we
  67. later export patches"""
  68. update_ref(repo=repo, ref=ref, newvalue='HEAD')
  69. am(repo=repo, **kwargs)
  70. def update_ref(repo, ref, newvalue):
  71. args = ['git', '-C', repo, 'update-ref', ref, newvalue]
  72. return subprocess.check_call(args)
  73. def get_commit_for_ref(repo, ref):
  74. args = ['git', '-C', repo, 'rev-parse', '--verify', ref]
  75. return subprocess.check_output(args).decode('utf-8').strip()
  76. def get_commit_count(repo, commit_range):
  77. args = ['git', '-C', repo, 'rev-list', '--count', commit_range]
  78. return int(subprocess.check_output(args).decode('utf-8').strip())
  79. def guess_base_commit(repo, ref):
  80. """Guess which commit the patches might be based on"""
  81. try:
  82. upstream_head = get_commit_for_ref(repo, ref)
  83. num_commits = get_commit_count(repo, upstream_head + '..')
  84. return [upstream_head, num_commits]
  85. except subprocess.CalledProcessError:
  86. args = [
  87. 'git',
  88. '-C',
  89. repo,
  90. 'describe',
  91. '--tags',
  92. ]
  93. return subprocess.check_output(args).decode('utf-8').rsplit('-', 2)[0:2]
  94. def format_patch(repo, since):
  95. args = [
  96. 'git',
  97. '-C',
  98. repo,
  99. '-c',
  100. 'core.attributesfile='
  101. + os.path.join(
  102. os.path.dirname(os.path.realpath(__file__)),
  103. 'electron.gitattributes',
  104. ),
  105. # Ensure it is not possible to match anything
  106. # Disabled for now as we have consistent chunk headers
  107. # '-c',
  108. # 'diff.electron.xfuncname=$^',
  109. 'format-patch',
  110. '--keep-subject',
  111. '--no-stat',
  112. '--stdout',
  113. # Per RFC 3676 the signature is separated from the body by a line with
  114. # '-- ' on it. If the signature option is omitted the signature defaults
  115. # to the Git version number.
  116. '--no-signature',
  117. # The name of the parent commit object isn't useful information in this
  118. # context, so zero it out to avoid needless patch-file churn.
  119. '--zero-commit',
  120. # Some versions of git print out different numbers of characters in the
  121. # 'index' line of patches, so pass --full-index to get consistent
  122. # behaviour.
  123. '--full-index',
  124. since
  125. ]
  126. return subprocess.check_output(args).decode('utf-8')
  127. def split_patches(patch_data):
  128. """Split a concatenated series of patches into N separate patches"""
  129. patches = []
  130. patch_start = re.compile('^From [0-9a-f]+ ')
  131. # Keep line endings in case any patches target files with CRLF.
  132. keep_line_endings = True
  133. for line in patch_data.splitlines(keep_line_endings):
  134. if patch_start.match(line):
  135. patches.append([])
  136. patches[-1].append(line)
  137. return patches
  138. def filter_patches(patches, key):
  139. """Return patches that include the specified key"""
  140. if key is None:
  141. return patches
  142. matches = []
  143. for patch in patches:
  144. if any(key in line for line in patch):
  145. matches.append(patch)
  146. continue
  147. return matches
  148. def munge_subject_to_filename(subject):
  149. """Derive a suitable filename from a commit's subject"""
  150. if subject.endswith('.patch'):
  151. subject = subject[:-6]
  152. return re.sub(r'[^A-Za-z0-9-]+', '_', subject).strip('_').lower() + '.patch'
  153. def get_file_name(patch):
  154. """Return the name of the file to which the patch should be written"""
  155. file_name = None
  156. for line in patch:
  157. if line.startswith(PATCH_FILENAME_PREFIX):
  158. file_name = line[len(PATCH_FILENAME_PREFIX):]
  159. break
  160. # If no patch-filename header, munge the subject.
  161. if not file_name:
  162. for line in patch:
  163. if line.startswith('Subject: '):
  164. file_name = munge_subject_to_filename(line[len('Subject: '):])
  165. break
  166. return file_name.rstrip('\n')
  167. def join_patch(patch):
  168. """Joins and formats patch contents"""
  169. return ''.join(remove_patch_location(patch)).rstrip('\n') + '\n'
  170. def remove_patch_location(patch):
  171. """Strip out the patch location lines from a patch's message body"""
  172. force_keep_next_line = False
  173. n = len(patch)
  174. for i, l in enumerate(patch):
  175. skip_line = is_patch_location_line(l)
  176. skip_next = i < n - 1 and is_patch_location_line(patch[i + 1])
  177. if not force_keep_next_line and (
  178. skip_line or (skip_next and len(l.rstrip()) == 0)
  179. ):
  180. pass # drop this line
  181. else:
  182. yield l
  183. force_keep_next_line = l.startswith('Subject: ')
  184. def to_utf8(patch):
  185. """Python 2/3 compatibility: unicode has been renamed to str in Python3"""
  186. if sys.version_info[0] >= 3:
  187. return str(patch, "utf-8")
  188. return unicode(patch, "utf-8")
  189. def export_patches(repo, out_dir,
  190. patch_range=None, ref=UPSTREAM_HEAD,
  191. dry_run=False, grep=None):
  192. if not os.path.exists(repo):
  193. sys.stderr.write(
  194. "Skipping patches in {} because it does not exist.\n".format(repo)
  195. )
  196. return
  197. if patch_range is None:
  198. patch_range, num_patches = guess_base_commit(repo, ref)
  199. sys.stderr.write("Exporting {} patches in {} since {}\n".format(
  200. num_patches, repo, patch_range[0:7]))
  201. patch_data = format_patch(repo, patch_range)
  202. patches = split_patches(patch_data)
  203. if grep:
  204. olen = len(patches)
  205. patches = filter_patches(patches, grep)
  206. sys.stderr.write("Exporting {} of {} patches\n".format(len(patches), olen))
  207. try:
  208. os.mkdir(out_dir)
  209. except OSError:
  210. pass
  211. if dry_run:
  212. # If we're doing a dry run, iterate through each patch and see if the newly
  213. # exported patch differs from what exists. Report number of mismatched
  214. # patches and fail if there's more than one.
  215. bad_patches = []
  216. for patch in patches:
  217. filename = get_file_name(patch)
  218. filepath = posixpath.join(out_dir, filename)
  219. existing_patch = to_utf8(io.open(filepath, 'rb').read())
  220. formatted_patch = join_patch(patch)
  221. if formatted_patch != existing_patch:
  222. bad_patches.append(filename)
  223. if len(bad_patches) > 0:
  224. sys.stderr.write(
  225. "Patches in {} not up to date: {} patches need update\n-- {}\n".format(
  226. out_dir, len(bad_patches), "\n-- ".join(bad_patches)
  227. )
  228. )
  229. sys.exit(1)
  230. else:
  231. # Remove old patches so that deleted commits are correctly reflected in the
  232. # patch files (as a removed file)
  233. for p in os.listdir(out_dir):
  234. if p.endswith('.patch'):
  235. os.remove(posixpath.join(out_dir, p))
  236. with io.open(
  237. posixpath.join(out_dir, '.patches'),
  238. 'w',
  239. newline='\n',
  240. encoding='utf-8',
  241. ) as pl:
  242. for patch in patches:
  243. filename = get_file_name(patch)
  244. file_path = posixpath.join(out_dir, filename)
  245. formatted_patch = join_patch(patch)
  246. # Write in binary mode to retain mixed line endings on write.
  247. with io.open(
  248. file_path, 'wb'
  249. ) as f:
  250. f.write(formatted_patch.encode('utf-8'))
  251. pl.write(filename + '\n')