git.py 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275
  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. import os
  7. import re
  8. import subprocess
  9. import sys
  10. def is_repo_root(path):
  11. path_exists = os.path.exists(path)
  12. if not path_exists:
  13. return False
  14. git_folder_path = os.path.join(path, '.git')
  15. git_folder_exists = os.path.exists(git_folder_path)
  16. return git_folder_exists
  17. def get_repo_root(path):
  18. """Finds a closest ancestor folder which is a repo root."""
  19. norm_path = os.path.normpath(path)
  20. norm_path_exists = os.path.exists(norm_path)
  21. if not norm_path_exists:
  22. return None
  23. if is_repo_root(norm_path):
  24. return norm_path
  25. parent_path = os.path.dirname(norm_path)
  26. # Check if we're in the root folder already.
  27. if parent_path == norm_path:
  28. return None
  29. return get_repo_root(parent_path)
  30. def am(repo, patch_data, threeway=False, directory=None, exclude=None,
  31. committer_name=None, committer_email=None):
  32. args = []
  33. if threeway:
  34. args += ['--3way']
  35. if directory is not None:
  36. args += ['--directory', directory]
  37. if exclude is not None:
  38. for path_pattern in exclude:
  39. args += ['--exclude', path_pattern]
  40. root_args = ['-C', repo]
  41. if committer_name is not None:
  42. root_args += ['-c', 'user.name=' + committer_name]
  43. if committer_email is not None:
  44. root_args += ['-c', 'user.email=' + committer_email]
  45. root_args += ['-c', 'commit.gpgsign=false']
  46. command = ['git'] + root_args + ['am'] + args
  47. proc = subprocess.Popen(
  48. command,
  49. stdin=subprocess.PIPE)
  50. proc.communicate(patch_data)
  51. if proc.returncode != 0:
  52. raise RuntimeError("Command {} returned {}".format(command,
  53. proc.returncode))
  54. def apply_patch(repo, patch_path, directory=None, index=False, reverse=False):
  55. args = ['git', '-C', repo, 'apply',
  56. '--ignore-space-change',
  57. '--ignore-whitespace',
  58. '--whitespace', 'fix'
  59. ]
  60. if directory:
  61. args += ['--directory', directory]
  62. if index:
  63. args += ['--index']
  64. if reverse:
  65. args += ['--reverse']
  66. args += ['--', patch_path]
  67. return_code = subprocess.call(args)
  68. applied_successfully = (return_code == 0)
  69. return applied_successfully
  70. def get_patch(repo, commit_hash):
  71. args = ['git', '-C', repo, 'diff-tree',
  72. '-p',
  73. commit_hash,
  74. '--' # Explicitly tell Git `commit_hash` is a revision, not a path.
  75. ]
  76. return subprocess.check_output(args)
  77. def get_head_commit(repo):
  78. args = ['git', '-C', repo, 'rev-parse', 'HEAD']
  79. return subprocess.check_output(args).strip()
  80. def update_ref(repo, ref, newvalue):
  81. args = ['git', '-C', repo, 'update-ref', ref, newvalue]
  82. return subprocess.check_call(args)
  83. def reset(repo):
  84. args = ['git', '-C', repo, 'reset']
  85. subprocess.check_call(args)
  86. def commit(repo, author, message):
  87. """Commit whatever in the index is now."""
  88. # Let's setup committer info so git won't complain about it being missing.
  89. # TODO: Is there a better way to set committer's name and email?
  90. env = os.environ.copy()
  91. env['GIT_COMMITTER_NAME'] = 'Anonymous Committer'
  92. env['GIT_COMMITTER_EMAIL'] = '[email protected]'
  93. args = ['git', '-C', repo, 'commit',
  94. '--author', author,
  95. '--message', message
  96. ]
  97. return_code = subprocess.call(args, env=env)
  98. committed_successfully = (return_code == 0)
  99. return committed_successfully
  100. def get_upstream_head(repo):
  101. args = [
  102. 'git',
  103. '-C',
  104. repo,
  105. 'rev-parse',
  106. '--verify',
  107. 'refs/patches/upstream-head',
  108. ]
  109. return subprocess.check_output(args).strip()
  110. def get_commit_count(repo, commit_range):
  111. args = [
  112. 'git',
  113. '-C',
  114. repo,
  115. 'rev-list',
  116. '--count',
  117. commit_range
  118. ]
  119. return int(subprocess.check_output(args).strip())
  120. def guess_base_commit(repo):
  121. """Guess which commit the patches might be based on"""
  122. try:
  123. upstream_head = get_upstream_head(repo)
  124. num_commits = get_commit_count(repo, upstream_head + '..')
  125. return [upstream_head, num_commits]
  126. except subprocess.CalledProcessError:
  127. args = [
  128. 'git',
  129. '-C',
  130. repo,
  131. 'describe',
  132. '--tags',
  133. ]
  134. return subprocess.check_output(args).rsplit('-', 2)[0:2]
  135. def format_patch(repo, since):
  136. args = [
  137. 'git',
  138. '-C',
  139. repo,
  140. '-c',
  141. 'core.attributesfile=' + os.path.join(os.path.dirname(os.path.realpath(__file__)), '.electron.attributes'),
  142. # Ensure it is not possible to match anything
  143. # Disabled for now as we have consistent chunk headers
  144. # '-c',
  145. # 'diff.electron.xfuncname=$^',
  146. 'format-patch',
  147. '--keep-subject',
  148. '--no-stat',
  149. '--stdout',
  150. # Per RFC 3676 the signature is separated from the body by a line with
  151. # '-- ' on it. If the signature option is omitted the signature defaults
  152. # to the Git version number.
  153. '--no-signature',
  154. # The name of the parent commit object isn't useful information in this
  155. # context, so zero it out to avoid needless patch-file churn.
  156. '--zero-commit',
  157. # Some versions of git print out different numbers of characters in the
  158. # 'index' line of patches, so pass --full-index to get consistent
  159. # behaviour.
  160. '--full-index',
  161. since
  162. ]
  163. return subprocess.check_output(args)
  164. def split_patches(patch_data):
  165. """Split a concatenated series of patches into N separate patches"""
  166. patches = []
  167. patch_start = re.compile('^From [0-9a-f]+ ')
  168. for line in patch_data.splitlines():
  169. if patch_start.match(line):
  170. patches.append([])
  171. patches[-1].append(line)
  172. return patches
  173. def munge_subject_to_filename(subject):
  174. """Derive a suitable filename from a commit's subject"""
  175. if subject.endswith('.patch'):
  176. subject = subject[:-6]
  177. return re.sub(r'[^A-Za-z0-9-]+', '_', subject).strip('_').lower() + '.patch'
  178. def get_file_name(patch):
  179. """Return the name of the file to which the patch should be written"""
  180. for line in patch:
  181. if line.startswith('Patch-Filename: '):
  182. return line[len('Patch-Filename: '):]
  183. # If no patch-filename header, munge the subject.
  184. for line in patch:
  185. if line.startswith('Subject: '):
  186. return munge_subject_to_filename(line[len('Subject: '):])
  187. def remove_patch_filename(patch):
  188. """Strip out the Patch-Filename trailer from a patch's message body"""
  189. force_keep_next_line = False
  190. for i, l in enumerate(patch):
  191. is_patchfilename = l.startswith('Patch-Filename: ')
  192. next_is_patchfilename = i < len(patch) - 1 and patch[i+1].startswith('Patch-Filename: ')
  193. if not force_keep_next_line and (is_patchfilename or (next_is_patchfilename and len(l.rstrip()) == 0)):
  194. pass # drop this line
  195. else:
  196. yield l
  197. force_keep_next_line = l.startswith('Subject: ')
  198. def export_patches(repo, out_dir, patch_range=None):
  199. if patch_range is None:
  200. patch_range, num_patches = guess_base_commit(repo)
  201. sys.stderr.write("Exporting {} patches since {}\n".format(num_patches, patch_range))
  202. patch_data = format_patch(repo, patch_range)
  203. patches = split_patches(patch_data)
  204. try:
  205. os.mkdir(out_dir)
  206. except OSError:
  207. pass
  208. # remove old patches, so that deleted commits are correctly reflected in the
  209. # patch files (as a removed file)
  210. for p in os.listdir(out_dir):
  211. if p.endswith('.patch'):
  212. os.remove(os.path.join(out_dir, p))
  213. with open(os.path.join(out_dir, '.patches'), 'w') as pl:
  214. for patch in patches:
  215. filename = get_file_name(patch)
  216. with open(os.path.join(out_dir, filename), 'w') as f:
  217. f.write('\n'.join(remove_patch_filename(patch)).rstrip('\n') + '\n')
  218. pl.write(filename + '\n')