git.py 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333
  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 apply_patch(repo, patch_path, directory=None, index=False, reverse=False):
  58. args = ['git', '-C', repo, 'apply',
  59. '--ignore-space-change',
  60. '--ignore-whitespace',
  61. '--whitespace', 'fix'
  62. ]
  63. if directory:
  64. args += ['--directory', directory]
  65. if index:
  66. args += ['--index']
  67. if reverse:
  68. args += ['--reverse']
  69. args += ['--', patch_path]
  70. return_code = subprocess.call(args)
  71. applied_successfully = (return_code == 0)
  72. return applied_successfully
  73. def import_patches(repo, **kwargs):
  74. """same as am(), but we save the upstream HEAD so we can refer to it when we
  75. later export patches"""
  76. update_ref(
  77. repo=repo,
  78. ref='refs/patches/upstream-head',
  79. newvalue='HEAD'
  80. )
  81. am(repo=repo, **kwargs)
  82. def get_patch(repo, commit_hash):
  83. args = ['git', '-C', repo, 'diff-tree',
  84. '-p',
  85. commit_hash,
  86. '--' # Explicitly tell Git `commit_hash` is a revision, not a path.
  87. ]
  88. return subprocess.check_output(args).decode('utf-8')
  89. def get_head_commit(repo):
  90. args = ['git', '-C', repo, 'rev-parse', 'HEAD']
  91. return subprocess.check_output(args).decode('utf-8').strip()
  92. def update_ref(repo, ref, newvalue):
  93. args = ['git', '-C', repo, 'update-ref', ref, newvalue]
  94. return subprocess.check_call(args)
  95. def reset(repo):
  96. args = ['git', '-C', repo, 'reset']
  97. subprocess.check_call(args)
  98. def commit(repo, author, message):
  99. """Commit whatever in the index is now."""
  100. # Let's setup committer info so git won't complain about it being missing.
  101. # TODO: Is there a better way to set committer's name and email?
  102. env = os.environ.copy()
  103. env['GIT_COMMITTER_NAME'] = 'Anonymous Committer'
  104. env['GIT_COMMITTER_EMAIL'] = '[email protected]'
  105. args = ['git', '-C', repo, 'commit',
  106. '--author', author,
  107. '--message', message
  108. ]
  109. return_code = subprocess.call(args, env=env)
  110. committed_successfully = (return_code == 0)
  111. return committed_successfully
  112. def get_upstream_head(repo):
  113. args = [
  114. 'git',
  115. '-C',
  116. repo,
  117. 'rev-parse',
  118. '--verify',
  119. 'refs/patches/upstream-head',
  120. ]
  121. return subprocess.check_output(args).decode('utf-8').strip()
  122. def get_commit_count(repo, commit_range):
  123. args = [
  124. 'git',
  125. '-C',
  126. repo,
  127. 'rev-list',
  128. '--count',
  129. commit_range
  130. ]
  131. return int(subprocess.check_output(args).decode('utf-8').strip())
  132. def guess_base_commit(repo):
  133. """Guess which commit the patches might be based on"""
  134. try:
  135. upstream_head = get_upstream_head(repo)
  136. num_commits = get_commit_count(repo, upstream_head + '..')
  137. return [upstream_head, num_commits]
  138. except subprocess.CalledProcessError:
  139. args = [
  140. 'git',
  141. '-C',
  142. repo,
  143. 'describe',
  144. '--tags',
  145. ]
  146. return subprocess.check_output(args).decode('utf-8').rsplit('-', 2)[0:2]
  147. def format_patch(repo, since):
  148. args = [
  149. 'git',
  150. '-C',
  151. repo,
  152. '-c',
  153. 'core.attributesfile='
  154. + os.path.join(
  155. os.path.dirname(os.path.realpath(__file__)),
  156. 'electron.gitattributes',
  157. ),
  158. # Ensure it is not possible to match anything
  159. # Disabled for now as we have consistent chunk headers
  160. # '-c',
  161. # 'diff.electron.xfuncname=$^',
  162. 'format-patch',
  163. '--keep-subject',
  164. '--no-stat',
  165. '--stdout',
  166. # Per RFC 3676 the signature is separated from the body by a line with
  167. # '-- ' on it. If the signature option is omitted the signature defaults
  168. # to the Git version number.
  169. '--no-signature',
  170. # The name of the parent commit object isn't useful information in this
  171. # context, so zero it out to avoid needless patch-file churn.
  172. '--zero-commit',
  173. # Some versions of git print out different numbers of characters in the
  174. # 'index' line of patches, so pass --full-index to get consistent
  175. # behaviour.
  176. '--full-index',
  177. since
  178. ]
  179. return subprocess.check_output(args).decode('utf-8')
  180. def split_patches(patch_data):
  181. """Split a concatenated series of patches into N separate patches"""
  182. patches = []
  183. patch_start = re.compile('^From [0-9a-f]+ ')
  184. for line in patch_data.splitlines():
  185. if patch_start.match(line):
  186. patches.append([])
  187. patches[-1].append(line)
  188. return patches
  189. def munge_subject_to_filename(subject):
  190. """Derive a suitable filename from a commit's subject"""
  191. if subject.endswith('.patch'):
  192. subject = subject[:-6]
  193. return re.sub(r'[^A-Za-z0-9-]+', '_', subject).strip('_').lower() + '.patch'
  194. def get_file_name(patch):
  195. """Return the name of the file to which the patch should be written"""
  196. for line in patch:
  197. if line.startswith('Patch-Filename: '):
  198. return line[len('Patch-Filename: '):]
  199. # If no patch-filename header, munge the subject.
  200. for line in patch:
  201. if line.startswith('Subject: '):
  202. return munge_subject_to_filename(line[len('Subject: '):])
  203. def remove_patch_filename(patch):
  204. """Strip out the Patch-Filename trailer from a patch's message body"""
  205. force_keep_next_line = False
  206. for i, l in enumerate(patch):
  207. is_patchfilename = l.startswith('Patch-Filename: ')
  208. next_is_patchfilename = i < len(patch) - 1 and patch[i + 1].startswith(
  209. 'Patch-Filename: '
  210. )
  211. if not force_keep_next_line and (
  212. is_patchfilename or (next_is_patchfilename and len(l.rstrip()) == 0)
  213. ):
  214. pass # drop this line
  215. else:
  216. yield l
  217. force_keep_next_line = l.startswith('Subject: ')
  218. def export_patches(repo, out_dir, patch_range=None, dry_run=False):
  219. if patch_range is None:
  220. patch_range, num_patches = guess_base_commit(repo)
  221. sys.stderr.write(
  222. "Exporting {} patches since {}\n".format(num_patches, patch_range)
  223. )
  224. patch_data = format_patch(repo, patch_range)
  225. patches = split_patches(patch_data)
  226. try:
  227. os.mkdir(out_dir)
  228. except OSError:
  229. pass
  230. if dry_run:
  231. # If we're doing a dry run, iterate through each patch and see if the newly
  232. # exported patch differs from what exists. Report number of mismatched
  233. # patches and fail if there's more than one.
  234. patch_count = 0
  235. for patch in patches:
  236. filename = get_file_name(patch)
  237. filepath = posixpath.join(out_dir, filename)
  238. existing_patch = io.open(filepath, 'r', encoding='utf-8').read()
  239. formatted_patch = (
  240. '\n'.join(remove_patch_filename(patch)).rstrip('\n') + '\n'
  241. )
  242. if formatted_patch != existing_patch:
  243. patch_count += 1
  244. if patch_count > 0:
  245. sys.stderr.write(
  246. "Patches in {} not up to date: {} patches need update\n".format(
  247. out_dir, patch_count
  248. )
  249. )
  250. exit(1)
  251. else:
  252. # Remove old patches so that deleted commits are correctly reflected in the
  253. # patch files (as a removed file)
  254. for p in os.listdir(out_dir):
  255. if p.endswith('.patch'):
  256. os.remove(posixpath.join(out_dir, p))
  257. with io.open(
  258. posixpath.join(out_dir, '.patches'),
  259. 'w',
  260. newline='\n',
  261. encoding='utf-8',
  262. ) as pl:
  263. for patch in patches:
  264. filename = get_file_name(patch)
  265. file_path = posixpath.join(out_dir, filename)
  266. formatted_patch = (
  267. '\n'.join(remove_patch_filename(patch)).rstrip('\n') + '\n'
  268. )
  269. with io.open(
  270. file_path, 'w', newline='\n', encoding='utf-8'
  271. ) as f:
  272. f.write(formatted_patch)
  273. pl.write(filename + '\n')