git.py 8.0 KB

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