├── .github └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── README.md ├── git-publish ├── git-publish.pod ├── hooks └── pre-publish-send-email.example └── testing ├── 0000-fake_git-sanity-check.sh ├── 0000-gitconfig-home ├── 0001-setup ├── 0002-no-cover-letter ├── 0003-config-ordering ├── 0004-edit-tag ├── 0005-subject-line-wrap ├── 0006-no-ascii-chars ├── 0007-subject-empty ├── README.md ├── fake_git ├── functions.sh ├── run_tests.sh └── test_utils.py /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | test: 6 | name: Ubuntu Tests 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - uses: "actions/setup-python@v2" 11 | with: 12 | python-version: "3.8" 13 | - name: Install git-email 14 | run: sudo add-apt-repository ppa:git-core/ppa && sudo apt-get update && sudo apt-get install -qy git-email 15 | - name: Run the test suite 16 | run: testing/run_tests.sh 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011 IBM, Corp. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | *Tired of manually creating patch series emails?* 2 | 3 | git-publish prepares patches and stores them as git tags for future reference. It works with individual patches as well as patch series. Revision numbering is handled automatically. 4 | 5 | No constraints are placed on git workflow, both vanilla git commands and custom workflow scripts are compatible with git-publish. 6 | 7 | Email sending and pull requests are fully integrated so that publishing patches can be done in a single command. 8 | 9 | Hook scripts are invoked during patch preparation so that custom checks or test runs can be automated. 10 | 11 | ## How to install 12 | 13 | ### Packages 14 | 15 | Packages are available for: 16 | * [Fedora](https://koji.fedoraproject.org/koji/packageinfo?packageID=25588) - `dnf install git-publish` 17 | * [Debian](https://packages.debian.org/buster/git-publish) and [Ubuntu](https://packages.ubuntu.com/bionic/git-publish) - `apt install git-publish` 18 | * [RHEL and CentOS](https://koji.fedoraproject.org/koji/packageinfo?packageID=25588) via [EPEL](https://fedoraproject.org/wiki/EPEL) - `yum install git-publish` 19 | 20 | ### Manual install 21 | 22 | You can also run git-publish from the source tree (useful for development). Assuming `~/bin` is on `$PATH`: 23 | 24 | ``` 25 | $ git clone https://github.com/stefanha/git-publish 26 | $ ln -s $PWD/git-publish/git-publish ~/bin/ 27 | ``` 28 | 29 | ### `git publish` alias 30 | 31 | Run `git-publish --setup` to install the git alias so you can invoke `git publish` instead of `git-publish`. 32 | 33 | ## How it works 34 | 35 | Send the initial patch series email like this: 36 | 37 | ```$ git publish --to patches@example.org --cc maintainer@example.org``` 38 | 39 | You will be prompted for a cover letter on a multi-patch series and you will be presented with a chance to review the emails before they are sent. 40 | 41 | Sending successive revisions is easy, you don't need to repeat all the details since git-publish stores them for you: 42 | 43 | ```$ git publish # to send v2, v3, etc``` 44 | 45 | ## Documentation 46 | 47 | Read the man page [here](https://github.com/stefanha/git-publish/blob/master/git-publish.pod). 48 | 49 | ## Get in touch 50 | 51 | Please submit pull requests on GitHub (https://github.com/stefanha/git-publish) or email patches to Stefan Hajnoczi . 52 | -------------------------------------------------------------------------------- /git-publish: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # git-publish - Prepare and store patch revisions as git tags 4 | # 5 | # Copyright 2011 IBM, Corp. 6 | # Copyright Red Hat 7 | # 8 | # Authors: 9 | # Stefan Hajnoczi 10 | # 11 | # This work is licensed under the MIT License. Please see the LICENSE file or 12 | # http://opensource.org/licenses/MIT. 13 | 14 | import email 15 | import email.policy 16 | import os 17 | import glob 18 | import sys 19 | import optparse 20 | import re 21 | import tempfile 22 | import shutil 23 | import subprocess 24 | import locale 25 | 26 | VERSION = '1.8.2' 27 | 28 | tag_version_re = re.compile(r'^[a-zA-Z0-9_/\-\.]+-v(\d+)$') 29 | git_email_policy = email.policy.default.clone(max_line_length=0, linesep='\n') 30 | 31 | # Encoding for command-line arguments 32 | CMDLINE_ENCODING = locale.getpreferredencoding() 33 | 34 | # Encoding for communicating with the Git executable 35 | GIT_ENCODING = 'utf-8' 36 | 37 | # Encoding for files that GIT_EDITOR can edit 38 | TEXTFILE_ENCODING = CMDLINE_ENCODING 39 | if os.name == 'nt': 40 | TEXTFILE_ENCODING = 'utf-8-sig' # plain utf-8 isn't supported by Notepad.exe 41 | 42 | # As a git alias it is helpful to be a single file script with no external 43 | # dependencies, so these git command-line wrappers are used instead of 44 | # python-git. 45 | 46 | class GitSendEmailError(Exception): 47 | pass 48 | 49 | class GitError(Exception): 50 | pass 51 | 52 | class GitHookError(Exception): 53 | pass 54 | 55 | class InspectEmailsError(Exception): 56 | pass 57 | 58 | def to_text(data): 59 | if isinstance(data, bytes): 60 | return data.decode(CMDLINE_ENCODING) 61 | return data 62 | 63 | def popen_lines(cmd, **kwargs): 64 | '''Communicate with a Popen object and return a list of lines for stdout and stderr''' 65 | stdout, stderr = cmd.communicate(**kwargs) 66 | stdout = re.split('\r\n|\n',stdout.decode(GIT_ENCODING))[:-1] 67 | stderr = re.split('\r\n|\n',stderr.decode(GIT_ENCODING))[:-1] 68 | return stdout, stderr 69 | 70 | def _git_check(*args): 71 | '''Run a git command and return a list of lines, may raise GitError''' 72 | cmdstr = 'git ' + ' '.join(('"%s"' % arg if ' ' in arg else arg) for arg in args) 73 | if VERBOSE: 74 | print(cmdstr) 75 | cmd = subprocess.Popen(['git'] + list(args), 76 | stdout=subprocess.PIPE, 77 | stderr=subprocess.PIPE) 78 | stdout, stderr = popen_lines(cmd) 79 | if cmd.returncode != 0: 80 | raise GitError('ERROR: %s\n%s' % (cmdstr, '\n'.join(stderr))) 81 | return stdout 82 | 83 | def _git(*args): 84 | '''Run a git command and return a list of lines, ignore errors''' 85 | try: 86 | return _git_check(*args) 87 | except GitError: 88 | # ignore git command errors 89 | return [] 90 | 91 | def _git_with_stderr(*args): 92 | '''Run a git command and return a list of lines for stdout and stderr''' 93 | if VERBOSE: 94 | print('git ' + ' '.join(args)) 95 | cmd = subprocess.Popen(['git'] + list(args), 96 | stdout=subprocess.PIPE, 97 | stderr=subprocess.PIPE) 98 | stdout, stderr = popen_lines(cmd) 99 | return stdout, stderr, cmd.returncode 100 | 101 | def bool_from_str(s): 102 | '''Parse a boolean string value like true/false, yes/no, or on/off''' 103 | return s.lower() in ('true', 'yes', 'on') 104 | 105 | def git_get_config(*components): 106 | '''Get a git-config(1) variable''' 107 | lines = _git('config', '.'.join(components)) 108 | if len(lines): 109 | return lines[0] 110 | return None 111 | 112 | def git_get_config_list(*components): 113 | '''Get a git-config(1) list variable''' 114 | return _git('config', '--get-all', '.'.join(components)) 115 | 116 | def git_unset_config(*components): 117 | _git('config', '--unset-all', '.'.join(components)) 118 | 119 | def git_set_config(*components): 120 | '''Set a git-config(1) variable''' 121 | if len(components) < 2: 122 | raise TypeError('git_set_config() takes at least 2 arguments (%d given)' % len(components)) 123 | 124 | val = components[-1] 125 | name = '.'.join(components[:-1]) 126 | 127 | if isinstance(val, (str, bytes)) or not hasattr(val, '__iter__'): 128 | _git('config', name, val) 129 | else: 130 | git_unset_config(name) 131 | for v in val: 132 | _git('config', '--add', name, v) 133 | 134 | def git_get_var(name): 135 | '''Get a git-var(1)''' 136 | lines = _git('var', name) 137 | if len(lines): 138 | return lines[0] 139 | return None 140 | 141 | def git_get_current_branch(): 142 | git_dir = git_get_git_dir() 143 | rebase_dir = os.path.join(git_dir, 'rebase-merge') 144 | if os.path.exists(rebase_dir): 145 | branch_path = os.path.join(rebase_dir, 'head-name') 146 | prefix = 'refs/heads/' 147 | # Path names are encoded in UTF-8 normalization form C. 148 | with open(branch_path, encoding=GIT_ENCODING) as f: 149 | branch = f.read().strip() 150 | if branch.startswith(prefix): 151 | return branch[len(prefix):] 152 | return branch 153 | else: 154 | return _git_check('symbolic-ref', '--short', 'HEAD')[0] 155 | 156 | GIT_TOPLEVEL = None 157 | def git_get_toplevel_dir(): 158 | global GIT_TOPLEVEL 159 | if GIT_TOPLEVEL is None: 160 | GIT_TOPLEVEL = _git_check('rev-parse', '--show-toplevel')[0] 161 | return GIT_TOPLEVEL 162 | 163 | GIT_DIR = None 164 | def git_get_git_dir(): 165 | global GIT_DIR 166 | if GIT_DIR is None: 167 | GIT_DIR = _git('rev-parse', '--git-dir')[0] 168 | return GIT_DIR 169 | 170 | def git_delete_tag(name): 171 | # Hide stderr when tag does not exist 172 | _git_with_stderr('tag', '-d', name) 173 | 174 | def git_get_tags(pattern=None): 175 | if pattern: 176 | return _git('tag', '-l', pattern) 177 | else: 178 | return _git('tag') 179 | 180 | def git_get_tag_message(tag): 181 | r = _git('tag', '-l', '--format=%(contents)', tag) 182 | # --format=%(contents) will print an extra newline if the tag message 183 | # already ends with a newline, so drop the extra line at the end: 184 | if r and r[-1] == '': 185 | r.pop() 186 | return r 187 | 188 | def git_get_remote_url(remote): 189 | '''Return the URL for a given remote''' 190 | return _git_check('ls-remote', '--get-url', remote)[0] 191 | 192 | def git_request_pull(base, remote, signed_tag): 193 | return _git_check('request-pull', base, remote, signed_tag) 194 | 195 | def git_branch_exists(branch): 196 | '''Check if the given branch exists''' 197 | try: 198 | _git_check('rev-parse', '-q', '--verify', branch) 199 | return True 200 | except GitError: 201 | return False 202 | 203 | def git_log(revlist): 204 | return _git('log', '--no-color', '--oneline', revlist) 205 | 206 | def git_tag(name, annotate=None, force=False, sign=False, keyid=None): 207 | args = ['tag', '--annotate'] 208 | if annotate: 209 | args += ['--file', annotate] 210 | else: 211 | args += ['--message', ''] 212 | if force: 213 | args += ['--force'] 214 | if sign: 215 | args += ['--sign'] 216 | if keyid: 217 | args += ['--local-user', keyid] 218 | args += [name] 219 | _git_check(*args) 220 | 221 | def git_format_patch(revlist, subject_prefix=None, output_directory=None, 222 | numbered=False, cover_letter=False, signoff=False, 223 | notes=False, binary=True, headers=[], extra_args=[]): 224 | args = ['format-patch'] 225 | if subject_prefix: 226 | args += ['--subject-prefix', subject_prefix] 227 | if output_directory: 228 | args += ['--output-directory', output_directory] 229 | if numbered: 230 | args += ['--numbered'] 231 | if cover_letter: 232 | args += ['--cover-letter'] 233 | args += ['--cover-from-description=none'] 234 | else: 235 | args += ['--no-cover-letter'] 236 | if signoff: 237 | args += ['--signoff'] 238 | if notes: 239 | args += ['--notes'] 240 | if not binary: 241 | args += ['--no-binary'] 242 | 243 | for header in headers: 244 | args += ['--add-header', header] 245 | 246 | args += [revlist] 247 | args += extra_args 248 | _git_check(*args) 249 | 250 | def git_send_email(to_list, cc_list, patches, suppress_cc, in_reply_to, thread, send_email_args=[], dry_run=False): 251 | args = ['git', 'send-email'] 252 | for address in to_list: 253 | args += ['--to', address] 254 | for address in cc_list: 255 | args += ['--cc', address] 256 | if suppress_cc: 257 | args += ['--suppress-cc', suppress_cc] 258 | if in_reply_to: 259 | args += ['--in-reply-to', in_reply_to] 260 | if thread is not None: 261 | args += ['--thread' if thread else '--no-thread'] 262 | args += send_email_args 263 | if dry_run: 264 | args += ['--dry-run', '--relogin-delay=0', '--batch-size=0'] 265 | else: 266 | args += ['--quiet'] 267 | args += ['--confirm=never'] 268 | args += patches 269 | if dry_run: 270 | return _git_with_stderr(*args[1:])[0] 271 | else: 272 | stdout, stderr, ret_code = _git_with_stderr(*args[1:]) 273 | print('\n'.join(stdout)) 274 | print('\n'.join(stderr)) 275 | if ret_code != 0: 276 | raise GitSendEmailError 277 | 278 | GIT_HOOKDIR = None 279 | def git_get_hook_dir(): 280 | global GIT_HOOKDIR 281 | if GIT_HOOKDIR is None: 282 | common_dir = _git('rev-parse', '--git-common-dir')[0] 283 | if common_dir.startswith("--git-common-dir"): 284 | common_dir = git_get_git_dir() 285 | GIT_HOOKDIR = os.path.join(common_dir, 'hooks') 286 | return GIT_HOOKDIR 287 | 288 | def invoke_hook(name, *args): 289 | '''Run a githooks(5) script''' 290 | hooks_path = git_get_config("core", "hooksPath") or \ 291 | os.path.join(git_get_hook_dir()) 292 | hook_path = os.path.join(hooks_path, name) 293 | if not os.access(hook_path, os.X_OK): 294 | return 295 | if subprocess.call((hook_path,) + args, cwd=git_get_toplevel_dir()) != 0: 296 | raise GitHookError 297 | 298 | def git_push(remote, ref, force=False): 299 | args = ['push'] 300 | if force: 301 | args += ['-f'] 302 | args += [remote, ref] 303 | _git_check(*args) 304 | 305 | def git_config_with_profile(*args): 306 | '''Like git-config(1) except with .gitpublish added to the file lookup chain 307 | 308 | Note that only git-config(1) read operations are supported. Write 309 | operations are not allowed since we should not modify .gitpublish.''' 310 | cmd = subprocess.Popen(['git', 'config', '--includes', '--file', '/dev/stdin'] + list(args), 311 | stdin=subprocess.PIPE, 312 | stdout=subprocess.PIPE, 313 | stderr=subprocess.PIPE) 314 | 315 | # git-config(1) --includes requires absolute paths 316 | gitpublish = os.path.abspath(os.path.join(git_get_toplevel_dir(), '.gitpublish')) 317 | if 'GIT_CONFIG' in os.environ: 318 | gitconfig = os.path.abspath(os.environ['GIT_CONFIG']) 319 | else: 320 | gitconfig = os.path.abspath(os.path.join(git_get_git_dir(), 'config')) 321 | 322 | git_config_file = ''' 323 | [include] 324 | path = ~/.gitconfig 325 | path = %s 326 | path = %s 327 | ''' % (gitpublish, gitconfig) 328 | 329 | stdout, _ = popen_lines(cmd, input=git_config_file.encode(GIT_ENCODING)) 330 | return stdout 331 | 332 | def git_cover_letter_info(base, topic, to, cc, in_reply_to, number): 333 | cl_info = ['Lines starting with \'#\' will be ignored.'] 334 | cl_info += [''] 335 | 336 | cl_info += ['Version number: ' + str(number)] 337 | cl_info += ['Branches:'] 338 | cl_info += [' base: ' + base, ' topic: ' + topic] 339 | cl_info += [''] 340 | 341 | if to: 342 | cl_info += ['To: ' + '\n# '.join(list(to))] 343 | if cc: 344 | cl_info += ['Cc: ' + '\n# '.join(list(cc))] 345 | if in_reply_to: 346 | cl_info += ['In-Reply-To: ' + in_reply_to] 347 | cl_info += [''] 348 | 349 | cl_info += _git('shortlog', base + '..' + topic) 350 | cl_info += _git('diff', '--stat', base + '..' + topic) 351 | 352 | return ["#" + (l if l == '' else ' ' + l) for l in cl_info] 353 | 354 | def check_profile_exists(profile_name): 355 | '''Return True if the profile exists, False otherwise''' 356 | lines = git_config_with_profile('--get-regexp', '^gitpublishprofile\\.%s\\.' % profile_name) 357 | return bool(lines) 358 | 359 | def has_profiles(): 360 | '''Return True if any profile exists, False otherwise''' 361 | lines = git_config_with_profile('--get-regexp', '^gitpublishprofile\\.*\\.') 362 | return bool(lines) 363 | 364 | def get_profile_var(profile_name, var_name): 365 | '''Get a profile variable''' 366 | option = '.'.join(['gitpublishprofile', profile_name, var_name]) 367 | lines = git_config_with_profile(option) 368 | if len(lines): 369 | return lines[0] 370 | return None 371 | 372 | def get_profile_var_list(profile_name, var_name): 373 | '''Get a profile list variable''' 374 | option = '.'.join(['gitpublishprofile', profile_name, var_name]) 375 | return git_config_with_profile('--get-all', option) 376 | 377 | def setup(): 378 | '''Add git alias in ~/.gitconfig''' 379 | path = os.path.abspath(sys.argv[0]) 380 | ret = subprocess.call(['git', 'config', '--global', 381 | 'alias.publish', '!' + path]) 382 | if ret == 0: 383 | print('You can now use \'git publish\' like a built-in git command.') 384 | 385 | def tag_name(topic, number): 386 | '''Build a tag name from a topic name and version number''' 387 | return '%s-v%d' % (topic, number) 388 | 389 | def tag_name_staging(topic): 390 | '''Build a staging tag name from a topic name''' 391 | return '%s-staging' % topic 392 | 393 | def tag_name_pull_request(topic): 394 | '''Build a pull request tag name from a topic name''' 395 | return '%s-pull-request' % topic 396 | 397 | def get_latest_tag_number(branch): 398 | '''Find the latest tag number or 0 if no tags exist''' 399 | number = 0 400 | for tag in git_get_tags('%s-v[0-9]*' % branch): 401 | m = tag_version_re.match(tag) 402 | if not m: 403 | continue 404 | n = int(m.group(1)) 405 | if n > number: 406 | number = n 407 | return number 408 | 409 | def get_latest_tag_message(topic, default_lines): 410 | '''Find the latest tag message or return a template if no tags exist''' 411 | msg = git_get_tag_message(tag_name_staging(topic)) 412 | if msg: 413 | return msg 414 | 415 | number = get_latest_tag_number(topic) 416 | msg = git_get_tag_message(tag_name(topic, number)) 417 | if msg: 418 | return msg 419 | 420 | return default_lines 421 | 422 | def get_pull_request_message(base, remote, topic): 423 | # Add a subject line 424 | message = [topic.replace('_', ' ').replace('-', ' ').capitalize() + ' patches', 425 | ''] 426 | output = git_request_pull(base, remote, tag_name_pull_request(topic)) 427 | 428 | # Chop off diffstat because git-send-email(1) will generate it 429 | first_separator = True 430 | for line in output: 431 | message.append(line) 432 | if line == '----------------------------------------------------------------': 433 | if not first_separator: 434 | break 435 | first_separator = False 436 | 437 | return message 438 | 439 | def get_number_of_commits(base): 440 | return len(git_log('%s..' % base)) 441 | 442 | def edit(*filenames): 443 | cmd = git_get_var('GIT_EDITOR').split(" ") 444 | cmd.extend(filenames) 445 | subprocess.call(cmd) 446 | 447 | def edit_content(content, suffix): 448 | fd, tmpfile = tempfile.mkstemp(suffix=suffix) 449 | try: 450 | with os.fdopen(fd, 'wb') as f: 451 | f.write(content.encode(TEXTFILE_ENCODING)) 452 | edit(tmpfile) 453 | with open(tmpfile, "rb") as f: 454 | new_content = f.read() 455 | return new_content.decode(TEXTFILE_ENCODING) 456 | finally: 457 | os.unlink(tmpfile) 458 | 459 | def tag(name, template, annotate=False, force=False, sign=False, keyid=None): 460 | '''Edit a tag message and create the tag''' 461 | fd, tmpfile = None, None 462 | 463 | try: 464 | if annotate: 465 | new_content = edit_content(os.linesep.join(template + ['']), '.txt') 466 | fd, tmpfile = tempfile.mkstemp() 467 | with os.fdopen(fd, 'wb') as f: 468 | f.write(new_content.encode(GIT_ENCODING)) 469 | 470 | git_tag(name, annotate=tmpfile, force=force, sign=sign, keyid=keyid) 471 | finally: 472 | if tmpfile: 473 | os.unlink(tmpfile) 474 | 475 | def menu_select(menu): 476 | while True: 477 | for k, v in menu: 478 | print("[%s] %s" % (k, v)) 479 | a = sys.stdin.readline().strip() 480 | if a not in [k for (k, v) in menu]: 481 | print("Unknown command, please retry") 482 | continue 483 | return a 484 | 485 | def edit_email_list(cc_list): 486 | new_content = edit_content(os.linesep.join(cc_list), '.txt') 487 | r = [] 488 | for line in new_content.splitlines(): 489 | # Remove blank email item in list by len(x.strip()) 490 | r += [x.strip() for x in line.split(",") if len(x.strip())] 491 | return r 492 | 493 | def git_save_email_lists(topic, to, cc, override_cc): 494 | # Store --to and --cc for next revision 495 | git_set_config('branch', topic, 'gitpublishto', to) 496 | if not override_cc: 497 | git_set_config('branch', topic, 'gitpublishcc', cc) 498 | 499 | def inspect_menu(tmpdir, to_list, cc_list, patches, suppress_cc, in_reply_to, 500 | thread, topic, override_cc, send_email_args=[]): 501 | while True: 502 | print('Stopping so you can inspect the patch emails:') 503 | print(' cd %s' % tmpdir) 504 | print() 505 | output = git_send_email(to_list, cc_list, patches, suppress_cc, in_reply_to, 506 | thread, send_email_args=send_email_args, dry_run=True) 507 | index = 0 508 | for patch in patches: 509 | with open(patch, 'rb') as f: 510 | m = email.message_from_binary_file(f, policy=git_email_policy) 511 | if 'Subject' in m: 512 | print(m['Subject'].strip()) 513 | # Print relevant 'Adding cc' lines from the git-send-email --dry-run output 514 | while index < len(output) and len(output[index]): 515 | line = output[index].replace('\r', '') 516 | if line.find('Adding ') != -1: 517 | print(' ' + line) 518 | index += 1 519 | index += 1 520 | print() 521 | print("To:", "\n ".join(to_list)) 522 | if cc_list: 523 | print("Cc:", "\n ".join(cc_list)) 524 | if in_reply_to: 525 | print("In-Reply-To:", in_reply_to) 526 | print() 527 | a = menu_select([ 528 | ('c', 'Edit Cc list in editor (save after edit)'), 529 | ('t', 'Edit To list in editor (save after edit)'), 530 | ('e', 'Edit patches in editor'), 531 | ('s', 'Select patches to send (default: all)'), 532 | ('p', 'Print final email headers (dry run)'), 533 | ('a', 'Send all'), 534 | ('q', 'Cancel (quit)'), 535 | ]) 536 | if a == 'q': 537 | raise InspectEmailsError 538 | elif a == 'c': 539 | new_cc_list = edit_email_list(cc_list) 540 | cc_list.clear() 541 | cc_list.update(new_cc_list) 542 | git_save_email_lists(topic, to_list, cc_list, override_cc) 543 | elif a == 't': 544 | new_to_list = edit_email_list(to_list) 545 | to_list.clear() 546 | to_list.update(new_to_list) 547 | git_save_email_lists(topic, to_list, cc_list, override_cc) 548 | elif a == 'e': 549 | edit(*patches) 550 | elif a == 's': 551 | new_content = edit_content(os.linesep.join(patches), '.txt') 552 | patches = [x for x in new_content.splitlines() if len(x.strip())] 553 | elif a == 'p': 554 | print('\n'.join(output)) 555 | elif a == 'a': 556 | break 557 | return patches 558 | 559 | def parse_args(): 560 | 561 | parser = optparse.OptionParser(version='%%prog %s' % VERSION, 562 | usage='%prog [options] -- [common format-patch options]', 563 | description='Prepare and store patch revisions as git tags.', 564 | epilog='Please report bugs to Stefan Hajnoczi .') 565 | parser.add_option('--annotate', dest='annotate', action='store_true', 566 | default=False, help='review and edit each patch email') 567 | parser.add_option('-b', '--base', dest='base', default=None, 568 | help='branch which this is based off [defaults to master]') 569 | parser.add_option('--blurb-template', dest='blurb_template', default=None, 570 | help='Template for blurb [defaults to *** BLURB HERE ***]') 571 | parser.add_option('--cc', dest='cc', action='append', default=[], 572 | help='specify a Cc: email recipient') 573 | parser.add_option('--cc-cmd', 574 | help='specify a command whose output to add to the cc list') 575 | parser.add_option('--no-check-url', dest='check_url', action='store_false', 576 | help='skip publicly accessible pull request URL check') 577 | parser.add_option('--check-url', dest='check_url', action='store_true', 578 | help='check pull request URLs are publicly accessible') 579 | parser.add_option('--skip', type='int', dest='skip', metavar='N', default=0, 580 | help='unselect the first N patch emails (including the cover letter if any)') 581 | parser.add_option('--edit', dest='edit', action='store_true', 582 | default=False, help='edit message but do not tag a new version') 583 | parser.add_option('--no-inspect-emails', dest='inspect_emails', 584 | action='store_false', 585 | help='no confirmation before sending emails') 586 | parser.add_option('--inspect-emails', dest='inspect_emails', 587 | action='store_true', default=True, 588 | help='show confirmation before sending emails') 589 | parser.add_option('-n', '--number', type='int', dest='number', default=-1, 590 | help='version number [auto-generated by default]') 591 | parser.add_option('--no-message', '--no-cover-letter', dest='message', 592 | action='store_false', help='do not add a message') 593 | parser.add_option('-m', '--message', '--cover-letter', dest='message', 594 | action='store_true', help='add a message') 595 | parser.add_option('--no-cover-info', dest='cover_info', 596 | action='store_false', default=True, 597 | help='do not append comments information when editing the cover letter') 598 | parser.add_option('--no-binary', dest='binary', 599 | action='store_false', default=True, 600 | help='Do not output contents of changes in binary files, instead display a notice that those files changed') 601 | parser.add_option('--profile', '-p', dest='profile_name', default='default', 602 | help='select default settings profile') 603 | parser.add_option('--pull-request', dest='pull_request', action='store_true', 604 | default=False, help='tag and send as a pull request') 605 | parser.add_option('--sign-pull', dest='sign_pull', action='store_true', 606 | help='sign tag when sending pull request') 607 | parser.add_option('-k', '--keyid', dest='keyid', 608 | help='use the given GPG key when signing pull request tag') 609 | parser.add_option('--no-sign-pull', dest='sign_pull', action='store_false', 610 | help='do not sign tag when sending pull request') 611 | parser.add_option('--subject-prefix', dest='prefix', default=None, 612 | help='set the email Subject: header prefix') 613 | parser.add_option('--clear-subject-prefix', dest='clear_prefix', 614 | action='store_true', default=False, 615 | help='clear the per-branch subject prefix') 616 | parser.add_option('--setup', dest='setup', action='store_true', default=False, 617 | help='add git alias in ~/.gitconfig') 618 | parser.add_option('-t', '--topic', dest='topic', 619 | help='topic name [defaults to current branch name]') 620 | parser.add_option('--to', dest='to', action='append', default=[], 621 | help='specify a primary email recipient') 622 | parser.add_option('-s', '--signoff', dest='signoff', action='store_true', 623 | default=False, 624 | help='add Signed-off-by: to commits when emailing') 625 | parser.add_option('--notes', dest='notes', action='store_true', 626 | default=False, 627 | help='Append the notes (see git-notes(1)) for the commit after the three-dash line.') 628 | parser.add_option('--suppress-cc', dest='suppress_cc', 629 | help='override auto-cc when sending email (man git-send-email for details)') 630 | parser.add_option('-v', '--verbose', dest='verbose', 631 | action='store_true', default=False, 632 | help='show executed git commands (useful for troubleshooting)') 633 | parser.add_option('--forget-cc', dest='forget_cc', action='store_true', 634 | default=False, help='Forget all previous CC emails') 635 | parser.add_option('--override-to', dest='override_to', action='store_true', 636 | default=False, help='Ignore any profile or saved TO emails') 637 | parser.add_option('--override-cc', dest='override_cc', action='store_true', 638 | default=False, help='Ignore any profile or saved CC emails') 639 | parser.add_option('--in-reply-to', "-R", 640 | help='specify the In-Reply-To: of the cover letter (or the single patch)') 641 | parser.add_option('--no-thread', dest='thread', action='store_false', 642 | help='do not add In-Reply-To: headers to any email') 643 | parser.add_option('--thread', dest='thread', action='store_true', 644 | help='add In-Reply-To: headers to sent emails') 645 | parser.add_option('--add-header', '-H', action='append', dest='headers', 646 | help='specify custom headers to git-send-email') 647 | parser.add_option('--separate-send', '-S', dest='separate_send', action='store_true', 648 | default=False, help='Send patches using separate git-send-email cmd') 649 | parser.add_option('--send-email-args', action='append', default=[], 650 | help="Arguments forwarded to git-send-email") 651 | 652 | return parser.parse_args() 653 | 654 | def main(): 655 | global VERBOSE 656 | 657 | options, args = parse_args() 658 | VERBOSE = options.verbose 659 | 660 | # The --edit option is for editing the cover letter without publishing a 661 | # new revision. Therefore it doesn't make sense to combine it with options 662 | # that create new revisions. 663 | if options.edit and any((options.annotate, options.number != -1, 664 | options.setup, options.to, options.pull_request)): 665 | print('The --edit option cannot be used together with other options') 666 | return 1 667 | 668 | # Keep this before any operations that call out to git(1) so that setup 669 | # works when the current working directory is outside a git repo. 670 | if options.setup: 671 | setup() 672 | return 0 673 | 674 | try: 675 | git_get_toplevel_dir() 676 | except GitError: 677 | print('Unable to find git directory, are you sure you are in a git repo?') 678 | return 1 679 | 680 | if not check_profile_exists(options.profile_name): 681 | if options.profile_name == 'default': 682 | if has_profiles(): 683 | print('Using defaults when a non-default profile exists. Forgot to pass --profile ?') 684 | else: 685 | print('Profile "%s" does not exist, please check .gitpublish or git-config(1) files' % options.profile_name) 686 | return 1 687 | 688 | current_branch = git_get_current_branch() 689 | 690 | if options.topic: 691 | topic = options.topic 692 | else: 693 | topic = current_branch 694 | 695 | base = options.base 696 | if not base: 697 | base = git_get_config('branch', current_branch, 'gitpublishbase') 698 | if not base: 699 | base = get_profile_var(options.profile_name, 'base') 700 | if not base: 701 | base = git_get_config('git-publish', 'base') 702 | if not base: 703 | base = 'master' 704 | 705 | if not git_branch_exists(base): 706 | print('Branch "%s" does not exist. Forgot to pass --base ?' % base) 707 | return 1 708 | 709 | if topic == base: 710 | print('Please use a topic branch, cannot version the base branch (%s)' % base) 711 | return 1 712 | 713 | if options.number >= 0: 714 | number = options.number 715 | elif options.pull_request: 716 | number = 1 717 | else: 718 | number = get_latest_tag_number(topic) + 1 719 | 720 | to = set([to_text(_) for _ in options.to]) 721 | if not options.edit and not options.override_to: 722 | to = to.union(git_get_config_list('branch', topic, 'gitpublishto')) 723 | to = to.union(get_profile_var_list(options.profile_name, 'to')) 724 | 725 | if options.forget_cc: 726 | git_set_config('branch', topic, 'gitpublishcc', []) 727 | 728 | cc = set([to_text(_) for _ in options.cc]) 729 | if not options.edit and not options.override_cc: 730 | cc = cc.union(git_get_config_list('branch', topic, 'gitpublishcc')) 731 | cc = cc.union(get_profile_var_list(options.profile_name, 'cc')) 732 | 733 | cc_cmd = options.cc_cmd 734 | if not cc_cmd: 735 | cc_cmd = git_get_config('branch', topic, 'gitpublishcccmd') or \ 736 | get_profile_var(options.profile_name, 'cccmd') 737 | 738 | blurb_template = options.blurb_template 739 | if not blurb_template: 740 | blurb_template = '\n'.join(get_profile_var_list(options.profile_name, 'blurb-template')) 741 | if not blurb_template: 742 | blurb_template = "*** BLURB HERE ***" 743 | 744 | headers = options.headers 745 | if not headers: 746 | headers = [] 747 | 748 | if options.pull_request: 749 | remote = git_get_config('branch', topic, 'pushRemote') 750 | if remote is None: 751 | remote = git_get_config('remote', 'pushDefault') 752 | if remote is None: 753 | remote = git_get_config('branch', topic, 'remote') 754 | if remote is None or remote == '.': 755 | remote = get_profile_var(options.profile_name, 'remote') 756 | if remote is None: 757 | print('''Unable to determine remote repo to push. Please set git config 758 | branch.%s.pushRemote, branch.%s.remote, remote.pushDefault, or 759 | gitpublishprofile.%s.remote''' % (topic, topic, options.profile_name)) 760 | return 1 761 | 762 | check_url = options.check_url 763 | if check_url is None: 764 | check_url_var = get_profile_var(options.profile_name, 'checkUrl') 765 | if check_url_var is None: 766 | check_url_var = git_get_config('git-publish', 'checkUrl') 767 | if check_url_var is not None: 768 | check_url = bool_from_str(check_url_var) 769 | if check_url is None: 770 | check_url = True 771 | 772 | url = git_get_remote_url(remote) 773 | if check_url and not any(url.startswith(scheme) for scheme in ('git://', 'http://', 'https://')): 774 | print('''Possible private URL "%s", normally pull requests reference publicly 775 | accessible git://, http://, or https:// URLs. Are you sure 776 | branch.%s.pushRemote is set appropriately? (Override with --no-check-url)''' % (url, topic)) 777 | return 1 778 | 779 | sign_pull = options.sign_pull 780 | if sign_pull is None: 781 | sign_pull_var = get_profile_var(options.profile_name, 'signPull') 782 | if sign_pull_var is None: 783 | sign_pull_var = git_get_config('git-publish', 'signPull') 784 | if sign_pull_var is not None: 785 | sign_pull = bool_from_str(sign_pull_var) 786 | if sign_pull is None: 787 | sign_pull = True 788 | 789 | profile_message_var = get_profile_var(options.profile_name, 'message') 790 | if options.message is not None: 791 | message = options.message 792 | elif git_get_tag_message(tag_name_staging(topic)): 793 | # If there is a staged tag message, we definitely want a cover letter 794 | message = True 795 | elif profile_message_var is not None: 796 | message = bool_from_str(profile_message_var) 797 | elif options.pull_request: 798 | # Pull requests always get a cover letter by default 799 | message = True 800 | else: 801 | config_cover_letter = git_get_config('format', 'coverLetter') 802 | if config_cover_letter is None or config_cover_letter.lower() == 'auto': 803 | # If there are several commits we probably want a cover letter 804 | message = get_number_of_commits(base) > 1 805 | else: 806 | message = bool_from_str(config_cover_letter) 807 | 808 | keyid = options.keyid 809 | if keyid is None: 810 | keyid_var = get_profile_var(options.profile_name, 'signingkey') 811 | if keyid_var is None: 812 | keyid_var = git_get_config('git-publish', 'signingkey') 813 | 814 | invoke_hook('pre-publish-tag', base) 815 | 816 | cl_info = [''] 817 | if options.cover_info: 818 | cl_info += git_cover_letter_info(base, topic, to, cc, options.in_reply_to, number) 819 | 820 | # Tag the tree 821 | if options.pull_request: 822 | tag_message = get_latest_tag_message(topic, ['Pull request']) 823 | tag_message += cl_info 824 | tag(tag_name_pull_request(topic), tag_message, annotate=message, force=True, sign=sign_pull, keyid=keyid) 825 | git_push(remote, tag_name_pull_request(topic), force=True) 826 | else: 827 | tag_message = get_latest_tag_message(topic, [ 828 | '*** SUBJECT HERE ***', 829 | '', 830 | blurb_template]) 831 | tag_message += cl_info 832 | anno = options.edit or message 833 | tag(tag_name_staging(topic), tag_message, annotate=anno, force=True) 834 | 835 | if options.clear_prefix: 836 | git_unset_config('branch', topic, 'gitpublishprefix') 837 | 838 | prefix = options.prefix 839 | if prefix is not None: 840 | git_set_config('branch', topic, 'gitpublishprefix', prefix) 841 | else: 842 | prefix = git_get_config('branch', topic, 'gitpublishprefix') 843 | if prefix is None: 844 | prefix = get_profile_var(options.profile_name, 'prefix') 845 | if prefix is None: 846 | if options.pull_request: 847 | prefix = 'PULL' 848 | else: 849 | prefix = git_get_config('format', 'subjectprefix') or 'PATCH' 850 | if number > 1: 851 | prefix = '%s v%d' % (prefix, number) 852 | 853 | if to: 854 | if options.pull_request: 855 | message = get_pull_request_message(base, remote, topic) 856 | else: 857 | message = git_get_tag_message(tag_name_staging(topic)) 858 | suppress_cc = options.suppress_cc 859 | if suppress_cc is None: 860 | suppress_cc = get_profile_var(options.profile_name, 'suppresscc') 861 | 862 | if options.signoff: 863 | signoff = True 864 | else: 865 | signoff = get_profile_var(options.profile_name, 'signoff') 866 | 867 | if options.inspect_emails: 868 | inspect_emails = True 869 | else: 870 | inspect_emails = get_profile_var(options.profile_name, 'inspect-emails') 871 | 872 | if options.notes: 873 | notes = True 874 | else: 875 | notes = get_profile_var(options.profile_name, 'notes') 876 | 877 | try: 878 | tmpdir = tempfile.mkdtemp() 879 | numbered = get_number_of_commits(base) > 1 or message 880 | git_format_patch(base + '..', 881 | subject_prefix=prefix, 882 | output_directory=tmpdir, 883 | numbered=numbered, 884 | cover_letter=message, 885 | signoff=signoff, 886 | notes=notes, 887 | binary=options.binary, 888 | headers=headers, 889 | extra_args=args) 890 | if message: 891 | cover_letter_path = os.path.join(tmpdir, '0000-cover-letter.patch') 892 | 893 | # email.policy.HTTP is like SMTP except that max_line_length 894 | # is set to None (unlimited). 895 | # This works better with git-send-email(1), avoiding issues 896 | # with the subject (https://github.com/stefanha/git-publish/issues/96) 897 | with open(cover_letter_path, 'rb') as f: 898 | msg = email.message_from_binary_file(f, policy=git_email_policy) 899 | 900 | subject = msg['Subject'].replace('\n', '') 901 | subject = subject.replace('*** SUBJECT HERE ***', message[0]) 902 | msg.replace_header('Subject', subject) 903 | 904 | blurb = os.linesep.join(message[2:]) 905 | body = msg.get_content().replace('*** BLURB HERE ***', blurb) 906 | 907 | # git-format-patch(1) generates the cover letter with 908 | # UTF-8 charset and Content-Transfer-Encoding=8bit. 909 | # git-send-email(1) expects the same, so let's behave similarly. 910 | msg.set_content(body, charset='utf-8', cte='8bit') 911 | 912 | with open(cover_letter_path, 'wb') as f: 913 | f.write(msg.as_bytes(unixfrom=True, policy=git_email_policy)) 914 | 915 | patches = sorted(glob.glob(os.path.join(tmpdir, '*'))) 916 | del patches[:options.skip] 917 | if options.annotate: 918 | edit(*patches) 919 | if cc_cmd: 920 | for x in patches: 921 | # The encoding of cc-cmd output is not well-defined. Use git's encoding for now 922 | # although git-send-email is a Perl script that uses Perl's Unicode support rather 923 | # than git's standard UTF-8 encoding. 924 | output = subprocess.check_output(cc_cmd + " " + x, 925 | shell=True, cwd=git_get_toplevel_dir()).decode(GIT_ENCODING) 926 | cc = cc.union(output.splitlines()) 927 | cc.difference_update(to) 928 | if inspect_emails: 929 | selected_patches = inspect_menu(tmpdir, to, cc, patches, suppress_cc, 930 | options.in_reply_to, options.thread, 931 | topic, options.override_cc, send_email_args=options.send_email_args) 932 | else: 933 | selected_patches = patches 934 | 935 | invoke_hook('pre-publish-send-email', tmpdir) 936 | 937 | final_patches = sorted(glob.glob(os.path.join(tmpdir, '*'))) 938 | del final_patches[:options.skip] 939 | if final_patches != patches: 940 | added = set(final_patches).difference(set(patches)) 941 | deleted = set(patches).difference(set(final_patches)) 942 | print("The list of files in %s changed and I don't know what to do" % tmpdir) 943 | if added: 944 | print('Added files: %s' % ' '.join(added)) 945 | if deleted: 946 | print('Deleted files: %s' % ' '.join(deleted)) 947 | return 1 948 | 949 | if (options.separate_send): 950 | for patch in selected_patches: 951 | git_send_email(to, cc, [patch], suppress_cc, options.in_reply_to, options.thread, 952 | send_email_args=options.send_email_args) 953 | else: 954 | git_send_email(to, cc, selected_patches, suppress_cc, options.in_reply_to, options.thread, 955 | send_email_args=options.send_email_args) 956 | except (GitSendEmailError, GitHookError, InspectEmailsError): 957 | return 1 958 | except GitError as e: 959 | print(e) 960 | return 1 961 | finally: 962 | if tmpdir: 963 | shutil.rmtree(tmpdir) 964 | 965 | git_save_email_lists(topic, to, cc, options.override_cc) 966 | 967 | if not options.pull_request: 968 | # Publishing is done, stablize the tag now 969 | _git_check('tag', '-f', tag_name(topic, number), tag_name_staging(topic)) 970 | git_delete_tag(tag_name_staging(topic)) 971 | 972 | return 0 973 | 974 | if __name__ == '__main__': 975 | sys.exit(main()) 976 | -------------------------------------------------------------------------------- /git-publish.pod: -------------------------------------------------------------------------------- 1 | =encoding utf8 2 | 3 | =head1 NAME 4 | 5 | git-publish - Prepare and store patch revisions as git tags 6 | 7 | =head1 SYNOPSIS 8 | 9 | git-publish [options] -- [common format-patch options] 10 | 11 | =head1 DESCRIPTION 12 | 13 | git-publish prepares patches and stores them as git tags for future reference. 14 | It works with individual patches as well as patch series. Revision numbering 15 | is handled automatically. 16 | 17 | No constraints are placed on git workflow, both vanilla git commands and custom 18 | workflow scripts are compatible with git-publish. 19 | 20 | Email sending and pull requests are fully integrated so that publishing patches 21 | can be done in a single command. 22 | 23 | Hook scripts are invoked during patch preparation so that custom checks or 24 | test runs can be automated. 25 | 26 | =head1 OPTIONS 27 | 28 | =over 4 29 | 30 | =item B<--version> 31 | 32 | Show program's version number and exit. 33 | 34 | =item B<-h> 35 | 36 | =item B<--help> 37 | 38 | Show help message and exit. 39 | 40 | =item B<--annotate> 41 | 42 | Review and edit each patch email. 43 | 44 | =item B<-b BASE> 45 | 46 | =item B<--base=BASE> 47 | 48 | Branch which this is based off (defaults to master). 49 | 50 | =item B<--cc=CC> 51 | 52 | Specify a Cc: email recipient. 53 | 54 | =item B<--cc-cmd=CC_CMD> 55 | 56 | Specify a command add whose output to add the Cc: email recipient list. See L for details. 57 | 58 | =item B<--no-check-url> 59 | 60 | Do not check whether the pull request URL is publicly accessible. 61 | 62 | =item B<--check-url> 63 | 64 | Check whether the pull request URL is publicly accessible. This is the default. 65 | 66 | =item B<--skip=N> 67 | 68 | Unselect the first N patch emails (including the cover letter if any). If 69 | negative, select only the last -N patches. 70 | 71 | =item B<--edit> 72 | 73 | Edit message but do not tag a new version. Use this to draft the cover letter before actually tagging a new version. 74 | 75 | =item B<--no-inspect-emails> 76 | 77 | Do not prompt for confirmation before sending emails. 78 | 79 | =item B<--inspect-emails> 80 | 81 | Show confirmation before sending emails. 82 | 83 | =item B<-n NUMBER> 84 | 85 | =item B<--number=NUMBER> 86 | 87 | Explicitly specify the version number (auto-generated by default). 88 | 89 | =item B<--no-message> 90 | 91 | =item B<--no-cover-letter> 92 | 93 | Do not add a message. 94 | 95 | =item B<-m> 96 | 97 | =item B<--message> 98 | 99 | =item B<--cover-letter> 100 | 101 | Add a message. 102 | 103 | =item B<--no-binary> 104 | 105 | Do not output contents of changes in binary files, instead display a 106 | notice that those files changed. Patches generated using this option 107 | cannot be applied properly, but they are still useful for code review. 108 | 109 | =item B<-p PROFILE_NAME> 110 | 111 | =item B<--profile=PROFILE_NAME> 112 | 113 | Select default settings from the given profile. 114 | 115 | =item B<--pull-request> 116 | 117 | Tag and send as a pull request. 118 | 119 | =item B<--sign-pull> 120 | 121 | Sign tag when sending pull request. 122 | 123 | =item B<--no-sign-pull> 124 | 125 | Do not sign tag when sending pull request. 126 | 127 | =item B<-k KEYID> 128 | 129 | =item B<--keyid=KEYID> 130 | 131 | Use the given GPG key to sign tag when sending pull request 132 | 133 | =item B<--blurb-template> 134 | 135 | Use a pre-defined blurb message for the series HEAD. 136 | 137 | =item B<--subject-prefix=PREFIX> 138 | 139 | Set the email Subject: header prefix. 140 | 141 | =item B<--clear-subject-prefix> 142 | 143 | Clear the per-branch subject prefix. The subject prefix persists between 144 | versions by default. Use this option to reset it. 145 | 146 | =item B<--setup> 147 | 148 | Add git alias in ~/.gitconfig so that the "git publish" git sub-command works. 149 | 150 | =item B<-t TOPIC> 151 | 152 | =item B<--topic=TOPIC> 153 | 154 | Set the topic name (defaults to current branch name). 155 | 156 | =item B<--to=TO> 157 | 158 | Specify a primary email recipient. 159 | 160 | =item B<-s> 161 | 162 | =item B<--signoff> 163 | 164 | Add Signed-off-by: to commits when emailing. 165 | 166 | =item B<--notes> 167 | 168 | Append the notes for the commit after the three-dash line. See L 169 | for details. 170 | 171 | =item B<--suppress-cc=SUPPRESS_CC> 172 | 173 | Override auto-cc when sending email. See L for details. 174 | 175 | =item B<-v> 176 | 177 | =item B<--verbose> 178 | 179 | Show executed git commands (useful for troubleshooting). 180 | 181 | =item B<--forget-cc> 182 | 183 | Forget all previous Cc: email addresses. 184 | 185 | =item B<--override-to> 186 | 187 | Ignore any profile or saved To: email addresses. 188 | 189 | =item B<--override-cc> 190 | 191 | Ignore any profile or saved Cc: email addresses. 192 | 193 | =item B<-R IN_REPLY_TO> 194 | 195 | =item B<--in-reply-to=IN_REPLY_TO> 196 | 197 | Specify the In-Reply-To: of the first email, or of all emails if --no-thread is 198 | given or is otherwise the default. 199 | 200 | =item B<--no-thread> 201 | 202 | Do not add In-Reply-To: and References: headers to any email. This may be the 203 | default depending on your L configuration. 204 | 205 | =item B<--thread> 206 | 207 | Add In-Reply-To: and References: headers to sent emails. This may be the 208 | default depending on your L configuration, which also 209 | controls whether each email refers to the previous email or to the first email. 210 | 211 | =item B<--send-email-args> 212 | 213 | List of arguments that are forwarded to the git-send-email call. You can add as 214 | many arguments as you need by using consecutive L<--send-email-args> arguments. 215 | 216 | =back 217 | 218 | =head1 DISCUSSION 219 | 220 | =head2 Setup 221 | 222 | Run git-publish in setup mode to configure the git alias: 223 | 224 | $ git-publish --setup 225 | 226 | You can now use 'git publish' like a built-in git command. 227 | 228 | =head2 Quickstart 229 | 230 | Create a "topic branch" on which to do your work (implement a new feature or fix a bug): 231 | 232 | $ git checkout -b add-funny-jokes 233 | ... 234 | $ git commit 235 | ... 236 | $ git commit 237 | 238 | Send a patch series via email: 239 | 240 | $ git publish --to patches@example.org --cc maintainer@example.org 241 | 242 | Address code review comments and send a new revision: 243 | 244 | $ git rebase -i master 245 | ... 246 | $ git publish --to patches@example.org --cc maintainer@example.org 247 | 248 | Refer back to older revisions: 249 | 250 | $ git show add-funny-jokes-v1 251 | 252 | This concludes the basic workflow for sending patch series. 253 | 254 | =head2 Storing patch revisions 255 | 256 | To store the first revision of a patch series: 257 | 258 | $ git checkout my-feature 259 | $ git publish 260 | 261 | This creates the my-feature-v1 git tag. Running git-publish again at a later 262 | point will create tags with incrementing version numbers: 263 | 264 | my-feature-v1 265 | my-feature-v2 266 | my-feature-v3 267 | ... 268 | 269 | To refer back to a previous version, simply check out that git tag. This way a 270 | record is kept of each patch revision that has been published. 271 | 272 | =head3 Overriding the version number 273 | 274 | The version number can be set manually. This is handy when starting out with 275 | git-publish on branches that were previously manually versioned: 276 | 277 | $ git checkout my-existing-feature 278 | $ git publish --number 7 279 | 280 | This creates the my-existing-feature-v7 tag. 281 | 282 | =head3 Overriding the branch name 283 | 284 | By default git-publish refuses to create a revision for the 'master' branch. 285 | Usually one works with so-called topic branches, one branch for each feature 286 | under development. Using the 'master' branch may indicate that one has 287 | forgotten to switch onto the intended topic branch. It is possible to override 288 | the topic name and even publish on 'master': 289 | 290 | $ git checkout branch-a 291 | $ git publish --topic branch-b 292 | 293 | This creates branch-b-v1 instead of branch-a-v1 and can be used to skip the 294 | check for 'master'. 295 | 296 | =head2 Tag messages 297 | 298 | Tag messages have a summary (or subject line) and a description (or blurb). 299 | When send email integration is used the summary is put into the cover letter 300 | Subject: line while the description is put into the body. 301 | 302 | When prompting for tag messages on v2, v3, or other incremental revisions, the 303 | previous revision's tag message is used as the starting point. This is handy 304 | for updating the existing description and keeping a changelog of the difference 305 | between revisions. 306 | 307 | The L format.coverLetter value is honored. The default 'auto' 308 | value adds a cover letter if there is more than 1 patch. The cover letter can 309 | also be forced with 'true' or 'false'. 310 | 311 | To insist on creating a tag message: 312 | 313 | $ git publish --message 314 | 315 | To refrain from creating a tag message: 316 | 317 | $ git publish --no-message 318 | 319 | For convenience these options are also available as --cover-letter and 320 | --no-cover-letter just like in L. 321 | 322 | =head3 Editing tag messages without publishing 323 | 324 | Sometimes it is useful to edit the tag message before publishing. This can be 325 | used to note down changelog entries as you prepare the next version of a patch 326 | series. 327 | 328 | To edit the tag message without publishing: 329 | 330 | $ git publish --edit 331 | 332 | This does not tag a new version. Instead a -staging tag will be created and 333 | the tag message will be picked up when you publish next time. For example, if 334 | you on branch my-feature and have already published v1 and v2, editing the tag 335 | message will create the tag my-feature-staging. When you publish next time the 336 | my-feature-v3 tag will be created and use the tag message you staged earlier. 337 | 338 | =head2 Setting the base branch 339 | 340 | git-publish detects whether the branch contains a single commit or multiple 341 | commits by comparing against a base branch ('master' by default). You can 342 | specify the base branch like this: 343 | 344 | $ git publish --base my-parent 345 | 346 | Most of the time 'master' works fine. 347 | 348 | It is also possible to persist which base branch to use. This is useful if you 349 | find yourself often specifying a base branch manually. It can be done globally 350 | for all branches in a reposity or just for a specific branch: 351 | 352 | $ git config git-publish.base origin/master # for all branches 353 | $ git config branch.foo.gitpublishbase origin/master # for one branch 354 | 355 | =head2 Send email integration 356 | 357 | git-publish can call L after creating a git tag. If there is a 358 | tag message it will be used as the cover letter. Email can be sent like this: 359 | 360 | $ git publish --to patches@example.org \ 361 | --cc alex@example.org --cc bob@example.org 362 | 363 | After the git tag has been created as usual, commits on top of the base branch 364 | are sent as the patch series. The base branch defaults to 'master' and can be 365 | set manually with --base. 366 | 367 | The L aliasesfile feature works since the email addresses are 368 | passed through without interpretation by git-publish. 369 | 370 | Patch emails can be manually edited before being sent, these changes only 371 | affect outgoing emails and are not stored permanently: 372 | 373 | $ git publish --to patches@example.org --annotate 374 | 375 | git-publish can background itself so patch emails can be inspected from the 376 | shell: 377 | 378 | $ git publish --to patches@example.org --inspect-emails 379 | 380 | Signed-off-by: lines can be applied to patch emails, only outgoing 381 | emails are affected and not the local git commits: 382 | 383 | $ git publish --to patches@example.org --signoff 384 | 385 | Sending [RFC] series instead of regular [PATCH] series can be done by 386 | customizing the Subject: line: 387 | 388 | $ git publish --to patches@example.org --subject-prefix RFC 389 | 390 | Using this way, specified "--subject-prefix" will be stored as 391 | per-branch subject prefix, and will be used for the next git-publish 392 | as well. 393 | 394 | One can override the stored per-branch subject prefix by providing the 395 | --subject-prefix parameter again, or to clear it permanently, we can use: 396 | 397 | $ git publish --clear-subject-prefix 398 | 399 | git-publish remembers the list of addresses CC'd on previous revisions 400 | of a patchset by default. To clear that internal list: 401 | 402 | $ git publish --to patches@example.org --forget-cc --cc new@example.org 403 | 404 | In the above example, new@example.org will be saved to the internal list 405 | for next time. 406 | 407 | CC addresses accumulate and cascade. Following the previous example, if we 408 | want to send a new version to both new@example.org and old@example.org: 409 | 410 | $ git-publish --cc old@example.org 411 | 412 | To temporarily ignore any CCs in the profile or saved list, and send only to 413 | the addresses specified on the CLI: 414 | 415 | $ git-publish --override-cc --cc onetime@example.org --to patches@example.org 416 | 417 | CCs specified alongside --override-cc are not remembered for future revisions. 418 | 419 | $ git publish --to patches@example.org --notes 420 | 421 | To include git-notes into a patch. 422 | 423 | One can attach notes to a commit with `git notes add `. For having the 424 | notes "following" a commit on rebase operation, you can use 425 | `git config notes.rewriteRef refs/notes/commits`. For more information, 426 | give a look at L. 427 | 428 | To have L add a cc list to individual patch emails we use: 429 | 430 | $ git-publish --cc patches@example.org --send-email-args="--cc-cmd" --send-email-args="SCRIPT" 431 | 432 | 433 | =head2 Creating profiles for frequently used projects 434 | 435 | Instead of providing command-line options each time a patch series is 436 | published, the options can be stored in L files: 437 | 438 | $ cat >>.git/config 439 | [gitpublishprofile "example"] 440 | prefix = PATCH for-example 441 | to = patches@example.org 442 | cc = maintainer1@example.org 443 | cc = maintainer2@example.org 444 | ^D 445 | $ git checkout first-feature 446 | $ git publish --profile example 447 | $ git checkout second-feature 448 | $ git publish --profile example 449 | 450 | The "example" profile is equivalent to the following command-line: 451 | 452 | $ git publish --subject-prefix 'PATCH for-example' --to patches@example.org --cc maintainer1@example.org --cc maintainer2@example.org 453 | 454 | If command-line options are given together with a profile, then the 455 | command-line options take precedence. 456 | 457 | The following profile options are available: 458 | 459 | [gitpublishprofile "example"] 460 | base = v2.1.0 # same as --base 461 | remote = origin # used if branch..remote not set 462 | prefix = PATCH # same as --patch 463 | to = patches@example.org # same as --to 464 | cc = maintainer@example.org # same as --cc 465 | suppresscc = all # same as --suppress-cc 466 | message = true # same as --message 467 | signoff = true # same as --signoff 468 | inspect-emails = true # same as --inspect-emails 469 | notes = true # same as --notes 470 | blurb-template = A blurb template # same as --blurb-template 471 | 472 | The special "default" profile name is active when no --profile command-line 473 | option was given. The default profile does not set any options but can be 474 | extended in L files: 475 | 476 | $ cat >>.git/config 477 | [gitpublishprofile "default"] 478 | suppresscc = all # do not auto-cc people 479 | 480 | If a file named .gitpublish exists in the repository top-level directory, it is 481 | automatically searched in addition to the L .git/config and 482 | ~/.gitconfig files. Since the .gitpublish file can be committed into git, this 483 | can be used to provide a default profile for branches that you expect to 484 | repeatedly use as a base for new work. 485 | 486 | =head2 Sending pull requests 487 | 488 | git-publish can send signed pull requests. Signed tags are pushed to a remote 489 | git repository that must be readable by the person who will merge the pull 490 | request. 491 | 492 | Ensure that the branch has a default remote repository saved: 493 | 494 | $ git config branch.foo.remote my-public-repo 495 | 496 | The remote must be accessible to the person receiving the pull request. 497 | Normally the remote URI should be git:// or https://. If the remote is 498 | configured for ssh:// then L can be supplemented with a public url 499 | and private pushurl. This ensures that pull requests always use the public 500 | URI: 501 | 502 | [remote ""] 503 | url = https://myhost.com/repo.git 504 | pushurl = me@myhost.com:repo.git 505 | 506 | Send a pull request: 507 | 508 | $ git publish --pull-request --to patches@example.org --annotate 509 | 510 | =head1 CONFIGURATION 511 | 512 | There are three possible levels of configuration with the following order of precedence: 513 | 514 | =over 4 515 | 516 | =item 1. Per-branch options only apply to a specific branch. 517 | 518 | =item 2. Per-profile options apply when the profile is enabled with B<--profile>. 519 | 520 | =item 3. Global options apply in all cases. 521 | 522 | =back 523 | 524 | The following configuration options are available: 525 | 526 | =over 4 527 | 528 | =item B 529 | 530 | =item B 531 | 532 | =item B 533 | 534 | Same as the B<--base> option. 535 | 536 | =item B 537 | 538 | =item B 539 | 540 | Same as the B<--to> option. 541 | 542 | =item B 543 | 544 | =item B 545 | 546 | Same as the B<--cc> option. 547 | 548 | =item B 549 | 550 | =item B 551 | 552 | Same as the B<--cc-cmd> option. 553 | 554 | =item B 555 | 556 | The remote where the pull request tag will be pushed. 557 | 558 | =item B 559 | 560 | Same as the B<--message> option. 561 | 562 | =item B 563 | 564 | =item B 565 | 566 | Same as the B<--subject-prefix> option. 567 | 568 | =item B 569 | 570 | Same as the B<--suppress-cc> option. 571 | 572 | =item B 573 | 574 | Same as the B<--signoff> option. 575 | 576 | =item B 577 | 578 | Same as the B<--inspect-emails> option. 579 | 580 | =item B 581 | 582 | Same as the B<--notes> option. 583 | 584 | =item B 585 | 586 | =item B 587 | 588 | Same as the B<--no-check-url> and B<--check-url> options. 589 | 590 | =item B 591 | 592 | =item B 593 | 594 | Same as the B<--no-sign-pull> and B<--sign-pull> options. 595 | 596 | =item B 597 | 598 | Same as the B<--keyid> option. 599 | 600 | =back 601 | 602 | =head1 HOOKS 603 | 604 | git-publish supports the L mechanism for running user scripts at 605 | important points during the workflow. The script can influence the outcome of 606 | the operation, for example, by rejecting a patch series that is about to be 607 | sent out. 608 | 609 | Available hooks include: 610 | 611 | =over 4 612 | 613 | =item B 614 | 615 | Invoked before L. Takes the path to the patches directory 616 | as an argument. If the exit code is non-zero, the series will not be sent. 617 | 618 | =item B 619 | 620 | Invoked before creating the -staging tag on current branch. Takes one argument 621 | which refers to the base commit or branch. If the exit code is non-zero, 622 | git-publish will abort. 623 | 624 | =back 625 | 626 | =head1 SEE ALSO 627 | 628 | L, L, L, L, L 629 | 630 | =head1 AUTHOR 631 | 632 | Stefan Hajnoczi L 633 | 634 | =head1 COPYRIGHT 635 | 636 | Copyright (C) 2011-2018 Stefan Hajnoczi 637 | -------------------------------------------------------------------------------- /hooks/pre-publish-send-email.example: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Check RHEL downstream patch format 3 | # 4 | # Copyright (c) 2014 Red Hat, Inc. 5 | # 6 | # This work is licensed under the MIT License. Please see the LICENSE file or 7 | # http://opensource.org/licenses/MIT. 8 | 9 | set -e 10 | [ ! -d redhat/ ] && exit 0 11 | 12 | patch_dir=$1 13 | 14 | fail() { 15 | echo "Error: $@" 16 | exit 1 17 | } 18 | 19 | check() { 20 | regexp=$1 21 | errmsg=$2 22 | if ! grep -q "$regexp" $(ls "$patch_dir"/*.patch | head -n1); then 23 | fail "$errmsg" 24 | fi 25 | } 26 | 27 | check '^Subject: \[.*RH.*\]' 'missing RHEL/RHEV/RHV tag in Subject: line' 28 | check '^Subject: \[.*qemu-kvm.*\]' 'missing qemu-kvm/qemu-kvm-rhev tag in Subject: line' 29 | check '^\(Bugzilla\|BZ\): ' 'missing Bugzilla: header in cover letter' 30 | check '^\(Brew\|BREW\): ' 'missing Brew: header in cover letter' 31 | check '^\(Upstream\|UPSTREAM\): ' 'missing Upstream: header in cover letter' 32 | -------------------------------------------------------------------------------- /testing/0000-fake_git-sanity-check.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # fake_git sanity checks 3 | # 4 | # Copyright 2019 Red Hat, Inc. 5 | # 6 | # Authors: 7 | # Eduardo Habkost 8 | # 9 | # This work is licensed under the MIT License. Please see the LICENSE file or 10 | # http://opensource.org/licenses/MIT. 11 | 12 | source "$TESTS_DIR/functions.sh" 13 | 14 | # ensure fake_git is refusing to run git-send-email without --dry-run: 15 | if git send-email --quiet --to somebody@example.com HEAD^..HEAD;then 16 | abort "fake_git send-email without '--dry-run' was supposed to fail" 17 | fi 18 | grep -q 'send-email --quiet --to somebody@example.com' "$FAKE_GIT_COMMAND_LOG" || \ 19 | abort "fake_git didn't log send-email command" 20 | 21 | # --dry-run must succeed, though: 22 | if ! git send-email --dry-run --to somebody@example.com HEAD^..HEAD > /dev/null;then 23 | abort "git send-email --dry-run failed" 24 | fi 25 | 26 | # ensure simple git-publish usage is actually using fake_git: 27 | rm -f "$FAKE_GIT_COMMAND_LOG" 28 | echo q | git-publish -b HEAD^ --to somebody@example.com --inspect-emails || : 29 | [ -s "$FAKE_GIT_COMMAND_LOG" ] || \ 30 | abort "git-publish didn't run fake_git" 31 | -------------------------------------------------------------------------------- /testing/0000-gitconfig-home: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # ensure git-config $HOME override is working as expected 3 | # 4 | # Copyright 2019 Red Hat, Inc. 5 | # 6 | # Authors: 7 | # Eduardo Habkost 8 | # 9 | # This work is licensed under the MIT License. Please see the LICENSE file or 10 | # http://opensource.org/licenses/MIT. 11 | 12 | source "$TESTS_DIR/functions.sh" 13 | 14 | assert [ "$HOME" = "$RESULTS_DIR/home" ] 15 | 16 | cat >> "$HOME/.gitconfig" < 9 | # 10 | # This work is licensed under the MIT License. Please see the LICENSE file or 11 | # http://opensource.org/licenses/MIT. 12 | 13 | from test_utils import * 14 | 15 | # note that 'git config' is not in the passthrough list of fake_git, 16 | # so this is safe to run: 17 | git_publish('--setup') 18 | assert last_git_command() == ['config', '--global', 'alias.publish', 19 | '!' + git_publish_path()] 20 | -------------------------------------------------------------------------------- /testing/0002-no-cover-letter: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # Ensure --no-cover-letter won't generate a cover letter 4 | # 5 | # Copyright 2019 Red Hat, Inc. 6 | # 7 | # Authors: 8 | # Eduardo Habkost 9 | # 10 | # This work is licensed under the MIT License. Please see the LICENSE file or 11 | # http://opensource.org/licenses/MIT. 12 | 13 | from test_utils import * 14 | 15 | git_publish('-b HEAD^ --no-cover-letter --no-inspect-emails ' \ 16 | '--to somebody@example.com') 17 | 18 | last = last_git_command() 19 | assert last[:5] == ['send-email', '--to', 'somebody@example.com', 20 | '--quiet', '--confirm=never'], \ 21 | "Unexpected command run by git-publish" 22 | assert len(last[5:]) == 1, \ 23 | "Only one file should be passed to git-send-email" 24 | -------------------------------------------------------------------------------- /testing/0003-config-ordering: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Ensure configuration file precedence is correct 3 | # 4 | # Copyright 2019 Red Hat, Inc. 5 | # 6 | # Authors: 7 | # Eduardo Habkost 8 | # 9 | # This work is licensed under the MIT License. Please see the LICENSE file or 10 | # http://opensource.org/licenses/MIT. 11 | 12 | source "$TESTS_DIR/functions.sh" 13 | 14 | out="$TEST_DIR/git-publish.stdout" 15 | 16 | git config --global gitpublishprofile.global1.prefix GLOBAL1 17 | git config --global gitpublishprofile.global2.prefix GLOBAL2 18 | git config --global gitpublishprofile.default.prefix GLOBALDEFAULT 19 | 20 | rm -f "$FAKE_GIT_COMMAND_LOG" 21 | git-publish --no-inspect-emails --to somebody@example.com -b HEAD^ \ 22 | 2>> "$TEST_DIR/stderr.log" || : 23 | grep -q -- '--subject-prefix GLOBALDEFAULT' "$FAKE_GIT_COMMAND_LOG" || \ 24 | abort "Global default profile prefix was ignored" 25 | 26 | git config --file .gitpublish gitpublishprofile.project1.prefix PROJECT1 27 | git config --file .gitpublish gitpublishprofile.project2.prefix PROJECT2 28 | git config --file .gitpublish gitpublishprofile.default.prefix PROJECTDEFAULT 29 | 30 | rm -f "$FAKE_GIT_COMMAND_LOG" 31 | git-publish --no-inspect-emails --to somebody@example.com -b HEAD^ \ 32 | 2>> "$TEST_DIR/stderr.log" || : 33 | grep -q -- '--subject-prefix PROJECTDEFAULT' "$FAKE_GIT_COMMAND_LOG" || \ 34 | abort "Project-defined default profile prefix was ignored" 35 | 36 | git config --local gitpublishprofile.local1.prefix LOCAL1 37 | git config --local gitpublishprofile.local2.prefix LOCAL2 38 | git config --local gitpublishprofile.default.prefix LOCALDEFAULT 39 | 40 | rm -f "$FAKE_GIT_COMMAND_LOG" 41 | git-publish --no-inspect-emails --to somebody@example.com -b HEAD^ \ 42 | 2>> "$TEST_DIR/stderr.log" || : 43 | grep -q -- '--subject-prefix LOCALDEFAULT' "$FAKE_GIT_COMMAND_LOG" || \ 44 | abort "Local default profile prefix was ignored" 45 | -------------------------------------------------------------------------------- /testing/0004-edit-tag: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | source "$TESTS_DIR/functions.sh" 3 | 4 | msgfile="$TEST_DIR/message" 5 | 6 | cat >"$msgfile" <"$msgfile" <"$msgfile" <"$msgfile" <"$hookfile" <"$TEST_DIR/expected" 31 | grep '^Subject:' "$coverfile" >"$TEST_DIR/found" 32 | assert diff -u "$TEST_DIR/expected" "$TEST_DIR/found" 33 | -------------------------------------------------------------------------------- /testing/0006-no-ascii-chars: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | source "$TESTS_DIR/functions.sh" 3 | 4 | msgfile="$TEST_DIR/message" 5 | 6 | cat >"$msgfile" <"$hookfile" <" \ 27 | -m "Commit with non-ascii characters (ąǫ)" 28 | 29 | GIT_EDITOR="cp $msgfile" git-publish --no-inspect-emails \ 30 | --to somebody@example.com \ 31 | -b HEAD^ \ 32 | --cover-letter \ 33 | --subject-prefix="PATCH ò" || : 34 | 35 | echo -ne \ 36 | 'Subject: [PATCH =?utf-8?q?=C3=B2_0/1=5D_This_is_the_message_with_non-ascii_characters_=28=C4=85=C7=AB=29?=\n' \ 37 | >"$TEST_DIR/expected" 38 | grep '^Subject:' "$coverfile" >"$TEST_DIR/found" 39 | assert diff -u "$TEST_DIR/expected" "$TEST_DIR/found" 40 | 41 | echo -ne \ 42 | 'This is the description with non-ascii characters (èé).\n' \ 43 | >"$TEST_DIR/expected" 44 | grep '^This is the description' "$coverfile" >"$TEST_DIR/found" 45 | assert diff -u "$TEST_DIR/expected" "$TEST_DIR/found" 46 | 47 | echo -ne \ 48 | 'Author with non-ascii characters (ẽã) (1):\n' \ 49 | ' Commit with non-ascii characters (ąǫ)\n' \ 50 | >"$TEST_DIR/expected" 51 | grep --after-context=1 '^Author with non-ascii characters' "$coverfile" >"$TEST_DIR/found" 52 | assert diff -u "$TEST_DIR/expected" "$TEST_DIR/found" 53 | -------------------------------------------------------------------------------- /testing/0007-subject-empty: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | source "$TESTS_DIR/functions.sh" 3 | 4 | msgfile="$TEST_DIR/message" 5 | 6 | cat >"$msgfile" <"$hookfile" <"$TEST_DIR/expected" 33 | grep '^Subject:' "$coverfile" >"$TEST_DIR/found" 34 | assert diff -u "$TEST_DIR/expected" "$TEST_DIR/found" 35 | -------------------------------------------------------------------------------- /testing/README.md: -------------------------------------------------------------------------------- 1 | This directory holds automated test scripts for git-publish. 2 | 3 | ## Running the test cases 4 | 5 | Just run: 6 | 7 | ``` 8 | $ ./testing/run_tests.sh 9 | ``` 10 | 11 | Optionally, the path to the `git-publish` binary can be provided 12 | as argument: 13 | 14 | ``` 15 | $ ./testing/run_tests.sh /path/to/git-publish 16 | ``` 17 | 18 | ## Adding new test cases 19 | 20 | To add a new test case, just create a new executable file in this 21 | directory whose name starts with a digit. 22 | 23 | ### Useful environment variables 24 | 25 | * `$GIT_PUBLISH`: path to `git-publish` script being tested 26 | * `$TEST_DIR`: path to temporary test log directory. Test cases 27 | are allowed (and encouraged) to create debug and log files 28 | inside this directory. 29 | * `$FAKE_GIT_COMMAND_LOG`: command log generated by `fake_git` 30 | 31 | ### Fake git command 32 | 33 | When running test cases, a fake `git` binary will be in `$PATH`, 34 | which will only accept a subset of non-destructive git commands. 35 | If your test case requires additional git commands to work, see 36 | the `PASSTHROUGH_COMMANDS` and `SPECIAL_COMMANDS` variables in 37 | the `fake_git` script. 38 | 39 | ### Writing new test cases using Bash 40 | 41 | Test cases using bash can include `functions.sh` for useful 42 | utility functions. 43 | 44 | `$PATH` will contain the `git-publish` script being tested, so 45 | you can just run `git-publish` directly. e.g.: 46 | 47 | Example: 48 | 49 | ```bash 50 | source "$TESTS_DIR/functions.sh" 51 | git-publish --some-arguments 52 | grep -q 'do-something' "$FAKE_GIT_COMMAND_LOG" || \ 53 | abort "git-publish didn't run `git do-something`" 54 | ``` 55 | 56 | ### Writing new test cases using Python 57 | 58 | Python test cases can import utility functions from the 59 | `test_utils` module. Example: 60 | 61 | ```python 62 | from test_utils import * 63 | git_publish('--some-arguments') 64 | assert last_git_command() == ['do-something', 'xxx', 'yyy'], \ 65 | "git-publish didn't run `git do-something` as expected" 66 | ``` 67 | -------------------------------------------------------------------------------- /testing/fake_git: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # Copyright 2019 Red Hat, Inc. 4 | # 5 | # Authors: 6 | # Eduardo Habkost 7 | # 8 | # This work is licensed under the MIT License. Please see the LICENSE file or 9 | # http://opensource.org/licenses/MIT. 10 | 11 | """ 12 | fake_git - fake git command that should be used by git-publish 13 | when running test cases 14 | 15 | The following environment variables must be set: 16 | 17 | - $REAL_GIT - path to real git binary 18 | - $FAKE_GIT_LOG - path to human-readable debugging log 19 | - $FAKE_GIT_COMMAND_LOG - path to machine-readable command log 20 | """ 21 | 22 | import sys 23 | import os 24 | import logging 25 | import shlex 26 | 27 | logger = logging.getLogger('fakegit') 28 | dbg = logger.debug 29 | 30 | def escape_command(command): 31 | return ' '.join(shlex.quote(a) for a in command) 32 | 33 | def run_real_git(args): 34 | global REAL_GIT 35 | dbg("Running real git: %r", args) 36 | os.execl(REAL_GIT, 'git', *args) 37 | 38 | def run_send_email(args): 39 | if '--dry-run' not in args: 40 | logger.error("git send-email not run using --dry-run") 41 | sys.exit(1) 42 | run_real_git(args) 43 | 44 | # harmless commands that can be always run: 45 | # NOTE: git-config is only safe because the test runner overrides $HOME, so 46 | # we will never touch the real ~/.gitconfig. See the 0000-gitconfig-home 47 | # test case 48 | PASSTHROUGH_COMMANDS = ['tag', 'rev-parse', 'symbolic-ref', 'format-patch', 49 | 'config', 'checkout', 'var', 'add', 'commit'] 50 | 51 | # special commands that require some validation: 52 | SPECIAL_COMMANDS = { 53 | 'send-email': run_send_email, 54 | } 55 | 56 | if len(sys.argv) < 2: 57 | sys.exit(1) 58 | 59 | REAL_GIT = os.getenv("REAL_GIT") 60 | if not REAL_GIT: 61 | print("$REAL_GIT not set", file=sys.stderr) 62 | sys.exit(1) 63 | 64 | if not os.access(REAL_GIT, os.X_OK): 65 | print("$REAL_GIT not executable", file=sys.stderr) 66 | sys.exit(1) 67 | 68 | # debugging log: 69 | log_file = os.getenv("FAKE_GIT_LOG") 70 | if not log_file: 71 | print("$FAKE_GIT_LOG not set", file=sys.stderr) 72 | sys.exit(1) 73 | logging.basicConfig(filename=log_file, level=logging.DEBUG) 74 | 75 | # warning and errors also go to stderr: 76 | err_handler = logging.StreamHandler() 77 | err_handler.setLevel(logging.WARN) 78 | logging.getLogger('').addHandler(err_handler) 79 | 80 | # command log: 81 | command_log = os.getenv("FAKE_GIT_COMMAND_LOG") 82 | if not command_log: 83 | print("$FAKE_GIT_COMMAND_LOG not set", file=sys.stderr) 84 | sys.exit(1) 85 | 86 | args = sys.argv[1:] 87 | logger.info("Command: %r", args) 88 | with open(command_log, 'a') as f: 89 | f.write('%s\n' % (escape_command(args))) 90 | 91 | if args[0] in PASSTHROUGH_COMMANDS: 92 | run_real_git(args) 93 | elif args[0] in SPECIAL_COMMANDS: 94 | SPECIAL_COMMANDS[args[0]](args) 95 | else: 96 | logger.error("command not allowed: %r", args) 97 | sys.exit(1) 98 | -------------------------------------------------------------------------------- /testing/functions.sh: -------------------------------------------------------------------------------- 1 | # Utility functions for test case shell scripts 2 | # 3 | # Copyright 2019 Red Hat, Inc. 4 | # 5 | # Authors: 6 | # Eduardo Habkost 7 | # 8 | # This work is licensed under the MIT License. Please see the LICENSE file or 9 | # http://opensource.org/licenses/MIT. 10 | 11 | 12 | tail_fake_git_log() 13 | { 14 | if [ -r "$FAKE_GIT_LOG" ];then 15 | echo "---- last 5 lines of fake_git log: ----" >&2 16 | tail -n 5 "$FAKE_GIT_LOG" >&2 17 | echo "---- end of fake_git log ----" >&2 18 | fi 19 | } 20 | 21 | abort() 22 | { 23 | echo "TEST FAILURE: $@" >&2 24 | exit 1 25 | } 26 | 27 | assert() 28 | { 29 | "$@" || abort "Assertion failed: $@" 30 | } 31 | 32 | assert_false() 33 | { 34 | ! "$@" || abort "Assertion failed: ! $@" 35 | } 36 | -------------------------------------------------------------------------------- /testing/run_tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Automated test cases for git-publish 3 | # 4 | # Copyright 2019 Red Hat, Inc. 5 | # 6 | # Authors: 7 | # Eduardo Habkost 8 | # 9 | # This work is licensed under the MIT License. Please see the LICENSE file or 10 | # http://opensource.org/licenses/MIT. 11 | 12 | set -e 13 | 14 | export TESTS_DIR="$(realpath "$(dirname "$0")")" 15 | export RESULTS_DIR="$(mktemp -d)" 16 | 17 | GIT_PUBLISH="$1" 18 | if [ -z "$GIT_PUBLISH" ];then 19 | GIT_PUBLISH="$TESTS_DIR/../git-publish" 20 | fi 21 | export GIT_PUBLISH="$(realpath "$GIT_PUBLISH")" 22 | 23 | source "$TESTS_DIR/functions.sh" 24 | 25 | setup_path() 26 | { 27 | export REAL_GIT="$(which git)" 28 | export PATH="$RESULTS_DIR/bin:$PATH" 29 | export PYTHONPATH="$TESTS_DIR:$PYTHONPATH" 30 | 31 | # Place fake git on $PATH to ensure we will never run real git commands by accident: 32 | mkdir -p "$RESULTS_DIR/bin" 33 | fake_git="$RESULTS_DIR/bin/git" 34 | cp "$TESTS_DIR/fake_git" "$RESULTS_DIR/bin/git" 35 | 36 | # Make sure our fake git command will appear first 37 | assert [ "$(which git)" = "$fake_git" ] 38 | 39 | ln -s "$GIT_PUBLISH" "$RESULTS_DIR/bin/git-publish" 40 | # make sure `git-publish` command will run our copy: 41 | assert [ "$(which git-publish)" = "$RESULTS_DIR/bin/git-publish" ] 42 | } 43 | 44 | # Create fake git repository for testing 45 | create_git_repo() 46 | { 47 | "$REAL_GIT" init -q 48 | cat > A < B <> B 63 | "$REAL_GIT" add B 64 | "$REAL_GIT" commit -q -m 'Second commit' 65 | } 66 | 67 | run_test_case() 68 | { 69 | local test_case="$1" 70 | local test_name="$(basename "$test_case")" 71 | export TEST_DIR="$RESULTS_DIR/$test_name" 72 | 73 | mkdir -p "$TEST_DIR" 74 | export FAKE_GIT_LOG="$TEST_DIR/fake_git.log" 75 | export FAKE_GIT_COMMAND_LOG="$TEST_DIR/fake_git_commands.log" 76 | echo -n "Running test case $test_name: " 77 | if ! "$test_case" > "$TEST_DIR/test_output.log" 2>&1;then 78 | echo FAILED 79 | echo "--- Last 10 lines of test output: ---" 80 | tail -n 10 "$TEST_DIR/test_output.log" 81 | echo "-------------------------------------" 82 | echo "Other log files are available at $TEST_DIR" >&2 83 | exit 1 84 | fi 85 | echo OK 86 | } 87 | 88 | # override $HOME so that git-config will never read/write ~/.gitconfig 89 | mkdir "$RESULTS_DIR/home" 90 | export HOME="$RESULTS_DIR/home" 91 | 92 | SOURCE_DIR="$RESULTS_DIR/source" 93 | 94 | # set fake user name and email to make git happy if system values are not set 95 | cat >> "$RESULTS_DIR/home/.gitconfig" </dev/null 111 | rm -rf "$SOURCE_DIR" 112 | done 113 | 114 | # Note that we'll delete the results dir only if all tests passed, 115 | # so failures can be investigated 116 | rm -rf "$RESULTS_DIR" 117 | -------------------------------------------------------------------------------- /testing/test_utils.py: -------------------------------------------------------------------------------- 1 | # Python utility functions for test cases 2 | # 3 | # Copyright 2019 Red Hat, Inc. 4 | # 5 | # Authors: 6 | # Eduardo Habkost 7 | # 8 | # This work is licensed under the MIT License. Please see the LICENSE file or 9 | # http://opensource.org/licenses/MIT. 10 | 11 | import os 12 | import shlex 13 | import subprocess 14 | from subprocess import DEVNULL, PIPE, STDOUT 15 | 16 | def open_test_log(name='test.log', mode='a'): 17 | """Open test log file. Defaults to append mode""" 18 | return open(os.path.join(os.getenv('TEST_DIR'), name), mode) 19 | 20 | def git_command_log(): 21 | with open(os.getenv('FAKE_GIT_COMMAND_LOG'), 'r') as f: 22 | return [shlex.split(l) for l in f.readlines()] 23 | 24 | def last_git_command(): 25 | return git_command_log()[-1] 26 | 27 | def git_publish_path(): 28 | return os.getenv('GIT_PUBLISH') 29 | 30 | def git_publish(args, **kwargs): 31 | """Helper to run git-publish using subprocess.run()""" 32 | if isinstance(args, str): 33 | args = shlex.split(args) 34 | return subprocess.run([git_publish_path()] + args, **kwargs) 35 | --------------------------------------------------------------------------------