gitutil.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590
  1. # Copyright (c) 2011 The Chromium OS Authors.
  2. #
  3. # SPDX-License-Identifier: GPL-2.0+
  4. #
  5. import command
  6. import re
  7. import os
  8. import series
  9. import subprocess
  10. import sys
  11. import terminal
  12. import checkpatch
  13. import settings
  14. # True to use --no-decorate - we check this in Setup()
  15. use_no_decorate = True
  16. def LogCmd(commit_range, git_dir=None, oneline=False, reverse=False,
  17. count=None):
  18. """Create a command to perform a 'git log'
  19. Args:
  20. commit_range: Range expression to use for log, None for none
  21. git_dir: Path to git repositiory (None to use default)
  22. oneline: True to use --oneline, else False
  23. reverse: True to reverse the log (--reverse)
  24. count: Number of commits to list, or None for no limit
  25. Return:
  26. List containing command and arguments to run
  27. """
  28. cmd = ['git']
  29. if git_dir:
  30. cmd += ['--git-dir', git_dir]
  31. cmd += ['log', '--no-color']
  32. if oneline:
  33. cmd.append('--oneline')
  34. if use_no_decorate:
  35. cmd.append('--no-decorate')
  36. if reverse:
  37. cmd.append('--reverse')
  38. if count is not None:
  39. cmd.append('-n%d' % count)
  40. if commit_range:
  41. cmd.append(commit_range)
  42. return cmd
  43. def CountCommitsToBranch():
  44. """Returns number of commits between HEAD and the tracking branch.
  45. This looks back to the tracking branch and works out the number of commits
  46. since then.
  47. Return:
  48. Number of patches that exist on top of the branch
  49. """
  50. pipe = [LogCmd('@{upstream}..', oneline=True),
  51. ['wc', '-l']]
  52. stdout = command.RunPipe(pipe, capture=True, oneline=True).stdout
  53. patch_count = int(stdout)
  54. return patch_count
  55. def GetUpstream(git_dir, branch):
  56. """Returns the name of the upstream for a branch
  57. Args:
  58. git_dir: Git directory containing repo
  59. branch: Name of branch
  60. Returns:
  61. Name of upstream branch (e.g. 'upstream/master') or None if none
  62. """
  63. try:
  64. remote = command.OutputOneLine('git', '--git-dir', git_dir, 'config',
  65. 'branch.%s.remote' % branch)
  66. merge = command.OutputOneLine('git', '--git-dir', git_dir, 'config',
  67. 'branch.%s.merge' % branch)
  68. except:
  69. return None
  70. if remote == '.':
  71. return merge
  72. elif remote and merge:
  73. leaf = merge.split('/')[-1]
  74. return '%s/%s' % (remote, leaf)
  75. else:
  76. raise ValueError, ("Cannot determine upstream branch for branch "
  77. "'%s' remote='%s', merge='%s'" % (branch, remote, merge))
  78. def GetRangeInBranch(git_dir, branch, include_upstream=False):
  79. """Returns an expression for the commits in the given branch.
  80. Args:
  81. git_dir: Directory containing git repo
  82. branch: Name of branch
  83. Return:
  84. Expression in the form 'upstream..branch' which can be used to
  85. access the commits. If the branch does not exist, returns None.
  86. """
  87. upstream = GetUpstream(git_dir, branch)
  88. if not upstream:
  89. return None
  90. return '%s%s..%s' % (upstream, '~' if include_upstream else '', branch)
  91. def CountCommitsInBranch(git_dir, branch, include_upstream=False):
  92. """Returns the number of commits in the given branch.
  93. Args:
  94. git_dir: Directory containing git repo
  95. branch: Name of branch
  96. Return:
  97. Number of patches that exist on top of the branch, or None if the
  98. branch does not exist.
  99. """
  100. range_expr = GetRangeInBranch(git_dir, branch, include_upstream)
  101. if not range_expr:
  102. return None
  103. pipe = [LogCmd(range_expr, git_dir=git_dir, oneline=True),
  104. ['wc', '-l']]
  105. result = command.RunPipe(pipe, capture=True, oneline=True)
  106. patch_count = int(result.stdout)
  107. return patch_count
  108. def CountCommits(commit_range):
  109. """Returns the number of commits in the given range.
  110. Args:
  111. commit_range: Range of commits to count (e.g. 'HEAD..base')
  112. Return:
  113. Number of patches that exist on top of the branch
  114. """
  115. pipe = [LogCmd(commit_range, oneline=True),
  116. ['wc', '-l']]
  117. stdout = command.RunPipe(pipe, capture=True, oneline=True).stdout
  118. patch_count = int(stdout)
  119. return patch_count
  120. def Checkout(commit_hash, git_dir=None, work_tree=None, force=False):
  121. """Checkout the selected commit for this build
  122. Args:
  123. commit_hash: Commit hash to check out
  124. """
  125. pipe = ['git']
  126. if git_dir:
  127. pipe.extend(['--git-dir', git_dir])
  128. if work_tree:
  129. pipe.extend(['--work-tree', work_tree])
  130. pipe.append('checkout')
  131. if force:
  132. pipe.append('-f')
  133. pipe.append(commit_hash)
  134. result = command.RunPipe([pipe], capture=True, raise_on_error=False)
  135. if result.return_code != 0:
  136. raise OSError, 'git checkout (%s): %s' % (pipe, result.stderr)
  137. def Clone(git_dir, output_dir):
  138. """Checkout the selected commit for this build
  139. Args:
  140. commit_hash: Commit hash to check out
  141. """
  142. pipe = ['git', 'clone', git_dir, '.']
  143. result = command.RunPipe([pipe], capture=True, cwd=output_dir)
  144. if result.return_code != 0:
  145. raise OSError, 'git clone: %s' % result.stderr
  146. def Fetch(git_dir=None, work_tree=None):
  147. """Fetch from the origin repo
  148. Args:
  149. commit_hash: Commit hash to check out
  150. """
  151. pipe = ['git']
  152. if git_dir:
  153. pipe.extend(['--git-dir', git_dir])
  154. if work_tree:
  155. pipe.extend(['--work-tree', work_tree])
  156. pipe.append('fetch')
  157. result = command.RunPipe([pipe], capture=True)
  158. if result.return_code != 0:
  159. raise OSError, 'git fetch: %s' % result.stderr
  160. def CreatePatches(start, count, series):
  161. """Create a series of patches from the top of the current branch.
  162. The patch files are written to the current directory using
  163. git format-patch.
  164. Args:
  165. start: Commit to start from: 0=HEAD, 1=next one, etc.
  166. count: number of commits to include
  167. Return:
  168. Filename of cover letter
  169. List of filenames of patch files
  170. """
  171. if series.get('version'):
  172. version = '%s ' % series['version']
  173. cmd = ['git', 'format-patch', '-M', '--signoff']
  174. if series.get('cover'):
  175. cmd.append('--cover-letter')
  176. prefix = series.GetPatchPrefix()
  177. if prefix:
  178. cmd += ['--subject-prefix=%s' % prefix]
  179. cmd += ['HEAD~%d..HEAD~%d' % (start + count, start)]
  180. stdout = command.RunList(cmd)
  181. files = stdout.splitlines()
  182. # We have an extra file if there is a cover letter
  183. if series.get('cover'):
  184. return files[0], files[1:]
  185. else:
  186. return None, files
  187. def ApplyPatch(verbose, fname):
  188. """Apply a patch with git am to test it
  189. TODO: Convert these to use command, with stderr option
  190. Args:
  191. fname: filename of patch file to apply
  192. """
  193. col = terminal.Color()
  194. cmd = ['git', 'am', fname]
  195. pipe = subprocess.Popen(cmd, stdout=subprocess.PIPE,
  196. stderr=subprocess.PIPE)
  197. stdout, stderr = pipe.communicate()
  198. re_error = re.compile('^error: patch failed: (.+):(\d+)')
  199. for line in stderr.splitlines():
  200. if verbose:
  201. print line
  202. match = re_error.match(line)
  203. if match:
  204. print checkpatch.GetWarningMsg(col, 'warning', match.group(1),
  205. int(match.group(2)), 'Patch failed')
  206. return pipe.returncode == 0, stdout
  207. def ApplyPatches(verbose, args, start_point):
  208. """Apply the patches with git am to make sure all is well
  209. Args:
  210. verbose: Print out 'git am' output verbatim
  211. args: List of patch files to apply
  212. start_point: Number of commits back from HEAD to start applying.
  213. Normally this is len(args), but it can be larger if a start
  214. offset was given.
  215. """
  216. error_count = 0
  217. col = terminal.Color()
  218. # Figure out our current position
  219. cmd = ['git', 'name-rev', 'HEAD', '--name-only']
  220. pipe = subprocess.Popen(cmd, stdout=subprocess.PIPE)
  221. stdout, stderr = pipe.communicate()
  222. if pipe.returncode:
  223. str = 'Could not find current commit name'
  224. print col.Color(col.RED, str)
  225. print stdout
  226. return False
  227. old_head = stdout.splitlines()[0]
  228. if old_head == 'undefined':
  229. str = "Invalid HEAD '%s'" % stdout.strip()
  230. print col.Color(col.RED, str)
  231. return False
  232. # Checkout the required start point
  233. cmd = ['git', 'checkout', 'HEAD~%d' % start_point]
  234. pipe = subprocess.Popen(cmd, stdout=subprocess.PIPE,
  235. stderr=subprocess.PIPE)
  236. stdout, stderr = pipe.communicate()
  237. if pipe.returncode:
  238. str = 'Could not move to commit before patch series'
  239. print col.Color(col.RED, str)
  240. print stdout, stderr
  241. return False
  242. # Apply all the patches
  243. for fname in args:
  244. ok, stdout = ApplyPatch(verbose, fname)
  245. if not ok:
  246. print col.Color(col.RED, 'git am returned errors for %s: will '
  247. 'skip this patch' % fname)
  248. if verbose:
  249. print stdout
  250. error_count += 1
  251. cmd = ['git', 'am', '--skip']
  252. pipe = subprocess.Popen(cmd, stdout=subprocess.PIPE)
  253. stdout, stderr = pipe.communicate()
  254. if pipe.returncode != 0:
  255. print col.Color(col.RED, 'Unable to skip patch! Aborting...')
  256. print stdout
  257. break
  258. # Return to our previous position
  259. cmd = ['git', 'checkout', old_head]
  260. pipe = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
  261. stdout, stderr = pipe.communicate()
  262. if pipe.returncode:
  263. print col.Color(col.RED, 'Could not move back to head commit')
  264. print stdout, stderr
  265. return error_count == 0
  266. def BuildEmailList(in_list, tag=None, alias=None, raise_on_error=True):
  267. """Build a list of email addresses based on an input list.
  268. Takes a list of email addresses and aliases, and turns this into a list
  269. of only email address, by resolving any aliases that are present.
  270. If the tag is given, then each email address is prepended with this
  271. tag and a space. If the tag starts with a minus sign (indicating a
  272. command line parameter) then the email address is quoted.
  273. Args:
  274. in_list: List of aliases/email addresses
  275. tag: Text to put before each address
  276. alias: Alias dictionary
  277. raise_on_error: True to raise an error when an alias fails to match,
  278. False to just print a message.
  279. Returns:
  280. List of email addresses
  281. >>> alias = {}
  282. >>> alias['fred'] = ['f.bloggs@napier.co.nz']
  283. >>> alias['john'] = ['j.bloggs@napier.co.nz']
  284. >>> alias['mary'] = ['Mary Poppins <m.poppins@cloud.net>']
  285. >>> alias['boys'] = ['fred', ' john']
  286. >>> alias['all'] = ['fred ', 'john', ' mary ']
  287. >>> BuildEmailList(['john', 'mary'], None, alias)
  288. ['j.bloggs@napier.co.nz', 'Mary Poppins <m.poppins@cloud.net>']
  289. >>> BuildEmailList(['john', 'mary'], '--to', alias)
  290. ['--to "j.bloggs@napier.co.nz"', \
  291. '--to "Mary Poppins <m.poppins@cloud.net>"']
  292. >>> BuildEmailList(['john', 'mary'], 'Cc', alias)
  293. ['Cc j.bloggs@napier.co.nz', 'Cc Mary Poppins <m.poppins@cloud.net>']
  294. """
  295. quote = '"' if tag and tag[0] == '-' else ''
  296. raw = []
  297. for item in in_list:
  298. raw += LookupEmail(item, alias, raise_on_error=raise_on_error)
  299. result = []
  300. for item in raw:
  301. if not item in result:
  302. result.append(item)
  303. if tag:
  304. return ['%s %s%s%s' % (tag, quote, email, quote) for email in result]
  305. return result
  306. def EmailPatches(series, cover_fname, args, dry_run, raise_on_error, cc_fname,
  307. self_only=False, alias=None, in_reply_to=None):
  308. """Email a patch series.
  309. Args:
  310. series: Series object containing destination info
  311. cover_fname: filename of cover letter
  312. args: list of filenames of patch files
  313. dry_run: Just return the command that would be run
  314. raise_on_error: True to raise an error when an alias fails to match,
  315. False to just print a message.
  316. cc_fname: Filename of Cc file for per-commit Cc
  317. self_only: True to just email to yourself as a test
  318. in_reply_to: If set we'll pass this to git as --in-reply-to.
  319. Should be a message ID that this is in reply to.
  320. Returns:
  321. Git command that was/would be run
  322. # For the duration of this doctest pretend that we ran patman with ./patman
  323. >>> _old_argv0 = sys.argv[0]
  324. >>> sys.argv[0] = './patman'
  325. >>> alias = {}
  326. >>> alias['fred'] = ['f.bloggs@napier.co.nz']
  327. >>> alias['john'] = ['j.bloggs@napier.co.nz']
  328. >>> alias['mary'] = ['m.poppins@cloud.net']
  329. >>> alias['boys'] = ['fred', ' john']
  330. >>> alias['all'] = ['fred ', 'john', ' mary ']
  331. >>> alias[os.getenv('USER')] = ['this-is-me@me.com']
  332. >>> series = series.Series()
  333. >>> series.to = ['fred']
  334. >>> series.cc = ['mary']
  335. >>> EmailPatches(series, 'cover', ['p1', 'p2'], True, True, 'cc-fname', \
  336. False, alias)
  337. 'git send-email --annotate --to "f.bloggs@napier.co.nz" --cc \
  338. "m.poppins@cloud.net" --cc-cmd "./patman --cc-cmd cc-fname" cover p1 p2'
  339. >>> EmailPatches(series, None, ['p1'], True, True, 'cc-fname', False, \
  340. alias)
  341. 'git send-email --annotate --to "f.bloggs@napier.co.nz" --cc \
  342. "m.poppins@cloud.net" --cc-cmd "./patman --cc-cmd cc-fname" p1'
  343. >>> series.cc = ['all']
  344. >>> EmailPatches(series, 'cover', ['p1', 'p2'], True, True, 'cc-fname', \
  345. True, alias)
  346. 'git send-email --annotate --to "this-is-me@me.com" --cc-cmd "./patman \
  347. --cc-cmd cc-fname" cover p1 p2'
  348. >>> EmailPatches(series, 'cover', ['p1', 'p2'], True, True, 'cc-fname', \
  349. False, alias)
  350. 'git send-email --annotate --to "f.bloggs@napier.co.nz" --cc \
  351. "f.bloggs@napier.co.nz" --cc "j.bloggs@napier.co.nz" --cc \
  352. "m.poppins@cloud.net" --cc-cmd "./patman --cc-cmd cc-fname" cover p1 p2'
  353. # Restore argv[0] since we clobbered it.
  354. >>> sys.argv[0] = _old_argv0
  355. """
  356. to = BuildEmailList(series.get('to'), '--to', alias, raise_on_error)
  357. if not to:
  358. git_config_to = command.Output('git', 'config', 'sendemail.to')
  359. if not git_config_to:
  360. print ("No recipient.\n"
  361. "Please add something like this to a commit\n"
  362. "Series-to: Fred Bloggs <f.blogs@napier.co.nz>\n"
  363. "Or do something like this\n"
  364. "git config sendemail.to u-boot@lists.denx.de")
  365. return
  366. cc = BuildEmailList(series.get('cc'), '--cc', alias, raise_on_error)
  367. if self_only:
  368. to = BuildEmailList([os.getenv('USER')], '--to', alias, raise_on_error)
  369. cc = []
  370. cmd = ['git', 'send-email', '--annotate']
  371. if in_reply_to:
  372. cmd.append('--in-reply-to="%s"' % in_reply_to)
  373. cmd += to
  374. cmd += cc
  375. cmd += ['--cc-cmd', '"%s --cc-cmd %s"' % (sys.argv[0], cc_fname)]
  376. if cover_fname:
  377. cmd.append(cover_fname)
  378. cmd += args
  379. str = ' '.join(cmd)
  380. if not dry_run:
  381. os.system(str)
  382. return str
  383. def LookupEmail(lookup_name, alias=None, raise_on_error=True, level=0):
  384. """If an email address is an alias, look it up and return the full name
  385. TODO: Why not just use git's own alias feature?
  386. Args:
  387. lookup_name: Alias or email address to look up
  388. alias: Dictionary containing aliases (None to use settings default)
  389. raise_on_error: True to raise an error when an alias fails to match,
  390. False to just print a message.
  391. Returns:
  392. tuple:
  393. list containing a list of email addresses
  394. Raises:
  395. OSError if a recursive alias reference was found
  396. ValueError if an alias was not found
  397. >>> alias = {}
  398. >>> alias['fred'] = ['f.bloggs@napier.co.nz']
  399. >>> alias['john'] = ['j.bloggs@napier.co.nz']
  400. >>> alias['mary'] = ['m.poppins@cloud.net']
  401. >>> alias['boys'] = ['fred', ' john', 'f.bloggs@napier.co.nz']
  402. >>> alias['all'] = ['fred ', 'john', ' mary ']
  403. >>> alias['loop'] = ['other', 'john', ' mary ']
  404. >>> alias['other'] = ['loop', 'john', ' mary ']
  405. >>> LookupEmail('mary', alias)
  406. ['m.poppins@cloud.net']
  407. >>> LookupEmail('arthur.wellesley@howe.ro.uk', alias)
  408. ['arthur.wellesley@howe.ro.uk']
  409. >>> LookupEmail('boys', alias)
  410. ['f.bloggs@napier.co.nz', 'j.bloggs@napier.co.nz']
  411. >>> LookupEmail('all', alias)
  412. ['f.bloggs@napier.co.nz', 'j.bloggs@napier.co.nz', 'm.poppins@cloud.net']
  413. >>> LookupEmail('odd', alias)
  414. Traceback (most recent call last):
  415. ...
  416. ValueError: Alias 'odd' not found
  417. >>> LookupEmail('loop', alias)
  418. Traceback (most recent call last):
  419. ...
  420. OSError: Recursive email alias at 'other'
  421. >>> LookupEmail('odd', alias, raise_on_error=False)
  422. Alias 'odd' not found
  423. []
  424. >>> # In this case the loop part will effectively be ignored.
  425. >>> LookupEmail('loop', alias, raise_on_error=False)
  426. Recursive email alias at 'other'
  427. Recursive email alias at 'john'
  428. Recursive email alias at 'mary'
  429. ['j.bloggs@napier.co.nz', 'm.poppins@cloud.net']
  430. """
  431. if not alias:
  432. alias = settings.alias
  433. lookup_name = lookup_name.strip()
  434. if '@' in lookup_name: # Perhaps a real email address
  435. return [lookup_name]
  436. lookup_name = lookup_name.lower()
  437. col = terminal.Color()
  438. out_list = []
  439. if level > 10:
  440. msg = "Recursive email alias at '%s'" % lookup_name
  441. if raise_on_error:
  442. raise OSError, msg
  443. else:
  444. print col.Color(col.RED, msg)
  445. return out_list
  446. if lookup_name:
  447. if not lookup_name in alias:
  448. msg = "Alias '%s' not found" % lookup_name
  449. if raise_on_error:
  450. raise ValueError, msg
  451. else:
  452. print col.Color(col.RED, msg)
  453. return out_list
  454. for item in alias[lookup_name]:
  455. todo = LookupEmail(item, alias, raise_on_error, level + 1)
  456. for new_item in todo:
  457. if not new_item in out_list:
  458. out_list.append(new_item)
  459. #print "No match for alias '%s'" % lookup_name
  460. return out_list
  461. def GetTopLevel():
  462. """Return name of top-level directory for this git repo.
  463. Returns:
  464. Full path to git top-level directory
  465. This test makes sure that we are running tests in the right subdir
  466. >>> os.path.realpath(os.path.dirname(__file__)) == \
  467. os.path.join(GetTopLevel(), 'tools', 'patman')
  468. True
  469. """
  470. return command.OutputOneLine('git', 'rev-parse', '--show-toplevel')
  471. def GetAliasFile():
  472. """Gets the name of the git alias file.
  473. Returns:
  474. Filename of git alias file, or None if none
  475. """
  476. fname = command.OutputOneLine('git', 'config', 'sendemail.aliasesfile',
  477. raise_on_error=False)
  478. if fname:
  479. fname = os.path.join(GetTopLevel(), fname.strip())
  480. return fname
  481. def GetDefaultUserName():
  482. """Gets the user.name from .gitconfig file.
  483. Returns:
  484. User name found in .gitconfig file, or None if none
  485. """
  486. uname = command.OutputOneLine('git', 'config', '--global', 'user.name')
  487. return uname
  488. def GetDefaultUserEmail():
  489. """Gets the user.email from the global .gitconfig file.
  490. Returns:
  491. User's email found in .gitconfig file, or None if none
  492. """
  493. uemail = command.OutputOneLine('git', 'config', '--global', 'user.email')
  494. return uemail
  495. def Setup():
  496. """Set up git utils, by reading the alias files."""
  497. # Check for a git alias file also
  498. alias_fname = GetAliasFile()
  499. if alias_fname:
  500. settings.ReadGitAliases(alias_fname)
  501. cmd = LogCmd(None, count=0)
  502. use_no_decorate = (command.RunPipe([cmd], raise_on_error=False)
  503. .return_code == 0)
  504. def GetHead():
  505. """Get the hash of the current HEAD
  506. Returns:
  507. Hash of HEAD
  508. """
  509. return command.OutputOneLine('git', 'show', '-s', '--pretty=format:%H')
  510. if __name__ == "__main__":
  511. import doctest
  512. doctest.testmod()