Merge branch 'decode-quoted-header' of https://github.com/jpgrayson/stgit
[stgit.git] / stgit / commands / common.py
blob1aae82dc08173a218342c12b80f1d68e7c26ae40
1 # -*- coding: utf-8 -*-
2 """Function/variables common to all the commands"""
3 from __future__ import (absolute_import, division, print_function,
4 unicode_literals)
5 import codecs
6 import email.utils
7 import os
8 import re
9 import sys
11 from stgit import stack, git, templates
12 from stgit.compat import text, decode_utf8_with_latin1
13 from stgit.config import config
14 from stgit.exception import StgException
15 from stgit.lib import git as libgit
16 from stgit.lib import log
17 from stgit.lib import stack as libstack
18 from stgit.out import out
19 from stgit.run import Run, RunException
20 from stgit.utils import (EditorException,
21 add_sign_line,
22 edit_string,
23 get_hook,
24 parse_name_email_date,
25 run_hook_on_string,
26 strip_prefix)
28 __copyright__ = """
29 Copyright (C) 2005, Catalin Marinas <catalin.marinas@gmail.com>
31 This program is free software; you can redistribute it and/or modify
32 it under the terms of the GNU General Public License version 2 as
33 published by the Free Software Foundation.
35 This program is distributed in the hope that it will be useful,
36 but WITHOUT ANY WARRANTY; without even the implied warranty of
37 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
38 GNU General Public License for more details.
40 You should have received a copy of the GNU General Public License
41 along with this program; if not, see http://www.gnu.org/licenses/.
42 """
45 # Command exception class
46 class CmdException(StgException):
47 pass
49 # Utility functions
50 def parse_rev(rev):
51 """Parse a revision specification into its branch:patch parts.
52 """
53 try:
54 branch, patch = rev.split(':', 1)
55 except ValueError:
56 branch = None
57 patch = rev
59 return (branch, patch)
61 def git_id(crt_series, rev):
62 """Return the GIT id
63 """
64 # TODO: remove this function once all the occurrences were converted
65 # to git_commit()
66 repository = libstack.Repository.default()
67 return git_commit(rev, repository, crt_series.get_name()).sha1
69 def get_public_ref(branch_name):
70 """Return the public ref of the branch."""
71 public_ref = config.get('branch.%s.public' % branch_name)
72 if not public_ref:
73 public_ref = 'refs/heads/%s.public' % branch_name
74 return public_ref
76 def git_commit(name, repository, branch_name = None):
77 """Return the a Commit object if 'name' is a patch name or Git commit.
78 The patch names allowed are in the form '<branch>:<patch>' and can
79 be followed by standard symbols used by git rev-parse. If <patch>
80 is '{base}', it represents the bottom of the stack. If <patch> is
81 {public}, it represents the public branch corresponding to the stack as
82 described in the 'publish' command.
83 """
84 # Try a [branch:]patch name first
85 branch, patch = parse_rev(name)
86 if not branch:
87 branch = branch_name or repository.current_branch_name
89 # The stack base
90 if patch.startswith('{base}'):
91 base_id = repository.get_stack(branch).base.sha1
92 return repository.rev_parse(base_id +
93 strip_prefix('{base}', patch))
94 elif patch.startswith('{public}'):
95 public_ref = get_public_ref(branch)
96 return repository.rev_parse(public_ref +
97 strip_prefix('{public}', patch),
98 discard_stderr = True)
100 # Other combination of branch and patch
101 try:
102 return repository.rev_parse('patches/%s/%s' % (branch, patch),
103 discard_stderr = True)
104 except libgit.RepositoryException:
105 pass
107 # Try a Git commit
108 try:
109 return repository.rev_parse(name, discard_stderr = True)
110 except libgit.RepositoryException:
111 raise CmdException('%s: Unknown patch or revision name' % name)
113 def color_diff_flags():
114 """Return the git flags for coloured diff output if the configuration and
115 stdout allows."""
116 stdout_is_tty = (sys.stdout.isatty() and 'true') or 'false'
117 if config.get_colorbool('color.diff', stdout_is_tty) == 'true':
118 return ['--color']
119 else:
120 return []
122 def check_local_changes():
123 if git.local_changes():
124 raise CmdException('local changes in the tree. Use "refresh" or'
125 ' "reset --hard"')
127 def check_head_top_equal(crt_series):
128 if not crt_series.head_top_equal():
129 raise CmdException('HEAD and top are not the same. This can happen'
130 ' if you modify a branch with git. "stg repair'
131 ' --help" explains more about what to do next.')
133 def check_conflicts():
134 if git.get_conflicts():
135 raise CmdException('Unsolved conflicts. Please fix the conflicts'
136 ' then use "git add --update <files>" or revert the'
137 ' changes with "reset --hard".')
139 def print_crt_patch(crt_series, branch = None):
140 if not branch:
141 patch = crt_series.get_current()
142 else:
143 patch = stack.Series(branch).get_current()
145 if patch:
146 out.info('Now at patch "%s"' % patch)
147 else:
148 out.info('No patches applied')
151 def push_patches(crt_series, patches, check_merged = False):
152 """Push multiple patches onto the stack. This function is shared
153 between the push and pull commands
155 forwarded = crt_series.forward_patches(patches)
156 if forwarded > 1:
157 out.info('Fast-forwarded patches "%s" - "%s"'
158 % (patches[0], patches[forwarded - 1]))
159 elif forwarded == 1:
160 out.info('Fast-forwarded patch "%s"' % patches[0])
162 names = patches[forwarded:]
164 # check for patches merged upstream
165 if names and check_merged:
166 out.start('Checking for patches merged upstream')
168 merged = crt_series.merged_patches(names)
170 out.done('%d found' % len(merged))
171 else:
172 merged = []
174 for p in names:
175 out.start('Pushing patch "%s"' % p)
177 if p in merged:
178 crt_series.push_empty_patch(p)
179 out.done('merged upstream')
180 else:
181 modified = crt_series.push_patch(p)
183 if crt_series.empty_patch(p):
184 out.done('empty patch')
185 elif modified:
186 out.done('modified')
187 else:
188 out.done()
190 def pop_patches(crt_series, patches, keep = False):
191 """Pop the patches in the list from the stack. It is assumed that
192 the patches are listed in the stack reverse order.
194 if len(patches) == 0:
195 out.info('Nothing to push/pop')
196 else:
197 p = patches[-1]
198 if len(patches) == 1:
199 out.start('Popping patch "%s"' % p)
200 else:
201 out.start('Popping patches "%s" - "%s"' % (patches[0], p))
202 crt_series.pop_patch(p, keep)
203 out.done()
205 def get_patch_from_list(part_name, patch_list):
206 candidates = [full for full in patch_list if part_name in full]
207 if len(candidates) >= 2:
208 out.info('Possible patches:\n %s' % '\n '.join(candidates))
209 raise CmdException('Ambiguous patch name "%s"' % part_name)
210 elif len(candidates) == 1:
211 return candidates[0]
212 else:
213 return None
215 def parse_patches(patch_args, patch_list, boundary = 0, ordered = False):
216 """Parse patch_args list for patch names in patch_list and return
217 a list. The names can be individual patches and/or in the
218 patch1..patch2 format.
220 # in case it receives a tuple
221 patch_list = list(patch_list)
222 patches = []
224 for name in patch_args:
225 pair = name.split('..')
226 for p in pair:
227 if p and p not in patch_list:
228 raise CmdException('Unknown patch name: %s' % p)
230 if len(pair) == 1:
231 # single patch name
232 pl = pair
233 elif len(pair) == 2:
234 # patch range [p1]..[p2]
235 # inclusive boundary
236 if pair[0]:
237 first = patch_list.index(pair[0])
238 else:
239 first = -1
240 # exclusive boundary
241 if pair[1]:
242 last = patch_list.index(pair[1]) + 1
243 else:
244 last = -1
246 # only cross the boundary if explicitly asked
247 if not boundary:
248 boundary = len(patch_list)
249 if first < 0:
250 if last <= boundary:
251 first = 0
252 else:
253 first = boundary
254 if last < 0:
255 if first < boundary:
256 last = boundary
257 else:
258 last = len(patch_list)
260 if last > first:
261 pl = patch_list[first:last]
262 else:
263 pl = patch_list[(last - 1):(first + 1)]
264 pl.reverse()
265 else:
266 raise CmdException('Malformed patch name: %s' % name)
268 for p in pl:
269 if p in patches:
270 raise CmdException('Duplicate patch name: %s' % p)
272 patches += pl
274 if ordered:
275 patches = [p for p in patch_list if p in patches]
277 return patches
279 def name_email(address):
280 p = email.utils.parseaddr(address)
281 if p[1]:
282 return p
283 else:
284 raise CmdException('Incorrect "name <email>"/"email (name)" string: %s'
285 % address)
287 def name_email_date(address):
288 p = parse_name_email_date(address)
289 if p:
290 return p
291 else:
292 raise CmdException('Incorrect "name <email> date" string: %s' % address)
294 def address_or_alias(addr_pair):
295 """Return a name-email tuple the e-mail address is valid or look up
296 the aliases in the config files.
298 addr = addr_pair[1]
299 if '@' in addr:
300 # it's an e-mail address
301 return addr_pair
302 alias = config.get('mail.alias.' + addr)
303 if alias:
304 # it's an alias
305 return name_email(alias)
306 raise CmdException('unknown e-mail alias: %s' % addr)
308 def prepare_rebase(crt_series):
309 # pop all patches
310 applied = crt_series.get_applied()
311 if len(applied) > 0:
312 out.start('Popping all applied patches')
313 crt_series.pop_patch(applied[0])
314 out.done()
315 return applied
317 def rebase(crt_series, target):
318 try:
319 tree_id = git_id(crt_series, target)
320 except:
321 # it might be that we use a custom rebase command with its own
322 # target type
323 tree_id = target
324 if target:
325 out.start('Rebasing to "%s"' % target)
326 else:
327 out.start('Rebasing to the default target')
328 git.rebase(tree_id = tree_id)
329 out.done()
331 def post_rebase(crt_series, applied, nopush, merged):
332 # memorize that we rebased to here
333 crt_series._set_field('orig-base', git.get_head())
334 # push the patches back
335 if not nopush:
336 push_patches(crt_series, applied, merged)
339 # Patch description/e-mail/diff parsing
341 def __end_descr(line):
342 return re.match(r'---\s*$', line) or re.match('diff -', line) or \
343 re.match('Index: ', line) or re.match('--- \w', line)
345 def __split_descr_diff(string):
346 """Return the description and the diff from the given string
348 descr = diff = ''
349 top = True
351 for line in string.split('\n'):
352 if top:
353 if not __end_descr(line):
354 descr += line + '\n'
355 continue
356 else:
357 top = False
358 diff += line + '\n'
360 return (descr.rstrip(), diff)
362 def __parse_description(descr):
363 """Parse the patch description and return the new description and
364 author information (if any).
366 subject = body = ''
367 authname = authemail = authdate = None
369 descr_lines = [line.rstrip() for line in descr.split('\n')]
370 if not descr_lines:
371 raise CmdException("Empty patch description")
373 lasthdr = 0
374 end = len(descr_lines)
375 descr_strip = 0
377 # Parse the patch header
378 for pos in range(0, end):
379 if not descr_lines[pos]:
380 continue
381 # check for a "From|Author:" line
382 if re.match(r'\s*(?:from|author):\s+', descr_lines[pos], re.I):
383 auth = re.findall(r'^.*?:\s+(.*)$', descr_lines[pos])[0]
384 authname, authemail = name_email(auth)
385 lasthdr = pos + 1
386 continue
387 # check for a "Date:" line
388 if re.match(r'\s*date:\s+', descr_lines[pos], re.I):
389 authdate = re.findall(r'^.*?:\s+(.*)$', descr_lines[pos])[0]
390 lasthdr = pos + 1
391 continue
392 if subject:
393 break
394 # get the subject
395 subject = descr_lines[pos][descr_strip:]
396 if re.match(r'commit [\da-f]{40}$', subject):
397 # 'git show' output, look for the real subject
398 subject = ''
399 descr_strip = 4
400 lasthdr = pos + 1
402 # get the body
403 if lasthdr < end:
404 body = '\n' + '\n'.join(l[descr_strip:] for l in descr_lines[lasthdr:])
406 return (subject + body, authname, authemail, authdate)
408 def parse_mail(msg):
409 """Parse the message object and return (description, authname,
410 authemail, authdate, diff)
412 import email.header
413 if sys.version_info[0] <= 2:
414 # Python 2's decode_header() fails to decode encoded words if they are
415 # quoted. This does not match the behavior of Python3 or `git mailinfo`.
416 # For example, Python2 does not handle this header correctly:
418 # From: "=?UTF-8?q?Christian=20K=C3=B6nig?=" <name@example.com>
420 # By replacing the encoded words regex in the email.header module, we can
421 # bless Python2 with the same behavior as Python3.
422 email.header.ecre = re.compile(
423 (r'=\? (?P<charset>[^?]*?)'
424 r' \? (?P<encoding>[QqBb])'
425 r' \? (?P<encoded>.*?)'
426 r' \?='), re.VERBOSE | re.MULTILINE)
428 def __decode_header(header):
429 """Decode a qp-encoded e-mail header as per rfc2047"""
430 try:
431 decoded_words = email.header.decode_header(header)
432 return text(email.header.make_header(decoded_words))
433 except Exception as ex:
434 raise CmdException('header decoding error: %s' % str(ex))
436 # parse the headers
437 if 'from' in msg:
438 authname, authemail = name_email(__decode_header(msg['from']))
439 else:
440 authname = authemail = None
442 # '\n\t' can be found on multi-line headers
443 descr = __decode_header(msg['subject'])
444 descr = re.sub('\n[ \t]*', ' ', descr)
445 authdate = msg['date']
447 # remove the '[*PATCH*]' expression in the subject
448 if descr:
449 descr = re.findall(r'^(\[.*?[Pp][Aa][Tt][Cc][Hh].*?\])?\s*(.*)$',
450 descr)[0][1]
451 else:
452 raise CmdException('Subject: line not found')
454 # the rest of the message
455 msg_text = ''
456 for part in msg.walk():
457 if part.get_content_type() in ['text/plain',
458 'application/octet-stream']:
459 payload = part.get_payload(decode=True)
460 charset = part.get_content_charset('utf-8')
461 if codecs.lookup(charset).name == 'utf-8':
462 msg_text += decode_utf8_with_latin1(payload)
463 else:
464 msg_text += payload.decode(charset)
466 rem_descr, diff = __split_descr_diff(msg_text)
467 if rem_descr:
468 descr += '\n\n' + rem_descr
470 # parse the description for author information
471 descr, descr_authname, descr_authemail, descr_authdate = \
472 __parse_description(descr)
473 if descr_authname:
474 authname = descr_authname
475 if descr_authemail:
476 authemail = descr_authemail
477 if descr_authdate:
478 authdate = descr_authdate
480 return (descr, authname, authemail, authdate, diff)
482 def parse_patch(text, contains_diff):
483 """Parse the input text and return (description, authname,
484 authemail, authdate, diff)
486 if contains_diff:
487 (text, diff) = __split_descr_diff(text)
488 else:
489 diff = None
490 (descr, authname, authemail, authdate) = __parse_description(text)
492 # we don't yet have an agreed place for the creation date.
493 # Just return None
494 return (descr, authname, authemail, authdate, diff)
496 def readonly_constant_property(f):
497 """Decorator that converts a function that computes a value to an
498 attribute that returns the value. The value is computed only once,
499 the first time it is accessed."""
500 def new_f(self):
501 n = '__' + f.__name__
502 if not hasattr(self, n):
503 setattr(self, n, f(self))
504 return getattr(self, n)
505 return property(new_f)
507 def run_commit_msg_hook(repo, cd, editor_is_used=True):
508 """Run the commit-msg hook (if any) on a commit.
510 @param cd: The L{CommitData<stgit.lib.git.CommitData>} to run the
511 hook on.
513 Return the new L{CommitData<stgit.lib.git.CommitData>}."""
514 env = dict(cd.env)
515 if not editor_is_used:
516 env['GIT_EDITOR'] = ':'
517 commit_msg_hook = get_hook(repo, 'commit-msg', env)
519 try:
520 new_msg = run_hook_on_string(commit_msg_hook, cd.message)
521 except RunException as exc:
522 raise EditorException(str(exc))
524 return cd.set_message(new_msg)
526 def update_commit_data(cd, options):
527 """Return a new CommitData object updated according to the command line
528 options."""
529 # Set the commit message from commandline.
530 if options.message is not None:
531 cd = cd.set_message(options.message)
533 # Modify author data.
534 cd = cd.set_author(options.author(cd.author))
536 # Add Signed-off-by: or similar.
537 if options.sign_str is not None:
538 sign_str = options.sign_str
539 else:
540 sign_str = config.get("stgit.autosign")
541 if sign_str is not None:
542 cd = cd.set_message(
543 add_sign_line(cd.message, sign_str,
544 cd.committer.name, cd.committer.email))
546 # Let user edit the commit message manually, unless
547 # --save-template or --message was specified.
548 if not getattr(options, 'save_template', None) and options.message is None:
549 tmpl = templates.get_template('patchdescr.tmpl')
550 if tmpl:
551 cd = cd.set_message(cd.message + tmpl)
552 cd = cd.set_message(edit_string(cd.message, '.stgit-new.txt'))
554 return cd
556 class DirectoryException(StgException):
557 pass
559 class _Directory(object):
560 def __init__(self, needs_current_series = True, log = True):
561 self.needs_current_series = needs_current_series
562 self.log = log
563 @readonly_constant_property
564 def git_dir(self):
565 try:
566 return Run('git', 'rev-parse', '--git-dir'
567 ).discard_stderr().output_one_line()
568 except RunException:
569 raise DirectoryException('No git repository found')
570 @readonly_constant_property
571 def __topdir_path(self):
572 try:
573 lines = Run('git', 'rev-parse', '--show-cdup'
574 ).discard_stderr().output_lines()
575 if len(lines) == 0:
576 return '.'
577 elif len(lines) == 1:
578 return lines[0]
579 else:
580 raise RunException('Too much output')
581 except RunException:
582 raise DirectoryException('No git repository found')
583 @readonly_constant_property
584 def is_inside_git_dir(self):
585 return { 'true': True, 'false': False
586 }[Run('git', 'rev-parse', '--is-inside-git-dir'
587 ).output_one_line()]
588 @readonly_constant_property
589 def is_inside_worktree(self):
590 return { 'true': True, 'false': False
591 }[Run('git', 'rev-parse', '--is-inside-work-tree'
592 ).output_one_line()]
593 def cd_to_topdir(self):
594 os.chdir(self.__topdir_path)
595 def write_log(self, msg):
596 if self.log:
597 log.compat_log_entry(msg)
599 class DirectoryAnywhere(_Directory):
600 def setup(self):
601 pass
603 class DirectoryHasRepository(_Directory):
604 def setup(self):
605 self.git_dir # might throw an exception
606 log.compat_log_external_mods()
608 class DirectoryInWorktree(DirectoryHasRepository):
609 def setup(self):
610 DirectoryHasRepository.setup(self)
611 if not self.is_inside_worktree:
612 raise DirectoryException('Not inside a git worktree')
614 class DirectoryGotoToplevel(DirectoryInWorktree):
615 def setup(self):
616 DirectoryInWorktree.setup(self)
617 self.cd_to_topdir()
619 class DirectoryHasRepositoryLib(_Directory):
620 """For commands that use the new infrastructure in stgit.lib.*."""
621 def __init__(self):
622 self.needs_current_series = False
623 self.log = False # stgit.lib.transaction handles logging
624 def setup(self):
625 # This will throw an exception if we don't have a repository.
626 self.repository = libstack.Repository.default()