git.py 7.5 KB

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