diff options
Diffstat (limited to 'contrib')
-rw-r--r-- | contrib/completion/git-completion.bash | 51 | ||||
-rwxr-xr-x | contrib/examples/git-pull.sh | 381 | ||||
-rw-r--r-- | contrib/hooks/multimail/CHANGES | 53 | ||||
-rw-r--r-- | contrib/hooks/multimail/README | 279 | ||||
-rw-r--r-- | contrib/hooks/multimail/README.Git | 4 | ||||
-rwxr-xr-x | contrib/hooks/multimail/git_multimail.py | 973 | ||||
-rwxr-xr-x | contrib/hooks/multimail/migrate-mailhook-config | 18 | ||||
-rwxr-xr-x | contrib/hooks/multimail/post-receive.example (renamed from contrib/hooks/multimail/post-receive) | 27 | ||||
-rwxr-xr-x | contrib/subtree/git-subtree.sh | 15 | ||||
-rw-r--r-- | contrib/subtree/git-subtree.txt | 2 | ||||
-rwxr-xr-x | contrib/subtree/t/t7900-subtree.sh | 302 |
11 files changed, 1594 insertions, 511 deletions
diff --git a/contrib/completion/git-completion.bash b/contrib/completion/git-completion.bash index eae9dce590..c97c648d7e 100644 --- a/contrib/completion/git-completion.bash +++ b/contrib/completion/git-completion.bash @@ -665,8 +665,8 @@ __git_list_porcelain_commands () checkout-index) : plumbing;; commit-tree) : plumbing;; count-objects) : infrequent;; - credential-cache) : credentials helper;; - credential-store) : credentials helper;; + credential) : credentials;; + credential-*) : credentials helper;; cvsexportcommit) : export;; cvsimport) : import;; cvsserver) : daemon;; @@ -735,35 +735,29 @@ __git_list_porcelain_commands () __git_porcelain_commands= __git_compute_porcelain_commands () { - __git_compute_all_commands test -n "$__git_porcelain_commands" || __git_porcelain_commands=$(__git_list_porcelain_commands) } -__git_pretty_aliases () +# Lists all set config variables starting with the given section prefix, +# with the prefix removed. +__git_get_config_variables () { - local i IFS=$'\n' - for i in $(git --git-dir="$(__gitdir)" config --get-regexp "pretty\..*" 2>/dev/null); do - case "$i" in - pretty.*) - i="${i#pretty.}" - echo "${i/ */}" - ;; - esac + local section="$1" i IFS=$'\n' + for i in $(git --git-dir="$(__gitdir)" config --get-regexp "^$section\..*" 2>/dev/null); do + i="${i#$section.}" + echo "${i/ */}" done } +__git_pretty_aliases () +{ + __git_get_config_variables "pretty" +} + __git_aliases () { - local i IFS=$'\n' - for i in $(git --git-dir="$(__gitdir)" config --get-regexp "alias\..*" 2>/dev/null); do - case "$i" in - alias.*) - i="${i#alias.}" - echo "${i/ */}" - ;; - esac - done + __git_get_config_variables "alias" } # __git_aliased_command requires 1 argument @@ -1114,7 +1108,7 @@ _git_commit () case "$cur" in --cleanup=*) - __gitcomp "default strip verbatim whitespace + __gitcomp "default scissors strip verbatim whitespace " "" "${cur##--cleanup=}" return ;; @@ -2123,6 +2117,7 @@ _git_config () http.noEPSV http.postBuffer http.proxy + http.sslCipherList http.sslCAInfo http.sslCAPath http.sslCert @@ -2260,12 +2255,7 @@ _git_remote () __git_complete_remote_or_refspec ;; update) - local i c='' IFS=$'\n' - for i in $(git --git-dir="$(__gitdir)" config --get-regexp "remotes\..*" 2>/dev/null); do - i="${i#remotes.}" - c="$c ${i/ */}" - done - __gitcomp "$c" + __gitcomp "$(__git_get_config_variables "remotes")" ;; *) ;; @@ -2292,6 +2282,11 @@ _git_reset () _git_revert () { + local dir="$(__gitdir)" + if [ -f "$dir"/REVERT_HEAD ]; then + __gitcomp "--continue --quit --abort" + return + fi case "$cur" in --*) __gitcomp "--edit --mainline --no-edit --no-commit --signoff" diff --git a/contrib/examples/git-pull.sh b/contrib/examples/git-pull.sh new file mode 100755 index 0000000000..a814bf61aa --- /dev/null +++ b/contrib/examples/git-pull.sh @@ -0,0 +1,381 @@ +#!/bin/sh +# +# Copyright (c) 2005 Junio C Hamano +# +# Fetch one or more remote refs and merge it/them into the current HEAD. + +SUBDIRECTORY_OK=Yes +OPTIONS_KEEPDASHDASH= +OPTIONS_STUCKLONG=Yes +OPTIONS_SPEC="\ +git pull [options] [<repository> [<refspec>...]] + +Fetch one or more remote refs and integrate it/them with the current HEAD. +-- +v,verbose be more verbose +q,quiet be more quiet +progress force progress reporting + + Options related to merging +r,rebase?false|true|preserve incorporate changes by rebasing rather than merging +n! do not show a diffstat at the end of the merge +stat show a diffstat at the end of the merge +summary (synonym to --stat) +log?n add (at most <n>) entries from shortlog to merge commit message +squash create a single commit instead of doing a merge +commit perform a commit if the merge succeeds (default) +e,edit edit message before committing +ff allow fast-forward +ff-only! abort if fast-forward is not possible +verify-signatures verify that the named commit has a valid GPG signature +s,strategy=strategy merge strategy to use +X,strategy-option=option option for selected merge strategy +S,gpg-sign?key-id GPG sign commit + + Options related to fetching +all fetch from all remotes +a,append append to .git/FETCH_HEAD instead of overwriting +upload-pack=path path to upload pack on remote end +f,force force overwrite of local branch +t,tags fetch all tags and associated objects +p,prune prune remote-tracking branches no longer on remote +recurse-submodules?on-demand control recursive fetching of submodules +dry-run dry run +k,keep keep downloaded pack +depth=depth deepen history of shallow clone +unshallow convert to a complete repository +update-shallow accept refs that update .git/shallow +refmap=refmap specify fetch refmap +" +test $# -gt 0 && args="$*" +. git-sh-setup +. git-sh-i18n +set_reflog_action "pull${args+ $args}" +require_work_tree_exists +cd_to_toplevel + + +die_conflict () { + git diff-index --cached --name-status -r --ignore-submodules HEAD -- + if [ $(git config --bool --get advice.resolveConflict || echo true) = "true" ]; then + die "$(gettext "Pull is not possible because you have unmerged files. +Please, fix them up in the work tree, and then use 'git add/rm <file>' +as appropriate to mark resolution and make a commit.")" + else + die "$(gettext "Pull is not possible because you have unmerged files.")" + fi +} + +die_merge () { + if [ $(git config --bool --get advice.resolveConflict || echo true) = "true" ]; then + die "$(gettext "You have not concluded your merge (MERGE_HEAD exists). +Please, commit your changes before you can merge.")" + else + die "$(gettext "You have not concluded your merge (MERGE_HEAD exists).")" + fi +} + +test -z "$(git ls-files -u)" || die_conflict +test -f "$GIT_DIR/MERGE_HEAD" && die_merge + +bool_or_string_config () { + git config --bool "$1" 2>/dev/null || git config "$1" +} + +strategy_args= diffstat= no_commit= squash= no_ff= ff_only= +log_arg= verbosity= progress= recurse_submodules= verify_signatures= +merge_args= edit= rebase_args= all= append= upload_pack= force= tags= prune= +keep= depth= unshallow= update_shallow= refmap= +curr_branch=$(git symbolic-ref -q HEAD) +curr_branch_short="${curr_branch#refs/heads/}" +rebase=$(bool_or_string_config branch.$curr_branch_short.rebase) +if test -z "$rebase" +then + rebase=$(bool_or_string_config pull.rebase) +fi + +# Setup default fast-forward options via `pull.ff` +pull_ff=$(bool_or_string_config pull.ff) +case "$pull_ff" in +true) + no_ff=--ff + ;; +false) + no_ff=--no-ff + ;; +only) + ff_only=--ff-only + ;; +esac + + +dry_run= +while : +do + case "$1" in + -q|--quiet) + verbosity="$verbosity -q" ;; + -v|--verbose) + verbosity="$verbosity -v" ;; + --progress) + progress=--progress ;; + --no-progress) + progress=--no-progress ;; + -n|--no-stat|--no-summary) + diffstat=--no-stat ;; + --stat|--summary) + diffstat=--stat ;; + --log|--log=*|--no-log) + log_arg="$1" ;; + --no-commit) + no_commit=--no-commit ;; + --commit) + no_commit=--commit ;; + -e|--edit) + edit=--edit ;; + --no-edit) + edit=--no-edit ;; + --squash) + squash=--squash ;; + --no-squash) + squash=--no-squash ;; + --ff) + no_ff=--ff ;; + --no-ff) + no_ff=--no-ff ;; + --ff-only) + ff_only=--ff-only ;; + -s*|--strategy=*) + strategy_args="$strategy_args $1" + ;; + -X*|--strategy-option=*) + merge_args="$merge_args $(git rev-parse --sq-quote "$1")" + ;; + -r*|--rebase=*) + rebase="${1#*=}" + ;; + --rebase) + rebase=true + ;; + --no-rebase) + rebase=false + ;; + --recurse-submodules) + recurse_submodules=--recurse-submodules + ;; + --recurse-submodules=*) + recurse_submodules="$1" + ;; + --no-recurse-submodules) + recurse_submodules=--no-recurse-submodules + ;; + --verify-signatures) + verify_signatures=--verify-signatures + ;; + --no-verify-signatures) + verify_signatures=--no-verify-signatures + ;; + --gpg-sign|-S) + gpg_sign_args=-S + ;; + --gpg-sign=*) + gpg_sign_args=$(git rev-parse --sq-quote "-S${1#--gpg-sign=}") + ;; + -S*) + gpg_sign_args=$(git rev-parse --sq-quote "$1") + ;; + --dry-run) + dry_run=--dry-run + ;; + --all|--no-all) + all=$1 ;; + -a|--append|--no-append) + append=$1 ;; + --upload-pack=*|--no-upload-pack) + upload_pack=$1 ;; + -f|--force|--no-force) + force="$force $1" ;; + -t|--tags|--no-tags) + tags=$1 ;; + -p|--prune|--no-prune) + prune=$1 ;; + -k|--keep|--no-keep) + keep=$1 ;; + --depth=*|--no-depth) + depth=$1 ;; + --unshallow|--no-unshallow) + unshallow=$1 ;; + --update-shallow|--no-update-shallow) + update_shallow=$1 ;; + --refmap=*|--no-refmap) + refmap=$1 ;; + -h|--help-all) + usage + ;; + --) + shift + break + ;; + *) + usage + ;; + esac + shift +done + +case "$rebase" in +preserve) + rebase=true + rebase_args=--preserve-merges + ;; +true|false|'') + ;; +*) + echo "Invalid value for --rebase, should be true, false, or preserve" + usage + exit 1 + ;; +esac + +error_on_no_merge_candidates () { + exec >&2 + + if test true = "$rebase" + then + op_type=rebase + op_prep=against + else + op_type=merge + op_prep=with + fi + + upstream=$(git config "branch.$curr_branch_short.merge") + remote=$(git config "branch.$curr_branch_short.remote") + + if [ $# -gt 1 ]; then + if [ "$rebase" = true ]; then + printf "There is no candidate for rebasing against " + else + printf "There are no candidates for merging " + fi + echo "among the refs that you just fetched." + echo "Generally this means that you provided a wildcard refspec which had no" + echo "matches on the remote end." + elif [ $# -gt 0 ] && [ "$1" != "$remote" ]; then + echo "You asked to pull from the remote '$1', but did not specify" + echo "a branch. Because this is not the default configured remote" + echo "for your current branch, you must specify a branch on the command line." + elif [ -z "$curr_branch" -o -z "$upstream" ]; then + . git-parse-remote + error_on_missing_default_upstream "pull" $op_type $op_prep \ + "git pull <remote> <branch>" + else + echo "Your configuration specifies to $op_type $op_prep the ref '${upstream#refs/heads/}'" + echo "from the remote, but no such ref was fetched." + fi + exit 1 +} + +test true = "$rebase" && { + if ! git rev-parse -q --verify HEAD >/dev/null + then + # On an unborn branch + if test -f "$(git rev-parse --git-path index)" + then + die "$(gettext "updating an unborn branch with changes added to the index")" + fi + else + require_clean_work_tree "pull with rebase" "Please commit or stash them." + fi + oldremoteref= && + test -n "$curr_branch" && + . git-parse-remote && + remoteref="$(get_remote_merge_branch "$@" 2>/dev/null)" && + oldremoteref=$(git merge-base --fork-point "$remoteref" $curr_branch 2>/dev/null) +} +orig_head=$(git rev-parse -q --verify HEAD) +git fetch $verbosity $progress $dry_run $recurse_submodules $all $append \ +$upload_pack $force $tags $prune $keep $depth $unshallow $update_shallow \ +$refmap --update-head-ok "$@" || exit 1 +test -z "$dry_run" || exit 0 + +curr_head=$(git rev-parse -q --verify HEAD) +if test -n "$orig_head" && test "$curr_head" != "$orig_head" +then + # The fetch involved updating the current branch. + + # The working tree and the index file is still based on the + # $orig_head commit, but we are merging into $curr_head. + # First update the working tree to match $curr_head. + + eval_gettextln "Warning: fetch updated the current branch head. +Warning: fast-forwarding your working tree from +Warning: commit \$orig_head." >&2 + git update-index -q --refresh + git read-tree -u -m "$orig_head" "$curr_head" || + die "$(eval_gettext "Cannot fast-forward your working tree. +After making sure that you saved anything precious from +$ git diff \$orig_head +output, run +$ git reset --hard +to recover.")" + +fi + +merge_head=$(sed -e '/ not-for-merge /d' \ + -e 's/ .*//' "$GIT_DIR"/FETCH_HEAD | \ + tr '\012' ' ') + +case "$merge_head" in +'') + error_on_no_merge_candidates "$@" + ;; +?*' '?*) + if test -z "$orig_head" + then + die "$(gettext "Cannot merge multiple branches into empty head")" + fi + if test true = "$rebase" + then + die "$(gettext "Cannot rebase onto multiple branches")" + fi + ;; +esac + +# Pulling into unborn branch: a shorthand for branching off +# FETCH_HEAD, for lazy typers. +if test -z "$orig_head" +then + # Two-way merge: we claim the index is based on an empty tree, + # and try to fast-forward to HEAD. This ensures we will not + # lose index/worktree changes that the user already made on + # the unborn branch. + empty_tree=4b825dc642cb6eb9a060e54bf8d69288fbee4904 + git read-tree -m -u $empty_tree $merge_head && + git update-ref -m "initial pull" HEAD $merge_head "$curr_head" + exit +fi + +if test true = "$rebase" +then + o=$(git show-branch --merge-base $curr_branch $merge_head $oldremoteref) + if test "$oldremoteref" = "$o" + then + unset oldremoteref + fi +fi + +case "$rebase" in +true) + eval="git-rebase $diffstat $strategy_args $merge_args $rebase_args $verbosity" + eval="$eval $gpg_sign_args" + eval="$eval --onto $merge_head ${oldremoteref:-$merge_head}" + ;; +*) + eval="git-merge $diffstat $no_commit $verify_signatures $edit $squash $no_ff $ff_only" + eval="$eval $log_arg $strategy_args $merge_args $verbosity $progress" + eval="$eval $gpg_sign_args" + eval="$eval FETCH_HEAD" + ;; +esac +eval "exec $eval" diff --git a/contrib/hooks/multimail/CHANGES b/contrib/hooks/multimail/CHANGES index 3603d56c26..6bb12306b8 100644 --- a/contrib/hooks/multimail/CHANGES +++ b/contrib/hooks/multimail/CHANGES @@ -1,3 +1,56 @@ +Release 1.1.1 (bugfix-only release) +=================================== + +* The SMTP mailer was not working with Python 2.4. + +Release 1.1.0 +============= + +* When a single commit is pushed, omit the reference changed email. + Set multimailhook.combineWhenSingleCommit to false to disable this + new feature. + +* In gitolite environments, the pusher's email address can be used as + the From address by creating a specially formatted comment block in + gitolite.conf (see multimailhook.from in README). + +* Support for SMTP authentication and SSL/TLS encryption was added, + see smtpUser, smtpPass, smtpEncryption in README. + +* A new option scanCommitForCc was added to allow git-multimail to + search the commit message for 'Cc: ...' lines, and add the + corresponding emails in Cc. + +* If $USER is not set, use the variable $USERNAME. This is needed on + Windows platform to recognize the pusher. + +* The emailPrefix variable can now be set to an empty string to remove + the prefix. + +* A short tutorial was added in doc/gitolite.rst to set up + git-multimail with gitolite. + +* The post-receive file was renamed to post-receive.example. It has + always been an example (the standard way to call git-multimail is to + call git_multimail.py), but it was unclear to many users. + +* A new refchangeShowGraph option was added to make it possible to + include both a graph and a log in the summary emails. The options + to control the graph formatting can be set via the new graphOpts + option. + +* New option --force-send was added to disable new commit detection + for update hook. One use-case is to run git_multimail.py after + running "git fetch" to send emails about commits that have just been + fetched (the detection of new commits was unreliable in this mode). + +* The testing infrastructure was considerably improved (continuous + integration with travis-ci, automatic check of PEP8 and RST syntax, + many improvements to the test scripts). + +This version has been tested with Python 2.4 to 2.7, and Git 1.7.1 to +2.4. + Release 1.0.0 ============= diff --git a/contrib/hooks/multimail/README b/contrib/hooks/multimail/README index 6efa4ffe17..e552c90c45 100644 --- a/contrib/hooks/multimail/README +++ b/contrib/hooks/multimail/README @@ -1,5 +1,8 @@ - git-multimail - ============= +git-multimail Version 1.1.1 +=========================== + +.. image:: https://travis-ci.org/git-multimail/git-multimail.svg?branch=master + :target: https://travis-ci.org/git-multimail/git-multimail git-multimail is a tool for sending notification emails on pushes to a Git repository. It includes a Python module called git_multimail.py, @@ -38,17 +41,17 @@ By default, for each push received by the repository, git-multimail: list) makes it easy to scan through the emails, jump to patches that need further attention, and write comments about specific commits. Commits are handled in reverse topological order (i.e., - parents shown before children). For example, - - [git] branch master updated - + [git] 01/08: doc: fix xref link from api docs to manual pages - + [git] 02/08: api-credentials.txt: show the big picture first - + [git] 03/08: api-credentials.txt: mention credential.helper explicitly - + [git] 04/08: api-credentials.txt: add "see also" section - + [git] 05/08: t3510 (cherry-pick-sequence): add missing '&&' - + [git] 06/08: Merge branch 'rr/maint-t3510-cascade-fix' - + [git] 07/08: Merge branch 'mm/api-credentials-doc' - + [git] 08/08: Git 1.7.11-rc2 + parents shown before children). For example:: + + [git] branch master updated + + [git] 01/08: doc: fix xref link from api docs to manual pages + + [git] 02/08: api-credentials.txt: show the big picture first + + [git] 03/08: api-credentials.txt: mention credential.helper explicitly + + [git] 04/08: api-credentials.txt: add "see also" section + + [git] 05/08: t3510 (cherry-pick-sequence): add missing '&&' + + [git] 06/08: Merge branch 'rr/maint-t3510-cascade-fix' + + [git] 07/08: Merge branch 'mm/api-credentials-doc' + + [git] 08/08: Git 1.7.11-rc2 Each commit appears in exactly one commit email, the first time that it is pushed to the repository. If a commit is later merged @@ -74,19 +77,19 @@ Requirements 3.x. The example scripts invoke Python using the following shebang line - (following PEP 394 [1]): + (following PEP 394 [1]_):: #! /usr/bin/env python2 If your system's Python2 interpreter is not in your PATH or is not - called "python2", you can change the lines accordingly. Or you can + called ``python2``, you can change the lines accordingly. Or you can invoke the Python interpreter explicitly, for example via a tiny - shell script like + shell script like:: #! /bin/sh /usr/local/bin/python /path/to/git_multimail.py "$@" -* The "git" command must be in your PATH. git-multimail is known to +* The ``git`` command must be in your PATH. git-multimail is known to work with Git versions back to 1.7.1. (Earlier versions have not been tested; if you do so, please report your results.) @@ -101,7 +104,7 @@ Requirements Invocation ---------- -git_multimail.py is designed to be used as a "post-receive" hook in a +git_multimail.py is designed to be used as a ``post-receive`` hook in a Git repository (see githooks(5)). Link or copy it to $GIT_DIR/hooks/post-receive within the repository for which email notifications are desired. Usually it should be installed on the @@ -109,10 +112,10 @@ central repository for a project, to which all commits are eventually pushed. For use on pre-v1.5.1 Git servers, git_multimail.py can also work as -an "update" hook, taking its arguments on the command line. To use +an ``update`` hook, taking its arguments on the command line. To use this script in this manner, link or copy it to $GIT_DIR/hooks/update. Please note that the script is not completely reliable in this mode -[2]. +[2]_. Alternatively, git_multimail.py can be imported as a Python module into your own Python post-receive script. This method is a bit more @@ -129,7 +132,7 @@ arbitrary Python code. For example, you can use a custom environment only about changes affecting particular files or subdirectories) Or you can change how emails are sent by writing your own Mailer -class. The "post-receive" script in this directory demonstrates how +class. The ``post-receive`` script in this directory demonstrates how to use git_multimail.py as a Python module. (If you make interesting changes of this type, please consider sharing them with the community.) @@ -139,18 +142,26 @@ Configuration ------------- By default, git-multimail mostly takes its configuration from the -following "git config" settings: +following ``git config`` settings: multimailhook.environment This describes the general environment of the repository. Currently supported values: - "generic" -- the username of the pusher is read from $USER and the - repository name is derived from the repository's path. + * generic + + the username of the pusher is read from $USER or $USERNAME and + the repository name is derived from the repository's path. + + * gitolite - "gitolite" -- the username of the pusher is read from $GL_USER and - the repository name from $GL_REPO. + the username of the pusher is read from $GL_USER, the repository + name is read from $GL_REPO, and the From: header value is + optionally read from gitolite.conf (see multimailhook.from). + + For more information about gitolite and git-multimail, read + doc/gitolite.rst If neither of these environments is suitable for your setup, then you can implement a Python class that inherits from Environment @@ -160,8 +171,8 @@ multimailhook.environment The environment value can be specified on the command line using the --environment option. If it is not specified on the command line or by multimailhook.environment, then it defaults to - "gitolite" if the environment contains variables $GL_USER and - $GL_REPO; otherwise "generic". + ``gitolite`` if the environment contains variables $GL_USER and + $GL_REPO; otherwise ``generic``. multimailhook.repoName @@ -219,61 +230,109 @@ multimailhook.announceShortlog not so straightforward, then the shortlog might be confusing rather than useful. Default is false. +multimailhook.refchangeShowGraph + + If this option is set to true, then summary emails about reference + changes will additionally include: + + * a graph of the added commits (if any) + + * a graph of the discarded commits (if any) + + The log is generated by running ``git log --graph`` with the options + specified in graphOpts. The default is false. + multimailhook.refchangeShowLog If this option is set to true, then summary emails about reference changes will include a detailed log of the added commits in addition to the one line summary. The log is generated by running - "git log" with the options specified in multimailhook.logOpts. + ``git log`` with the options specified in multimailhook.logOpts. Default is false. multimailhook.mailer This option changes the way emails are sent. Accepted values are: - - sendmail (the default): use the command /usr/sbin/sendmail or - /usr/lib/sendmail (or sendmailCommand, if configured). This + - sendmail (the default): use the command ``/usr/sbin/sendmail`` or + ``/usr/lib/sendmail`` (or sendmailCommand, if configured). This mode can be further customized via the following options: - multimailhook.sendmailCommand + * multimailhook.sendmailCommand - The command used by mailer "sendmail" to send emails. Shell - quoting is allowed in the value of this setting, but remember that - Git requires double-quotes to be escaped; e.g., + The command used by mailer ``sendmail`` to send emails. Shell + quoting is allowed in the value of this setting, but remember that + Git requires double-quotes to be escaped; e.g.:: git config multimailhook.sendmailcommand '/usr/sbin/sendmail -oi -t -F \"Git Repo\"' - Default is '/usr/sbin/sendmail -oi -t' or - '/usr/lib/sendmail -oi -t' (depending on which file is - present and executable). + Default is '/usr/sbin/sendmail -oi -t' or + '/usr/lib/sendmail -oi -t' (depending on which file is + present and executable). - multimailhook.envelopeSender + * multimailhook.envelopeSender - If set then pass this value to sendmail via the -f option to set - the envelope sender address. + If set then pass this value to sendmail via the -f option to set + the envelope sender address. - smtp: use Python's smtplib. This is useful when the sendmail command is not available on the system. This mode can be further customized via the following options: - multimailhook.smtpServer + * multimailhook.smtpServer + + The name of the SMTP server to connect to. The value can + also include a colon and a port number; e.g., + ``mail.example.com:25``. Default is 'localhost' using port 25. + + * multimailhook.smtpUser + * multimailhook.smtpPass + + Server username and password. Required if smtpEncryption is 'ssl'. + Note that the username and password currently need to be + set cleartext in the configuration file, which is not + recommended. If you need to use this option, be sure your + configuration file is read-only. + + * multimailhook.envelopeSender + + The sender address to be passed to the SMTP server. If + unset, then the value of multimailhook.from is used. + + * multimailhook.smtpServerTimeout + + Timeout in seconds. - The name of the SMTP server to connect to. The value can - also include a colon and a port number; e.g., - "mail.example.com:25". Default is 'localhost' using port - 25. + * multimailhook.smtpEncryption - multimailhook.envelopeSender + Set the security type. Allowed values: none, ssl. + Default=none. - The sender address to be passed to the SMTP server. If - unset, then the value of multimailhook.from is used. + * multimailhook.smtpServerDebugLevel + + Integer number. Set to greater than 0 to activate debugging. multimailhook.from - If set then use this value in the From: field of generated emails. - If unset, then use the repository's user configuration (user.name - and user.email). If user.email is also unset, then use - multimailhook.envelopeSender. + If set, use this value in the From: field of generated emails. If + unset, the value of the From: header is determined as follows: + + 1. (gitolite environment only) Parse gitolite.conf, looking for a + block of comments that looks like this:: + + # BEGIN USER EMAILS + # username Firstname Lastname <email@example.com> + # END USER EMAILS + + If that block exists, and there is a line between the BEGIN + USER EMAILS and END USER EMAILS lines where the first field + matches the gitolite username ($GL_USER), use the rest of the + line for the From: header. + + 2. If the user.email configuration setting is set, use its value + (and the value of user.name, if set). + + 3. Use the value of multimailhook.envelopeSender. multimailhook.administrator @@ -287,7 +346,8 @@ multimailhook.emailPrefix All emails have this string prepended to their subjects, to aid email filtering (though filtering based on the X-Git-* email headers is probably more robust). Default is the short name of - the repository in square brackets; e.g., "[myrepo]". + the repository in square brackets; e.g., ``[myrepo]``. Set this + value to the empty string to suppress the email prefix. multimailhook.emailMaxLines @@ -299,7 +359,7 @@ multimailhook.emailMaxLines multimailhook.emailMaxLineLength The maximum length of a line in the email body. Lines longer than - this limit are truncated to this length with a trailing " [...]" + this limit are truncated to this length with a trailing `` [...]`` added to indicate the missing text. The default is 500, because (a) diffs with longer lines are probably from binary files, for which a diff is useless, and (b) even if a text file has such long @@ -316,54 +376,62 @@ multimailhook.maxCommitEmails multimailhook.emailStrictUTF8 - If this boolean option is set to "true", then the main part of the + If this boolean option is set to `true`, then the main part of the email body is forced to be valid UTF-8. Any characters that are not valid UTF-8 are converted to the Unicode replacement - character, U+FFFD. The default is "true". + character, U+FFFD. The default is `true`. multimailhook.diffOpts - Options passed to "git diff-tree" when generating the summary - information for ReferenceChange emails. Default is "--stat - --summary --find-copies-harder". Add -p to those options to + Options passed to ``git diff-tree`` when generating the summary + information for ReferenceChange emails. Default is ``--stat + --summary --find-copies-harder``. Add -p to those options to include a unified diff of changes in addition to the usual summary output. Shell quoting is allowed; see multimailhook.logOpts for details. +multimailhook.graphOpts + + Options passed to ``git log --graph`` when generating graphs for the + reference change summary emails (used only if refchangeShowGraph + is true). The default is '--oneline --decorate'. + + Shell quoting is allowed; see logOpts for details. + multimailhook.logOpts - Options passed to "git log" to generate additional info for + Options passed to ``git log`` to generate additional info for reference change emails (used only if refchangeShowLog is set). - For example, adding --graph will show the graph of revisions, -p - will show the complete diff, etc. The default is empty. + For example, adding -p will show each commit's complete diff. The + default is empty. Shell quoting is allowed; for example, a log format that contains - spaces can be specified using something like: + spaces can be specified using something like:: git config multimailhook.logopts '--pretty=format:"%h %aN <%aE>%n%s%n%n%b%n"' If you want to set this by editing your configuration file directly, remember that Git requires double-quotes to be escaped - (see git-config(1) for more information): + (see git-config(1) for more information):: [multimailhook] logopts = --pretty=format:\"%h %aN <%aE>%n%s%n%n%b%n\" multimailhook.commitLogOpts - Options passed to "git log" to generate additional info for + Options passed to ``git log`` to generate additional info for revision change emails. For example, adding --ignore-all-spaces - will suppress whitespace changes. The default options are "-C - --stat -p --cc". Shell quoting is allowed; see + will suppress whitespace changes. The default options are ``-C + --stat -p --cc``. Shell quoting is allowed; see multimailhook.logOpts for details. multimailhook.emailDomain Domain name appended to the username of the person doing the push - to convert it into an email address (via "%s@%s" % (username, - emaildomain)). More complicated schemes can be implemented by - overriding Environment and overriding its get_pusher_email() - method. + to convert it into an email address + (via ``"%s@%s" % (username, emaildomain)``). More complicated + schemes can be implemented by overriding Environment and + overriding its get_pusher_email() method. multimailhook.replyTo multimailhook.replyToCommit @@ -377,26 +445,48 @@ multimailhook.replyToRefchange - An email address, which will be used directly. - - The value "pusher", in which case the pusher's address (if + - The value `pusher`, in which case the pusher's address (if available) will be used. This is the default for refchange emails. - - The value "author" (meaningful only for replyToCommit), in which + - The value `author` (meaningful only for replyToCommit), in which case the commit author's address will be used. This is the default for commit emails. - - The value "none", in which case the Reply-To: field will be + - The value `none`, in which case the Reply-To: field will be omitted. +multimailhook.quiet + + Do not output the list of email recipients from the hook + +multimailhook.stdout + + For debugging, send emails to stdout rather than to the + mailer. Equivalent to the --stdout command line option + +multimailhook.scanCommitForCc + + If this option is set to true, than recipients from lines in commit body + that starts with ``CC:`` will be added to CC list. + Default: false + +multimailhook.combineWhenSingleCommit + + If this option is set to true and a single new commit is pushed to + a branch, combine the summary and commit email messages into a + single email. + Default: true + Email filtering aids -------------------- All emails include extra headers to enable fine tuned filtering and give information for debugging. All emails include the headers -"X-Git-Host", "X-Git-Repo", "X-Git-Refname", and "X-Git-Reftype". -ReferenceChange emails also include headers "X-Git-Oldrev" and "X-Git-Newrev"; -Revision emails also include header "X-Git-Rev". +``X-Git-Host``, ``X-Git-Repo``, ``X-Git-Refname``, and ``X-Git-Reftype``. +ReferenceChange emails also include headers ``X-Git-Oldrev`` and ``X-Git-Newrev``; +Revision emails also include header ``X-Git-Rev``. Customizing email contents @@ -420,16 +510,17 @@ environment are built in: * GenericEnvironment: a stand-alone Git repository. * GitoliteEnvironment: a Git repository that is managed by gitolite - [3]. For such repositories, the identity of the pusher is read from - environment variable $GL_USER, and the name of the repository is - read from $GL_REPO (if it is not overridden by - multimailhook.reponame). + [3]_. For such repositories, the identity of the pusher is read from + environment variable $GL_USER, the name of the repository is read + from $GL_REPO (if it is not overridden by multimailhook.reponame), + and the From: header value is optionally read from gitolite.conf + (see multimailhook.from). By default, git-multimail assumes GitoliteEnvironment if $GL_USER and $GL_REPO are set, and otherwise assumes GenericEnvironment. Alternatively, you can choose one of these two environments explicitly -by setting a "multimailhook.environment" config setting (which can -have the value "generic" or "gitolite") or by passing an --environment +by setting a ``multimailhook.environment`` config setting (which can +have the value `generic` or `gitolite`) or by passing an --environment option to the script. If you need to customize the script in ways that are not supported by @@ -439,8 +530,8 @@ git_multimail.py as a Python module, as demonstrated by the example post-receive script. Then implement your environment class; it should usually inherit from one of the existing Environment classes and possibly one or more of the EnvironmentMixin classes. Then set the -"environment" variable to an instance of your own environment class -and pass it to run_as_post_receive_hook(). +``environment`` variable to an instance of your own environment class +and pass it to ``run_as_post_receive_hook()``. The standard environment classes, GenericEnvironment and GitoliteEnvironment, are in fact themselves put together out of a @@ -490,12 +581,14 @@ don't overlook them. Footnotes --------- -[1] http://www.python.org/dev/peps/pep-0394/ +.. [1] http://www.python.org/dev/peps/pep-0394/ -[2] Because of the way information is passed to update hooks, the - script's method of determining whether a commit has already been - seen does not work when it is used as an "update" script. In - particular, no notification email will be generated for a new - commit that is added to multiple references in the same push. +.. [2] Because of the way information is passed to update hooks, the + script's method of determining whether a commit has already + been seen does not work when it is used as an ``update`` script. + In particular, no notification email will be generated for a + new commit that is added to multiple references in the same + push. A workaround is to use --force-send to force sending the + emails. -[3] https://github.com/sitaramc/gitolite +.. [3] https://github.com/sitaramc/gitolite diff --git a/contrib/hooks/multimail/README.Git b/contrib/hooks/multimail/README.Git index ab3ece5221..f5d59a8d31 100644 --- a/contrib/hooks/multimail/README.Git +++ b/contrib/hooks/multimail/README.Git @@ -6,10 +6,10 @@ website: https://github.com/git-multimail/git-multimail The version in this directory was obtained from the upstream project -on 2015-04-27 and consists of the "git-multimail" subdirectory from +on July 03 2015 and consists of the "git-multimail" subdirectory from revision - 8c3aaafa873bf10de8dddf1d202c449b3eff3b42 refs/tags/1.0.2 + 6d6c9eb62a054143322cfaecde3949189c065b46 refs/tags/1.1.1 Please see the README file in this directory for information about how to report bugs or contribute to git-multimail. diff --git a/contrib/hooks/multimail/git_multimail.py b/contrib/hooks/multimail/git_multimail.py index 8b58ed6444..c06ce7a515 100755 --- a/contrib/hooks/multimail/git_multimail.py +++ b/contrib/hooks/multimail/git_multimail.py @@ -1,5 +1,6 @@ #! /usr/bin/env python2 +# Copyright (c) 2015 Matthieu Moy and others # Copyright (c) 2012-2014 Michael Haggerty and others # Derived from contrib/hooks/post-receive-email, which is # Copyright (c) 2007 Andy Parkins @@ -99,6 +100,10 @@ REF_DELETED_SUBJECT_TEMPLATE = ( ' (was %(oldrev_short)s)' ) +COMBINED_REFCHANGE_REVISION_SUBJECT_TEMPLATE = ( + '%(emailprefix)s%(refname_type)s %(short_refname)s updated: %(oneline)s' + ) + REFCHANGE_HEADER_TEMPLATE = """\ Date: %(send_date)s To: %(recipients)s @@ -230,6 +235,7 @@ how to provide full information about this reference change. REVISION_HEADER_TEMPLATE = """\ Date: %(send_date)s To: %(recipients)s +Cc: %(cc_recipients)s Subject: %(emailprefix)s%(num)02d/%(tot)02d: %(oneline)s MIME-Version: 1.0 Content-Type: text/plain; charset=%(charset)s @@ -258,6 +264,38 @@ in repository %(repo_shortname)s. REVISION_FOOTER_TEMPLATE = FOOTER_TEMPLATE +# Combined, meaning refchange+revision email (for single-commit additions) +COMBINED_HEADER_TEMPLATE = """\ +Date: %(send_date)s +To: %(recipients)s +Subject: %(subject)s +MIME-Version: 1.0 +Content-Type: text/plain; charset=%(charset)s +Content-Transfer-Encoding: 8bit +Message-ID: %(msgid)s +From: %(fromaddr)s +Reply-To: %(reply_to)s +X-Git-Host: %(fqdn)s +X-Git-Repo: %(repo_shortname)s +X-Git-Refname: %(refname)s +X-Git-Reftype: %(refname_type)s +X-Git-Oldrev: %(oldrev)s +X-Git-Newrev: %(newrev)s +X-Git-Rev: %(rev)s +Auto-Submitted: auto-generated +""" + +COMBINED_INTRO_TEMPLATE = """\ +This is an automated email from the git hooks/post-receive script. + +%(pusher)s pushed a commit to %(refname_type)s %(short_refname)s +in repository %(repo_shortname)s. + +""" + +COMBINED_FOOTER_TEMPLATE = FOOTER_TEMPLATE + + class CommandError(Exception): def __init__(self, cmd, retcode): self.cmd = cmd @@ -336,6 +374,47 @@ def read_git_lines(args, keepends=False, **kw): return read_git_output(args, keepends=True, **kw).splitlines(keepends) +def git_rev_list_ish(cmd, spec, args=None, **kw): + """Common functionality for invoking a 'git rev-list'-like command. + + Parameters: + * cmd is the Git command to run, e.g., 'rev-list' or 'log'. + * spec is a list of revision arguments to pass to the named + command. If None, this function returns an empty list. + * args is a list of extra arguments passed to the named command. + * All other keyword arguments (if any) are passed to the + underlying read_git_lines() function. + + Return the output of the Git command in the form of a list, one + entry per output line. + """ + if spec is None: + return [] + if args is None: + args = [] + args = [cmd, '--stdin'] + args + spec_stdin = ''.join(s + '\n' for s in spec) + return read_git_lines(args, input=spec_stdin, **kw) + + +def git_rev_list(spec, **kw): + """Run 'git rev-list' with the given list of revision arguments. + + See git_rev_list_ish() for parameter and return value + documentation. + """ + return git_rev_list_ish('rev-list', spec, **kw) + + +def git_log(spec, **kw): + """Run 'git log' with the given list of revision arguments. + + See git_rev_list_ish() for parameter and return value + documentation. + """ + return git_rev_list_ish('log', spec, **kw) + + def header_encode(text, header_name=None): """Encode and line-wrap the value of an email header field.""" @@ -388,9 +467,9 @@ class Config(object): def get(self, name, default=None): try: values = self._split(read_git_output( - ['config', '--get', '--null', '%s.%s' % (self.section, name)], - env=self.env, keepends=True, - )) + ['config', '--get', '--null', '%s.%s' % (self.section, name)], + env=self.env, keepends=True, + )) assert len(values) == 1 return values[0] except CommandError: @@ -449,9 +528,14 @@ class Config(object): env=self.env, ) - def has_key(self, name): + def __contains__(self, name): return self.get_all(name, default=None) is not None + # We don't use this method anymore internally, but keep it here in + # case somebody is calling it from their own code: + def has_key(self, name): + return name in self + def unset_all(self, name): try: read_git_output( @@ -579,7 +663,7 @@ class Change(object): self._values = None def _compute_values(self): - """Return a dictionary {keyword : expansion} for this Change. + """Return a dictionary {keyword: expansion} for this Change. Derived classes overload this method to add more entries to the return value. This method is used internally by @@ -589,7 +673,7 @@ class Change(object): return self.environment.get_values() def get_values(self, **extra_values): - """Return a dictionary {keyword : expansion} for this Change. + """Return a dictionary {keyword: expansion} for this Change. Return a dictionary mapping keywords to the values that they should be expanded to for this Change (used when interpolating @@ -636,7 +720,7 @@ class Change(object): value = value % values except KeyError, e: if DEBUG: - sys.stderr.write( + self.environment.log_warning( 'Warning: unknown variable %r in the following line; line skipped:\n' ' %s\n' % (e.args[0], line,) @@ -711,6 +795,8 @@ class Change(object): class Revision(Change): """A Change consisting of a single git commit.""" + CC_RE = re.compile(r'^\s*C[Cc]:\s*(?P<to>[^#]+@[^\s#]*)\s*(#.*)?$') + def __init__(self, reference_change, rev, num, tot): Change.__init__(self, reference_change.environment) self.reference_change = reference_change @@ -722,6 +808,24 @@ class Revision(Change): self.author = read_git_output(['log', '--no-walk', '--format=%aN <%aE>', self.rev.sha1]) self.recipients = self.environment.get_revision_recipients(self) + self.cc_recipients = '' + if self.environment.get_scancommitforcc(): + self.cc_recipients = ', '.join(to.strip() for to in self._cc_recipients()) + if self.cc_recipients: + self.environment.log_msg( + 'Add %s to CC for %s\n' % (self.cc_recipients, self.rev.sha1)) + + def _cc_recipients(self): + cc_recipients = [] + message = read_git_output(['log', '--no-walk', '--format=%b', self.rev.sha1]) + lines = message.strip().split('\n') + for line in lines: + m = re.match(self.CC_RE, line) + if m: + cc_recipients.append(m.group('to')) + + return cc_recipients + def _compute_values(self): values = Change._compute_values(self) @@ -739,6 +843,8 @@ class Revision(Change): values['num'] = self.num values['tot'] = self.tot values['recipients'] = self.recipients + if self.cc_recipients: + values['cc_recipients'] = self.cc_recipients values['oneline'] = oneline values['author'] = self.author @@ -750,8 +856,8 @@ class Revision(Change): def generate_email_header(self, **extra_values): for line in self.expand_header_lines( - REVISION_HEADER_TEMPLATE, **extra_values - ): + REVISION_HEADER_TEMPLATE, **extra_values + ): yield line def generate_email_intro(self): @@ -822,26 +928,26 @@ class ReferenceChange(Change): klass = BranchChange elif area == 'remotes': # Tracking branch: - sys.stderr.write( + environment.log_warning( '*** Push-update of tracking branch %r\n' '*** - incomplete email generated.\n' - % (refname,) + % (refname,) ) klass = OtherReferenceChange else: # Some other reference namespace: - sys.stderr.write( + environment.log_warning( '*** Push-update of strange reference %r\n' '*** - incomplete email generated.\n' - % (refname,) + % (refname,) ) klass = OtherReferenceChange else: # Anything else (is there anything else?) - sys.stderr.write( + environment.log_warning( '*** Unknown type of update to %r (%s)\n' '*** - incomplete email generated.\n' - % (refname, rev.type,) + % (refname, rev.type,) ) klass = OtherReferenceChange @@ -854,9 +960,9 @@ class ReferenceChange(Change): def __init__(self, environment, refname, short_refname, old, new, rev): Change.__init__(self, environment) self.change_type = { - (False, True) : 'create', - (True, True) : 'update', - (True, False) : 'delete', + (False, True): 'create', + (True, True): 'update', + (True, False): 'delete', }[bool(old), bool(new)] self.refname = refname self.short_refname = short_refname @@ -865,10 +971,16 @@ class ReferenceChange(Change): self.rev = rev self.msgid = make_msgid() self.diffopts = environment.diffopts + self.graphopts = environment.graphopts self.logopts = environment.logopts self.commitlogopts = environment.commitlogopts + self.showgraph = environment.refchange_showgraph self.showlog = environment.refchange_showlog + self.header_template = REFCHANGE_HEADER_TEMPLATE + self.intro_template = REFCHANGE_INTRO_TEMPLATE + self.footer_template = FOOTER_TEMPLATE + def _compute_values(self): values = Change._compute_values(self) @@ -894,11 +1006,39 @@ class ReferenceChange(Change): return values + def send_single_combined_email(self, known_added_sha1s): + """Determine if a combined refchange/revision email should be sent + + If there is only a single new (non-merge) commit added by a + change, it is useful to combine the ReferenceChange and + Revision emails into one. In such a case, return the single + revision; otherwise, return None. + + This method is overridden in BranchChange.""" + + return None + + def generate_combined_email(self, push, revision, body_filter=None, extra_header_values={}): + """Generate an email describing this change AND specified revision. + + Iterate over the lines (including the header lines) of an + email describing this change. If body_filter is not None, + then use it to filter the lines that are intended for the + email body. + + The extra_header_values field is received as a dict and not as + **kwargs, to allow passing other keyword arguments in the + future (e.g. passing extra values to generate_email_intro() + + This method is overridden in BranchChange.""" + + raise NotImplementedError + def get_subject(self): template = { - 'create' : REF_CREATED_SUBJECT_TEMPLATE, - 'update' : REF_UPDATED_SUBJECT_TEMPLATE, - 'delete' : REF_DELETED_SUBJECT_TEMPLATE, + 'create': REF_CREATED_SUBJECT_TEMPLATE, + 'update': REF_UPDATED_SUBJECT_TEMPLATE, + 'delete': REF_DELETED_SUBJECT_TEMPLATE, }[self.change_type] return self.expand(template) @@ -907,12 +1047,12 @@ class ReferenceChange(Change): extra_values['subject'] = self.get_subject() for line in self.expand_header_lines( - REFCHANGE_HEADER_TEMPLATE, **extra_values - ): + self.header_template, **extra_values + ): yield line def generate_email_intro(self): - for line in self.expand_lines(REFCHANGE_INTRO_TEMPLATE): + for line in self.expand_lines(self.intro_template): yield line def generate_email_body(self, push): @@ -922,9 +1062,9 @@ class ReferenceChange(Change): generate_update_summary() / generate_delete_summary().""" change_summary = { - 'create' : self.generate_create_summary, - 'delete' : self.generate_delete_summary, - 'update' : self.generate_update_summary, + 'create': self.generate_create_summary, + 'delete': self.generate_delete_summary, + 'update': self.generate_update_summary, }[self.change_type](push) for line in change_summary: yield line @@ -933,7 +1073,23 @@ class ReferenceChange(Change): yield line def generate_email_footer(self): - return self.expand_lines(FOOTER_TEMPLATE) + return self.expand_lines(self.footer_template) + + def generate_revision_change_graph(self, push): + if self.showgraph: + args = ['--graph'] + self.graphopts + for newold in ('new', 'old'): + has_newold = False + spec = push.get_commits_spec(newold, self) + for line in git_log(spec, args=args, keepends=True): + if not has_newold: + has_newold = True + yield '\n' + yield 'Graph of %s commits:\n\n' % ( + {'new': 'new', 'old': 'discarded'}[newold],) + yield ' ' + line + if has_newold: + yield '\n' def generate_revision_change_log(self, new_commits_list): if self.showlog: @@ -945,9 +1101,17 @@ class ReferenceChange(Change): + new_commits_list + ['--'], keepends=True, - ): + ): yield line + def generate_new_revision_summary(self, tot, new_commits_list, push): + for line in self.expand_lines(NEW_REVISIONS_TEMPLATE, tot=tot): + yield line + for line in self.generate_revision_change_graph(push): + yield line + for line in self.generate_revision_change_log(new_commits_list): + yield line + def generate_revision_change_summary(self, push): """Generate a summary of the revisions added/removed by this change.""" @@ -960,7 +1124,7 @@ class ReferenceChange(Change): sha1s.reverse() tot = len(sha1s) new_revisions = [ - Revision(self, GitObject(sha1), num=i+1, tot=tot) + Revision(self, GitObject(sha1), num=i + 1, tot=tot) for (i, sha1) in enumerate(sha1s) ] @@ -973,9 +1137,8 @@ class ReferenceChange(Change): BRIEF_SUMMARY_TEMPLATE, action='new', text=subject, ) yield '\n' - for line in self.expand_lines(NEW_REVISIONS_TEMPLATE, tot=tot): - yield line - for line in self.generate_revision_change_log([r.rev.sha1 for r in new_revisions]): + for line in self.generate_new_revision_summary( + tot, [r.rev.sha1 for r in new_revisions], push): yield line else: for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE): @@ -993,16 +1156,16 @@ class ReferenceChange(Change): # revisions in the summary even though we will not send # new notification emails for them. adds = list(generate_summaries( - '--topo-order', '--reverse', '%s..%s' - % (self.old.commit_sha1, self.new.commit_sha1,) - )) + '--topo-order', '--reverse', '%s..%s' + % (self.old.commit_sha1, self.new.commit_sha1,) + )) # List of the revisions that were removed from the branch # by this update. This will be empty except for # non-fast-forward updates. discards = list(generate_summaries( - '%s..%s' % (self.new.commit_sha1, self.old.commit_sha1,) - )) + '%s..%s' % (self.new.commit_sha1, self.old.commit_sha1,) + )) if adds: new_commits_list = push.get_new_commits(self) @@ -1071,13 +1234,14 @@ class ReferenceChange(Change): yield '\n' if new_commits: - for line in self.expand_lines(NEW_REVISIONS_TEMPLATE, tot=len(new_commits)): - yield line - for line in self.generate_revision_change_log(new_commits_list): + for line in self.generate_new_revision_summary( + len(new_commits), new_commits_list, push): yield line else: for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE): yield line + for line in self.generate_revision_change_graph(push): + yield line # The diffstat is shown from the old revision to the new # revision. This is to show the truth of what happened in @@ -1089,11 +1253,11 @@ class ReferenceChange(Change): yield '\n' yield 'Summary of changes:\n' for line in read_git_lines( - ['diff-tree'] - + self.diffopts - + ['%s..%s' % (self.old.commit_sha1, self.new.commit_sha1,)], - keepends=True, - ): + ['diff-tree'] + + self.diffopts + + ['%s..%s' % (self.old.commit_sha1, self.new.commit_sha1,)], + keepends=True, + ): yield line elif self.old.commit_sha1 and not self.new.commit_sha1: @@ -1103,7 +1267,7 @@ class ReferenceChange(Change): sha1s = list(push.get_discarded_commits(self)) tot = len(sha1s) discarded_revisions = [ - Revision(self, GitObject(sha1), num=i+1, tot=tot) + Revision(self, GitObject(sha1), num=i + 1, tot=tot) for (i, sha1) in enumerate(sha1s) ] @@ -1116,6 +1280,8 @@ class ReferenceChange(Change): yield r.expand( BRIEF_SUMMARY_TEMPLATE, action='discards', text=subject, ) + for line in self.generate_revision_change_graph(push): + yield line else: for line in self.expand_lines(NO_DISCARDED_REVISIONS_TEMPLATE): yield line @@ -1161,6 +1327,150 @@ class BranchChange(ReferenceChange): old=old, new=new, rev=rev, ) self.recipients = environment.get_refchange_recipients(self) + self._single_revision = None + + def send_single_combined_email(self, known_added_sha1s): + if not self.environment.combine_when_single_commit: + return None + + # In the sadly-all-too-frequent usecase of people pushing only + # one of their commits at a time to a repository, users feel + # the reference change summary emails are noise rather than + # important signal. This is because, in this particular + # usecase, there is a reference change summary email for each + # new commit, and all these summaries do is point out that + # there is one new commit (which can readily be inferred by + # the existence of the individual revision email that is also + # sent). In such cases, our users prefer there to be a combined + # reference change summary/new revision email. + # + # So, if the change is an update and it doesn't discard any + # commits, and it adds exactly one non-merge commit (gerrit + # forces a workflow where every commit is individually merged + # and the git-multimail hook fired off for just this one + # change), then we send a combined refchange/revision email. + try: + # If this change is a reference update that doesn't discard + # any commits... + if self.change_type != 'update': + return None + + if read_git_lines( + ['merge-base', self.old.sha1, self.new.sha1] + ) != [self.old.sha1]: + return None + + # Check if this update introduced exactly one non-merge + # commit: + + def split_line(line): + """Split line into (sha1, [parent,...]).""" + + words = line.split() + return (words[0], words[1:]) + + # Get the new commits introduced by the push as a list of + # (sha1, [parent,...]) + new_commits = [ + split_line(line) + for line in read_git_lines( + [ + 'log', '-3', '--format=%H %P', + '%s..%s' % (self.old.sha1, self.new.sha1), + ] + ) + ] + + if not new_commits: + return None + + # If the newest commit is a merge, save it for a later check + # but otherwise ignore it + merge = None + tot = len(new_commits) + if len(new_commits[0][1]) > 1: + merge = new_commits[0][0] + del new_commits[0] + + # Our primary check: we can't combine if more than one commit + # is introduced. We also currently only combine if the new + # commit is a non-merge commit, though it may make sense to + # combine if it is a merge as well. + if not ( + len(new_commits) == 1 + and len(new_commits[0][1]) == 1 + and new_commits[0][0] in known_added_sha1s + ): + return None + + # We do not want to combine revision and refchange emails if + # those go to separate locations. + rev = Revision(self, GitObject(new_commits[0][0]), 1, tot) + if rev.recipients != self.recipients: + return None + + # We ignored the newest commit if it was just a merge of the one + # commit being introduced. But we don't want to ignore that + # merge commit it it involved conflict resolutions. Check that. + if merge and merge != read_git_output(['diff-tree', '--cc', merge]): + return None + + # We can combine the refchange and one new revision emails + # into one. Return the Revision that a combined email should + # be sent about. + return rev + except CommandError: + # Cannot determine number of commits in old..new or new..old; + # don't combine reference/revision emails: + return None + + def generate_combined_email(self, push, revision, body_filter=None, extra_header_values={}): + values = revision.get_values() + if extra_header_values: + values.update(extra_header_values) + if 'subject' not in extra_header_values: + values['subject'] = self.expand(COMBINED_REFCHANGE_REVISION_SUBJECT_TEMPLATE, **values) + + self._single_revision = revision + self.header_template = COMBINED_HEADER_TEMPLATE + self.intro_template = COMBINED_INTRO_TEMPLATE + self.footer_template = COMBINED_FOOTER_TEMPLATE + for line in self.generate_email(push, body_filter, values): + yield line + + def generate_email_body(self, push): + '''Call the appropriate body generation routine. + + If this is a combined refchange/revision email, the special logic + for handling this combined email comes from this function. For + other cases, we just use the normal handling.''' + + # If self._single_revision isn't set; don't override + if not self._single_revision: + for line in super(BranchChange, self).generate_email_body(push): + yield line + return + + # This is a combined refchange/revision email; we first provide + # some info from the refchange portion, and then call the revision + # generate_email_body function to handle the revision portion. + adds = list(generate_summaries( + '--topo-order', '--reverse', '%s..%s' + % (self.old.commit_sha1, self.new.commit_sha1,) + )) + + yield self.expand("The following commit(s) were added to %(refname)s by this push:\n") + for (sha1, subject) in adds: + yield self.expand( + BRIEF_SUMMARY_TEMPLATE, action='new', + rev_short=sha1, text=subject, + ) + + yield self._single_revision.rev.short + " is described below\n" + yield '\n' + + for line in self._single_revision.generate_email_body(push): + yield line class AnnotatedTagChange(ReferenceChange): @@ -1390,13 +1700,17 @@ class SendMailer(Mailer): sys.exit(1) try: p.stdin.writelines(lines) - except: + except Exception, e: sys.stderr.write( '*** Error while generating commit email\n' '*** - mail sending aborted.\n' ) - p.terminate() - raise + try: + # subprocess.terminate() is not available in Python 2.4 + p.terminate() + except AttributeError: + pass + raise e else: p.stdin.close() retcode = p.wait() @@ -1407,34 +1721,78 @@ class SendMailer(Mailer): class SMTPMailer(Mailer): """Send emails using Python's smtplib.""" - def __init__(self, envelopesender, smtpserver): + def __init__(self, envelopesender, smtpserver, + smtpservertimeout=10.0, smtpserverdebuglevel=0, + smtpencryption='none', + smtpuser='', smtppass='', + ): if not envelopesender: sys.stderr.write( 'fatal: git_multimail: cannot use SMTPMailer without a sender address.\n' 'please set either multimailhook.envelopeSender or user.email\n' ) sys.exit(1) + if smtpencryption == 'ssl' and not (smtpuser and smtppass): + raise ConfigurationException( + 'Cannot use SMTPMailer with security option ssl ' + 'without options username and password.' + ) self.envelopesender = envelopesender self.smtpserver = smtpserver + self.smtpservertimeout = smtpservertimeout + self.smtpserverdebuglevel = smtpserverdebuglevel + self.security = smtpencryption + self.username = smtpuser + self.password = smtppass try: - self.smtp = smtplib.SMTP(self.smtpserver) + def call(klass, server, timeout): + try: + return klass(server, timeout=timeout) + except TypeError: + # Old Python versions do not have timeout= argument. + return klass(server) + if self.security == 'none': + self.smtp = call(smtplib.SMTP, self.smtpserver, timeout=self.smtpservertimeout) + elif self.security == 'ssl': + self.smtp = call(smtplib.SMTP_SSL, self.smtpserver, timeout=self.smtpservertimeout) + elif self.security == 'tls': + if ':' not in self.smtpserver: + self.smtpserver += ':587' # default port for TLS + self.smtp = call(smtplib.SMTP, self.smtpserver, timeout=self.smtpservertimeout) + self.smtp.ehlo() + self.smtp.starttls() + self.smtp.ehlo() + else: + sys.stdout.write('*** Error: Control reached an invalid option. ***') + sys.exit(1) + if self.smtpserverdebuglevel > 0: + sys.stdout.write( + "*** Setting debug on for SMTP server connection (%s) ***\n" + % self.smtpserverdebuglevel) + self.smtp.set_debuglevel(self.smtpserverdebuglevel) except Exception, e: - sys.stderr.write('*** Error establishing SMTP connection to %s***\n' % self.smtpserver) + sys.stderr.write( + '*** Error establishing SMTP connection to %s ***\n' + % self.smtpserver) sys.stderr.write('*** %s\n' % str(e)) sys.exit(1) def __del__(self): - self.smtp.quit() + if hasattr(self, 'smtp'): + self.smtp.quit() def send(self, lines, to_addrs): try: + if self.username or self.password: + sys.stderr.write("*** Authenticating as %s ***\n" % self.username) + self.smtp.login(self.username, self.password) msg = ''.join(lines) # turn comma-separated list into Python list if needed. if isinstance(to_addrs, basestring): to_addrs = [email for (name, email) in getaddresses([to_addrs])] self.smtp.sendmail(self.envelopesender, to_addrs, msg) except Exception, e: - sys.stderr.write('*** Error sending email***\n') + sys.stderr.write('*** Error sending email ***\n') sys.stderr.write('*** %s\n' % str(e)) self.smtp.quit() sys.exit(1) @@ -1549,6 +1907,10 @@ class Environment(object): True iff announce emails should include a shortlog. + refchange_showgraph (bool) + + True iff refchanges emails should include a detailed graph. + refchange_showlog (bool) True iff refchanges emails should include a detailed log. @@ -1559,6 +1921,12 @@ class Environment(object): summary email. The value should be a list of strings representing words to be passed to the command. + graphopts (list of strings) + + Analogous to diffopts, but contains options passed to + 'git log --graph' when generating the detailed graph for + a set of commits (see refchange_showgraph) + logopts (list of strings) Analogous to diffopts, but contains options passed to @@ -1571,6 +1939,17 @@ class Environment(object): commit mail. The value should be a list of strings representing words to be passed to the command. + quiet (bool) + On success do not write to stderr + + stdout (bool) + Write email to stdout rather than emailing. Useful for debugging + + combine_when_single_commit (bool) + + True if a combined email should be produced when a single + new commit is pushed to a branch, False otherwise. + """ REPO_NAME_RE = re.compile(r'^(?P<name>.+?)(?:\.git)$') @@ -1580,9 +1959,14 @@ class Environment(object): self.announce_show_shortlog = False self.maxcommitemails = 500 self.diffopts = ['--stat', '--summary', '--find-copies-harder'] + self.graphopts = ['--oneline', '--decorate'] self.logopts = [] + self.refchange_showgraph = False self.refchange_showlog = False self.commitlogopts = ['-C', '--stat', '-p', '--cc'] + self.quiet = False + self.stdout = False + self.combine_when_single_commit = True self.COMPUTED_KEYS = [ 'administrator', @@ -1614,6 +1998,14 @@ class Environment(object): def get_pusher_email(self): return None + def get_fromaddr(self): + config = Config('user') + fromname = config.get('name', default='') + fromemail = config.get('email', default='') + if fromemail: + return formataddr([fromname, fromemail]) + return self.get_sender() + def get_administrator(self): return 'the administrator of this repository' @@ -1631,7 +2023,7 @@ class Environment(object): return CHARSET def get_values(self): - """Return a dictionary {keyword : expansion} for this Environment. + """Return a dictionary {keyword: expansion} for this Environment. This method is called by Change._compute_values(). The keys in the returned dictionary are available to be used in any of @@ -1699,6 +2091,24 @@ class Environment(object): return lines + def log_msg(self, msg): + """Write the string msg on a log file or on stderr. + + Sends the text to stderr by default, override to change the behavior.""" + sys.stderr.write(msg) + + def log_warning(self, msg): + """Write the string msg on a log file or on stderr. + + Sends the text to stderr by default, override to change the behavior.""" + sys.stderr.write(msg) + + def log_error(self, msg): + """Write the string msg on a log file or on stderr. + + Sends the text to stderr by default, override to change the behavior.""" + sys.stderr.write(msg) + class ConfigEnvironmentMixin(Environment): """A mixin that sets self.config to its constructor's config argument. @@ -1723,20 +2133,23 @@ class ConfigOptionsEnvironmentMixin(ConfigEnvironmentMixin): config=config, **kw ) - self.announce_show_shortlog = config.get_bool( - 'announceshortlog', default=self.announce_show_shortlog - ) - - self.refchange_showlog = config.get_bool( - 'refchangeshowlog', default=self.refchange_showlog - ) + for var, cfg in ( + ('announce_show_shortlog', 'announceshortlog'), + ('refchange_showgraph', 'refchangeShowGraph'), + ('refchange_showlog', 'refchangeshowlog'), + ('quiet', 'quiet'), + ('stdout', 'stdout'), + ): + val = config.get_bool(cfg) + if val is not None: + setattr(self, var, val) maxcommitemails = config.get('maxcommitemails') if maxcommitemails is not None: try: self.maxcommitemails = int(maxcommitemails) except ValueError: - sys.stderr.write( + self.log_warning( '*** Malformed value for multimailhook.maxCommitEmails: %s\n' % maxcommitemails + '*** Expected a number. Ignoring.\n' ) @@ -1745,6 +2158,10 @@ class ConfigOptionsEnvironmentMixin(ConfigEnvironmentMixin): if diffopts is not None: self.diffopts = shlex.split(diffopts) + graphopts = config.get('graphOpts') + if graphopts is not None: + self.graphopts = shlex.split(graphopts) + logopts = config.get('logopts') if logopts is not None: self.logopts = shlex.split(logopts) @@ -1756,14 +2173,18 @@ class ConfigOptionsEnvironmentMixin(ConfigEnvironmentMixin): reply_to = config.get('replyTo') self.__reply_to_refchange = config.get('replyToRefchange', default=reply_to) if ( - self.__reply_to_refchange is not None - and self.__reply_to_refchange.lower() == 'author' - ): + self.__reply_to_refchange is not None + and self.__reply_to_refchange.lower() == 'author' + ): raise ConfigurationException( '"author" is not an allowed setting for replyToRefchange' ) self.__reply_to_commit = config.get('replyToCommit', default=reply_to) + combine = config.get_bool('combineWhenSingleCommit') + if combine is not None: + self.combine_when_single_commit = combine + def get_administrator(self): return ( self.config.get('administrator') @@ -1779,8 +2200,12 @@ class ConfigOptionsEnvironmentMixin(ConfigEnvironmentMixin): def get_emailprefix(self): emailprefix = self.config.get('emailprefix') - if emailprefix and emailprefix.strip(): - return emailprefix.strip() + ' ' + if emailprefix is not None: + emailprefix = emailprefix.strip() + if emailprefix: + return emailprefix + ' ' + else: + return '' else: return '[%s] ' % (self.get_repo_shortname(),) @@ -1791,14 +2216,7 @@ class ConfigOptionsEnvironmentMixin(ConfigEnvironmentMixin): fromaddr = self.config.get('from') if fromaddr: return fromaddr - else: - config = Config('user') - fromname = config.get('name', default='') - fromemail = config.get('email', default='') - if fromemail: - return formataddr([fromname, fromemail]) - else: - return self.get_sender() + return super(ConfigOptionsEnvironmentMixin, self).get_fromaddr() def get_reply_to_refchange(self, refchange): if self.__reply_to_refchange is None: @@ -1814,7 +2232,7 @@ class ConfigOptionsEnvironmentMixin(ConfigEnvironmentMixin): if self.__reply_to_commit is None: return super(ConfigOptionsEnvironmentMixin, self).get_reply_to_commit(revision) elif self.__reply_to_commit.lower() == 'author': - return revision.get_author() + return revision.author elif self.__reply_to_commit.lower() == 'pusher': return self.get_pusher_email() elif self.__reply_to_commit.lower() == 'none': @@ -1822,6 +2240,9 @@ class ConfigOptionsEnvironmentMixin(ConfigEnvironmentMixin): else: return self.__reply_to_commit + def get_scancommitforcc(self): + return self.config.get('scancommitforcc') + class FilterLinesEnvironmentMixin(Environment): """Handle encoding and maximum line length of body lines. @@ -1862,9 +2283,9 @@ class FilterLinesEnvironmentMixin(Environment): class ConfigFilterLinesEnvironmentMixin( - ConfigEnvironmentMixin, - FilterLinesEnvironmentMixin, - ): + ConfigEnvironmentMixin, + FilterLinesEnvironmentMixin, + ): """Handle encoding and maximum line length based on config.""" def __init__(self, config, **kw): @@ -1896,9 +2317,9 @@ class MaxlinesEnvironmentMixin(Environment): class ConfigMaxlinesEnvironmentMixin( - ConfigEnvironmentMixin, - MaxlinesEnvironmentMixin, - ): + ConfigEnvironmentMixin, + MaxlinesEnvironmentMixin, + ): """Limit the email body to the number of lines specified in config.""" def __init__(self, config, **kw): @@ -1927,9 +2348,9 @@ class FQDNEnvironmentMixin(Environment): class ConfigFQDNEnvironmentMixin( - ConfigEnvironmentMixin, - FQDNEnvironmentMixin, - ): + ConfigEnvironmentMixin, + FQDNEnvironmentMixin, + ): """Read the FQDN from the config.""" def __init__(self, config, **kw): @@ -1970,10 +2391,10 @@ class StaticRecipientsEnvironmentMixin(Environment): """Set recipients statically based on constructor parameters.""" def __init__( - self, - refchange_recipients, announce_recipients, revision_recipients, - **kw - ): + self, + refchange_recipients, announce_recipients, revision_recipients, scancommitforcc, + **kw + ): super(StaticRecipientsEnvironmentMixin, self).__init__(**kw) # The recipients for various types of notification emails, as @@ -1985,7 +2406,8 @@ class StaticRecipientsEnvironmentMixin(Environment): # compute them once and for all: if not (refchange_recipients or announce_recipients - or revision_recipients): + or revision_recipients + or scancommitforcc): raise ConfigurationException('No email recipients configured!') self.__refchange_recipients = refchange_recipients self.__announce_recipients = announce_recipients @@ -2002,9 +2424,9 @@ class StaticRecipientsEnvironmentMixin(Environment): class ConfigRecipientsEnvironmentMixin( - ConfigEnvironmentMixin, - StaticRecipientsEnvironmentMixin - ): + ConfigEnvironmentMixin, + StaticRecipientsEnvironmentMixin + ): """Determine recipients statically based on config.""" def __init__(self, config, **kw): @@ -2019,6 +2441,7 @@ class ConfigRecipientsEnvironmentMixin( revision_recipients=self._get_recipients( config, 'commitlist', 'mailinglist', ), + scancommitforcc=config.get('scancommitforcc'), **kw ) @@ -2067,20 +2490,20 @@ class ProjectdescEnvironmentMixin(Environment): class GenericEnvironmentMixin(Environment): def get_pusher(self): - return self.osenv.get('USER', 'unknown user') + return self.osenv.get('USER', self.osenv.get('USERNAME', 'unknown user')) class GenericEnvironment( - ProjectdescEnvironmentMixin, - ConfigMaxlinesEnvironmentMixin, - ComputeFQDNEnvironmentMixin, - ConfigFilterLinesEnvironmentMixin, - ConfigRecipientsEnvironmentMixin, - PusherDomainEnvironmentMixin, - ConfigOptionsEnvironmentMixin, - GenericEnvironmentMixin, - Environment, - ): + ProjectdescEnvironmentMixin, + ConfigMaxlinesEnvironmentMixin, + ComputeFQDNEnvironmentMixin, + ConfigFilterLinesEnvironmentMixin, + ConfigRecipientsEnvironmentMixin, + PusherDomainEnvironmentMixin, + ConfigOptionsEnvironmentMixin, + GenericEnvironmentMixin, + Environment, + ): pass @@ -2097,6 +2520,45 @@ class GitoliteEnvironmentMixin(Environment): def get_pusher(self): return self.osenv.get('GL_USER', 'unknown user') + def get_fromaddr(self): + GL_USER = self.osenv.get('GL_USER') + if GL_USER is not None: + # Find the path to gitolite.conf. Note that gitolite v3 + # did away with the GL_ADMINDIR and GL_CONF environment + # variables (they are now hard-coded). + GL_ADMINDIR = self.osenv.get( + 'GL_ADMINDIR', + os.path.expanduser(os.path.join('~', '.gitolite'))) + GL_CONF = self.osenv.get( + 'GL_CONF', + os.path.join(GL_ADMINDIR, 'conf', 'gitolite.conf')) + if os.path.isfile(GL_CONF): + f = open(GL_CONF, 'rU') + try: + in_user_emails_section = False + re_template = r'^\s*#\s*{}\s*$' + re_begin, re_user, re_end = ( + re.compile(re_template.format(x)) + for x in ( + r'BEGIN\s+USER\s+EMAILS', + re.escape(GL_USER) + r'\s+(.*)', + r'END\s+USER\s+EMAILS', + )) + for l in f: + l = l.rstrip('\n') + if not in_user_emails_section: + if re_begin.match(l): + in_user_emails_section = True + continue + if re_end.match(l): + break + m = re_user.match(l) + if m: + return m.group(1) + finally: + f.close() + return super(GitoliteEnvironmentMixin, self).get_fromaddr() + class IncrementalDateTime(object): """Simple wrapper to give incremental date/times. @@ -2116,16 +2578,16 @@ class IncrementalDateTime(object): class GitoliteEnvironment( - ProjectdescEnvironmentMixin, - ConfigMaxlinesEnvironmentMixin, - ComputeFQDNEnvironmentMixin, - ConfigFilterLinesEnvironmentMixin, - ConfigRecipientsEnvironmentMixin, - PusherDomainEnvironmentMixin, - ConfigOptionsEnvironmentMixin, - GitoliteEnvironmentMixin, - Environment, - ): + ProjectdescEnvironmentMixin, + ConfigMaxlinesEnvironmentMixin, + ComputeFQDNEnvironmentMixin, + ConfigFilterLinesEnvironmentMixin, + ConfigRecipientsEnvironmentMixin, + PusherDomainEnvironmentMixin, + ConfigOptionsEnvironmentMixin, + GitoliteEnvironmentMixin, + Environment, + ): pass @@ -2149,9 +2611,9 @@ class Push(object): references. The first step is to determine the "other" references--those - unaffected by the current push. They are computed by - Push._compute_other_ref_sha1s() by listing all references then - removing any affected by this push. + unaffected by the current push. They are computed by listing all + references then removing any affected by this push. The results + are stored in Push._other_ref_sha1s. The commits contained in the repository before this push were @@ -2187,7 +2649,7 @@ class Push(object): possible and working with SHA1s thereafter (because SHA1s are immutable).""" - # A map {(changeclass, changetype) : integer} specifying the order + # A map {(changeclass, changetype): integer} specifying the order # that reference changes will be processed if multiple reference # changes are included in a single push. The order is significant # mostly because new commit notifications are threaded together @@ -2211,66 +2673,134 @@ class Push(object): ]) ) - def __init__(self, changes): + def __init__(self, changes, ignore_other_refs=False): self.changes = sorted(changes, key=self._sort_key) + self.__other_ref_sha1s = None + self.__cached_commits_spec = {} - # The SHA-1s of commits referred to by references unaffected - # by this push: - other_ref_sha1s = self._compute_other_ref_sha1s() + if ignore_other_refs: + self.__other_ref_sha1s = set() - self._old_rev_exclusion_spec = self._compute_rev_exclusion_spec( - other_ref_sha1s.union( - change.old.sha1 + @classmethod + def _sort_key(klass, change): + return (klass.SORT_ORDER[change.__class__, change.change_type], change.refname,) + + @property + def _other_ref_sha1s(self): + """The GitObjects referred to by references unaffected by this push. + """ + if self.__other_ref_sha1s is None: + # The refnames being changed by this push: + updated_refs = set( + change.refname for change in self.changes - if change.old.type in ['commit', 'tag'] ) - ) - self._new_rev_exclusion_spec = self._compute_rev_exclusion_spec( - other_ref_sha1s.union( - change.new.sha1 - for change in self.changes - if change.new.type in ['commit', 'tag'] + + # The SHA-1s of commits referred to by all references in this + # repository *except* updated_refs: + sha1s = set() + fmt = ( + '%(objectname) %(objecttype) %(refname)\n' + '%(*objectname) %(*objecttype) %(refname)' ) - ) + for line in read_git_lines( + ['for-each-ref', '--format=%s' % (fmt,)]): + (sha1, type, name) = line.split(' ', 2) + if sha1 and type == 'commit' and name not in updated_refs: + sha1s.add(sha1) - @classmethod - def _sort_key(klass, change): - return (klass.SORT_ORDER[change.__class__, change.change_type], change.refname,) + self.__other_ref_sha1s = sha1s + + return self.__other_ref_sha1s + + def _get_commits_spec_incl(self, new_or_old, reference_change=None): + """Get new or old SHA-1 from one or each of the changed refs. - def _compute_other_ref_sha1s(self): - """Return the GitObjects referred to by references unaffected by this push.""" + Return a list of SHA-1 commit identifier strings suitable as + arguments to 'git rev-list' (or 'git log' or ...). The + returned identifiers are either the old or new values from one + or all of the changed references, depending on the values of + new_or_old and reference_change. - # The refnames being changed by this push: - updated_refs = set( - change.refname + new_or_old is either the string 'new' or the string 'old'. If + 'new', the returned SHA-1 identifiers are the new values from + each changed reference. If 'old', the SHA-1 identifiers are + the old values from each changed reference. + + If reference_change is specified and not None, only the new or + old reference from the specified reference is included in the + return value. + + This function returns None if there are no matching revisions + (e.g., because a branch was deleted and new_or_old is 'new'). + """ + + if not reference_change: + incl_spec = sorted( + getattr(change, new_or_old).sha1 + for change in self.changes + if getattr(change, new_or_old) + ) + if not incl_spec: + incl_spec = None + elif not getattr(reference_change, new_or_old).commit_sha1: + incl_spec = None + else: + incl_spec = [getattr(reference_change, new_or_old).commit_sha1] + return incl_spec + + def _get_commits_spec_excl(self, new_or_old): + """Get exclusion revisions for determining new or discarded commits. + + Return a list of strings suitable as arguments to 'git + rev-list' (or 'git log' or ...) that will exclude all + commits that, depending on the value of new_or_old, were + either previously in the repository (useful for determining + which commits are new to the repository) or currently in the + repository (useful for determining which commits were + discarded from the repository). + + new_or_old is either the string 'new' or the string 'old'. If + 'new', the commits to be excluded are those that were in the + repository before the push. If 'old', the commits to be + excluded are those that are currently in the repository. """ + + old_or_new = {'old': 'new', 'new': 'old'}[new_or_old] + excl_revs = self._other_ref_sha1s.union( + getattr(change, old_or_new).sha1 for change in self.changes + if getattr(change, old_or_new).type in ['commit', 'tag'] ) + return ['^' + sha1 for sha1 in sorted(excl_revs)] - # The SHA-1s of commits referred to by all references in this - # repository *except* updated_refs: - sha1s = set() - fmt = ( - '%(objectname) %(objecttype) %(refname)\n' - '%(*objectname) %(*objecttype) %(refname)' - ) - for line in read_git_lines(['for-each-ref', '--format=%s' % (fmt,)]): - (sha1, type, name) = line.split(' ', 2) - if sha1 and type == 'commit' and name not in updated_refs: - sha1s.add(sha1) + def get_commits_spec(self, new_or_old, reference_change=None): + """Get rev-list arguments for added or discarded commits. - return sha1s + Return a list of strings suitable as arguments to 'git + rev-list' (or 'git log' or ...) that select those commits + that, depending on the value of new_or_old, are either new to + the repository or were discarded from the repository. - def _compute_rev_exclusion_spec(self, sha1s): - """Return an exclusion specification for 'git rev-list'. + new_or_old is either the string 'new' or the string 'old'. If + 'new', the returned list is used to select commits that are + new to the repository. If 'old', the returned value is used + to select the commits that have been discarded from the + repository. - git_objects is an iterable over GitObject instances. Return a - string that can be passed to the standard input of 'git - rev-list --stdin' to exclude all of the commits referred to by - git_objects.""" + If reference_change is specified and not None, the new or + discarded commits are limited to those that are reachable from + the new or old value of the specified reference. - return ''.join( - ['^%s\n' % (sha1,) for sha1 in sorted(sha1s)] - ) + This function returns None if there are no added (or discarded) + revisions. + """ + key = (new_or_old, reference_change) + if key not in self.__cached_commits_spec: + ret = self._get_commits_spec_incl(new_or_old, reference_change) + if ret is not None: + ret.extend(self._get_commits_spec_excl(new_or_old)) + self.__cached_commits_spec[key] = ret + return self.__cached_commits_spec[key] def get_new_commits(self, reference_change=None): """Return a list of commits added by this push. @@ -2280,19 +2810,8 @@ class Push(object): reference_change is None, then return a list of *all* commits added by this push.""" - if not reference_change: - new_revs = sorted( - change.new.sha1 - for change in self.changes - if change.new - ) - elif not reference_change.new.commit_sha1: - return [] - else: - new_revs = [reference_change.new.commit_sha1] - - cmd = ['rev-list', '--stdin'] + new_revs - return read_git_lines(cmd, input=self._old_rev_exclusion_spec) + spec = self.get_commits_spec('new', reference_change) + return git_rev_list(spec) def get_discarded_commits(self, reference_change): """Return a list of commits discarded by this push. @@ -2301,13 +2820,8 @@ class Push(object): entirely discarded from the repository by the part of this push represented by reference_change.""" - if not reference_change.old.commit_sha1: - return [] - else: - old_revs = [reference_change.old.commit_sha1] - - cmd = ['rev-list', '--stdin'] + old_revs - return read_git_lines(cmd, input=self._new_rev_exclusion_spec) + spec = self.get_commits_spec('old', reference_change) + return git_rev_list(spec) def send_emails(self, mailer, body_filter=None): """Use send all of the notification emails needed for this push. @@ -2325,30 +2839,43 @@ class Push(object): unhandled_sha1s = set(self.get_new_commits()) send_date = IncrementalDateTime() for change in self.changes: + sha1s = [] + for sha1 in reversed(list(self.get_new_commits(change))): + if sha1 in unhandled_sha1s: + sha1s.append(sha1) + unhandled_sha1s.remove(sha1) + # Check if we've got anyone to send to if not change.recipients: - sys.stderr.write( + change.environment.log_warning( '*** no recipients configured so no email will be sent\n' '*** for %r update %s->%s\n' % (change.refname, change.old.sha1, change.new.sha1,) ) else: - sys.stderr.write('Sending notification emails to: %s\n' % (change.recipients,)) - extra_values = {'send_date' : send_date.next()} - mailer.send( - change.generate_email(self, body_filter, extra_values), - change.recipients, - ) + if not change.environment.quiet: + change.environment.log_msg( + 'Sending notification emails to: %s\n' % (change.recipients,)) + extra_values = {'send_date': send_date.next()} - sha1s = [] - for sha1 in reversed(list(self.get_new_commits(change))): - if sha1 in unhandled_sha1s: - sha1s.append(sha1) - unhandled_sha1s.remove(sha1) + rev = change.send_single_combined_email(sha1s) + if rev: + mailer.send( + change.generate_combined_email(self, rev, body_filter, extra_values), + rev.recipients, + ) + # This change is now fully handled; no need to handle + # individual revisions any further. + continue + else: + mailer.send( + change.generate_email(self, body_filter, extra_values), + change.recipients, + ) max_emails = change.environment.maxcommitemails if max_emails and len(sha1s) > max_emails: - sys.stderr.write( + change.environment.log_warning( '*** Too many new commits (%d), not sending commit emails.\n' % len(sha1s) + '*** Try setting multimailhook.maxCommitEmails to a greater value\n' + '*** Currently, multimailhook.maxCommitEmails=%d\n' % max_emails @@ -2356,9 +2883,13 @@ class Push(object): return for (num, sha1) in enumerate(sha1s): - rev = Revision(change, GitObject(sha1), num=num+1, tot=len(sha1s)) + rev = Revision(change, GitObject(sha1), num=num + 1, tot=len(sha1s)) + if not rev.recipients and rev.cc_recipients: + change.environment.log_msg('*** Replacing Cc: with To:\n') + rev.recipients = rev.cc_recipients + rev.cc_recipients = None if rev.recipients: - extra_values = {'send_date' : send_date.next()} + extra_values = {'send_date': send_date.next()} mailer.send( rev.generate_email(self, body_filter, extra_values), rev.recipients, @@ -2366,7 +2897,7 @@ class Push(object): # Consistency check: if unhandled_sha1s: - sys.stderr.write( + change.environment.log_error( 'ERROR: No emails were sent for the following new commits:\n' ' %s\n' % ('\n '.join(sorted(unhandled_sha1s)),) @@ -2384,7 +2915,7 @@ def run_as_post_receive_hook(environment, mailer): push.send_emails(mailer, body_filter=environment.filter_body) -def run_as_update_hook(environment, mailer, refname, oldrev, newrev): +def run_as_update_hook(environment, mailer, refname, oldrev, newrev, force_send=False): changes = [ ReferenceChange.create( environment, @@ -2393,7 +2924,7 @@ def run_as_update_hook(environment, mailer, refname, oldrev, newrev): refname, ), ] - push = Push(changes) + push = Push(changes, force_send) push.send_emails(mailer, body_filter=environment.filter_body) @@ -2402,9 +2933,18 @@ def choose_mailer(config, environment): if mailer == 'smtp': smtpserver = config.get('smtpserver', default='localhost') + smtpservertimeout = float(config.get('smtpservertimeout', default=10.0)) + smtpserverdebuglevel = int(config.get('smtpserverdebuglevel', default=0)) + smtpencryption = config.get('smtpencryption', default='none') + smtpuser = config.get('smtpuser', default='') + smtppass = config.get('smtppass', default='') mailer = SMTPMailer( envelopesender=(environment.get_sender() or environment.get_fromaddr()), - smtpserver=smtpserver, + smtpserver=smtpserver, smtpservertimeout=smtpservertimeout, + smtpserverdebuglevel=smtpserverdebuglevel, + smtpencryption=smtpencryption, + smtpuser=smtpuser, + smtppass=smtppass, ) elif mailer == 'sendmail': command = config.get('sendmailcommand') @@ -2412,7 +2952,7 @@ def choose_mailer(config, environment): command = shlex.split(command) mailer = SendMailer(command=command, envelopesender=environment.get_sender()) else: - sys.stderr.write( + environment.log_error( 'fatal: multimailhook.mailer is set to an incorrect value: "%s"\n' % mailer + 'please use one of "smtp" or "sendmail".\n' ) @@ -2421,8 +2961,8 @@ def choose_mailer(config, environment): KNOWN_ENVIRONMENTS = { - 'generic' : GenericEnvironmentMixin, - 'gitolite' : GitoliteEnvironmentMixin, + 'generic': GenericEnvironmentMixin, + 'gitolite': GitoliteEnvironmentMixin, } @@ -2439,8 +2979,8 @@ def choose_environment(config, osenv=None, env=None, recipients=None): ConfigOptionsEnvironmentMixin, ] environment_kw = { - 'osenv' : osenv, - 'config' : config, + 'osenv': osenv, + 'config': config, } if not env: @@ -2459,6 +2999,7 @@ def choose_environment(config, osenv=None, env=None, recipients=None): environment_kw['refchange_recipients'] = recipients environment_kw['announce_recipients'] = recipients environment_kw['revision_recipients'] = recipients + environment_kw['scancommitforcc'] = config.get('scancommitforcc') else: environment_mixins.insert(0, ConfigRecipientsEnvironmentMixin) @@ -2499,6 +3040,14 @@ def main(args): '(intended for debugging purposes).' ), ) + parser.add_option( + '--force-send', action='store_true', default=False, + help=( + 'Force sending refchange email when using as an update hook. ' + 'This is useful to work around the unreliable new commits ' + 'detection in this mode.' + ), + ) (options, args) = parser.parse_args(args) @@ -2513,11 +3062,11 @@ def main(args): if options.show_env: sys.stderr.write('Environment values:\n') - for (k,v) in sorted(environment.get_values().items()): - sys.stderr.write(' %s : %r\n' % (k,v)) + for (k, v) in sorted(environment.get_values().items()): + sys.stderr.write(' %s : %r\n' % (k, v)) sys.stderr.write('\n') - if options.stdout: + if options.stdout or environment.stdout: mailer = OutputMailer(sys.stdout) else: mailer = choose_mailer(config, environment) @@ -2528,7 +3077,7 @@ def main(args): if len(args) != 3: parser.error('Need zero or three non-option arguments') (refname, oldrev, newrev) = args - run_as_update_hook(environment, mailer, refname, oldrev, newrev) + run_as_update_hook(environment, mailer, refname, oldrev, newrev, options.force_send) else: run_as_post_receive_hook(environment, mailer) except ConfigurationException, e: diff --git a/contrib/hooks/multimail/migrate-mailhook-config b/contrib/hooks/multimail/migrate-mailhook-config index 04eeaac413..d0e9b39201 100755 --- a/contrib/hooks/multimail/migrate-mailhook-config +++ b/contrib/hooks/multimail/migrate-mailhook-config @@ -22,6 +22,7 @@ OLD_NAMES = [ 'showrev', 'emailmaxlines', 'diffopts', + 'scancommitforcc', ] NEW_NAMES = [ @@ -38,6 +39,7 @@ NEW_NAMES = [ 'emailmaxlines', 'diffopts', 'emaildomain', + 'scancommitforcc', ] @@ -61,7 +63,7 @@ def _check_old_config_exists(old): """Check that at least one old configuration value is set.""" for name in OLD_NAMES: - if old.has_key(name): + if name in old: return True return False @@ -72,7 +74,7 @@ def _check_new_config_clear(new): retval = True for name in NEW_NAMES: - if new.has_key(name): + if name in new: if retval: sys.stderr.write('INFO: The following configuration values already exist:\n\n') sys.stderr.write(' "%s.%s"\n' % (new.section, name)) @@ -83,7 +85,7 @@ def _check_new_config_clear(new): def erase_values(config, names): for name in names: - if config.has_key(name): + if name in config: try: sys.stderr.write('...unsetting "%s.%s"\n' % (config.section, name)) config.unset_all(name) @@ -170,7 +172,7 @@ def migrate_config(strict=False, retain=False, overwrite=False): ) name = 'showrev' - if old.has_key(name): + if name in old: msg = 'git-multimail does not support "%s.%s"' % (old.section, name,) if strict: sys.exit( @@ -182,7 +184,7 @@ def migrate_config(strict=False, retain=False, overwrite=False): sys.stderr.write('\nWARNING: %s (ignoring).\n\n' % (msg,)) for name in ['mailinglist', 'announcelist']: - if old.has_key(name): + if name in old: sys.stderr.write( '...copying "%s.%s" to "%s.%s"\n' % (old.section, name, new.section, name) ) @@ -198,15 +200,15 @@ def migrate_config(strict=False, retain=False, overwrite=False): ) new.set('announceshortlog', 'true') - for name in ['envelopesender', 'emailmaxlines', 'diffopts']: - if old.has_key(name): + for name in ['envelopesender', 'emailmaxlines', 'diffopts', 'scancommitforcc']: + if name in old: sys.stderr.write( '...copying "%s.%s" to "%s.%s"\n' % (old.section, name, new.section, name) ) new.set(name, old.get(name)) name = 'emailprefix' - if old.has_key(name): + if name in old: sys.stderr.write( '...copying "%s.%s" to "%s.%s"\n' % (old.section, name, new.section, name) ) diff --git a/contrib/hooks/multimail/post-receive b/contrib/hooks/multimail/post-receive.example index 4d46828ba5..43f7b6b635 100755 --- a/contrib/hooks/multimail/post-receive +++ b/contrib/hooks/multimail/post-receive.example @@ -2,16 +2,18 @@ """Example post-receive hook based on git-multimail. -This script is a simple example of a post-receive hook implemented -using git_multimail.py as a Python module. It is intended to be -customized before use; see the comments in the script to help you get -started. +The simplest way to use git-multimail is to use the script +git_multimail.py directly as a post-receive hook, and to configure it +using Git's configuration files and command-line parameters. You can +also write your own Python wrapper for more advanced configurability, +using git_multimail.py as a Python module. -It is possible to use git_multimail.py itself as a post-receive or -update hook, configured via git config settings and/or command-line -parameters. But for more flexibility, it can also be imported as a -Python module by a custom post-receive script as done here. The -latter has the following advantages: +This script is a simple example of such a post-receive hook. It is +intended to be customized before use; see the comments in the script +to help you get started. + +Using git-multimail as a Python module as done here provides more +flexibility. It has the following advantages: * The tool's behavior can be customized using arbitrary Python code, without having to edit git_multimail.py. @@ -56,8 +58,11 @@ config = git_multimail.Config('multimailhook') # Select the type of environment: -environment = git_multimail.GenericEnvironment(config=config) -#environment = git_multimail.GitoliteEnvironment(config=config) +try: + environment = git_multimail.GenericEnvironment(config=config) + #environment = git_multimail.GitoliteEnvironment(config=config) +except git_multimail.ConfigurationException, e: + sys.exit(str(e)) # Choose the method of sending emails based on the git config: diff --git a/contrib/subtree/git-subtree.sh b/contrib/subtree/git-subtree.sh index fa1a5839af..07bd77c4c8 100755 --- a/contrib/subtree/git-subtree.sh +++ b/contrib/subtree/git-subtree.sh @@ -26,7 +26,7 @@ b,branch= create a new branch from the split subtree ignore-joins ignore prior --rejoin commits onto= try connecting new tree to an existing one rejoin merge the new branch back into HEAD - options for 'add', 'merge', 'pull' and 'push' + options for 'add', 'merge', and 'pull' squash merge subtree changes as a single commit " eval "$(echo "$OPTS_SPEC" | git rev-parse --parseopt -- "$@" || echo exit $?)" @@ -51,14 +51,21 @@ prefix= debug() { if [ -n "$debug" ]; then - echo "$@" >&2 + printf "%s\n" "$*" >&2 fi } say() { if [ -z "$quiet" ]; then - echo "$@" >&2 + printf "%s\n" "$*" >&2 + fi +} + +progress() +{ + if [ -z "$quiet" ]; then + printf "%s\r" "$*" >&2 fi } @@ -599,7 +606,7 @@ cmd_split() eval "$grl" | while read rev parents; do revcount=$(($revcount + 1)) - say -n "$revcount/$revmax ($createcount)
" + progress "$revcount/$revmax ($createcount)" debug "Processing commit: $rev" exists=$(cache_get $rev) if [ -n "$exists" ]; then diff --git a/contrib/subtree/git-subtree.txt b/contrib/subtree/git-subtree.txt index 54e4b4a243..60d76cdddf 100644 --- a/contrib/subtree/git-subtree.txt +++ b/contrib/subtree/git-subtree.txt @@ -146,7 +146,7 @@ OPTIONS OPTIONS FOR add, merge, push, pull ---------------------------------- --squash:: - This option is only valid for add, merge, push and pull + This option is only valid for add, merge, and pull commands. + Instead of merging the entire history from the subtree project, produce diff --git a/contrib/subtree/t/t7900-subtree.sh b/contrib/subtree/t/t7900-subtree.sh index 6309d124ca..bd3df97936 100755 --- a/contrib/subtree/t/t7900-subtree.sh +++ b/contrib/subtree/t/t7900-subtree.sh @@ -62,17 +62,17 @@ last_commit_message() } test_expect_success 'init subproj' ' - test_create_repo subproj + test_create_repo subproj ' # To the subproject! cd subproj test_expect_success 'add sub1' ' - create sub1 && - git commit -m "sub1" && - git branch sub1 && - git branch -m master subproj + create sub1 && + git commit -m "sub1" && + git branch sub1 && + git branch -m master subproj ' # Save this hash for testing later. @@ -80,133 +80,134 @@ test_expect_success 'add sub1' ' subdir_hash=$(git rev-parse HEAD) test_expect_success 'add sub2' ' - create sub2 && - git commit -m "sub2" && - git branch sub2 + create sub2 && + git commit -m "sub2" && + git branch sub2 ' test_expect_success 'add sub3' ' - create sub3 && - git commit -m "sub3" && - git branch sub3 + create sub3 && + git commit -m "sub3" && + git branch sub3 ' # Back to mainline cd .. test_expect_success 'add main4' ' - create main4 && - git commit -m "main4" && - git branch -m master mainline && - git branch subdir + create main4 && + git commit -m "main4" && + git branch -m master mainline && + git branch subdir ' test_expect_success 'fetch subproj history' ' - git fetch ./subproj sub1 && - git branch sub1 FETCH_HEAD + git fetch ./subproj sub1 && + git branch sub1 FETCH_HEAD ' test_expect_success 'no subtree exists in main tree' ' - test_must_fail git subtree merge --prefix=subdir sub1 + test_must_fail git subtree merge --prefix=subdir sub1 ' test_expect_success 'no pull from non-existant subtree' ' - test_must_fail git subtree pull --prefix=subdir ./subproj sub1 + test_must_fail git subtree pull --prefix=subdir ./subproj sub1 ' test_expect_success 'check if --message works for add' ' - git subtree add --prefix=subdir --message="Added subproject" sub1 && - check_equal ''"$(last_commit_message)"'' "Added subproject" && - undo + git subtree add --prefix=subdir --message="Added subproject" sub1 && + check_equal ''"$(last_commit_message)"'' "Added subproject" && + undo ' test_expect_success 'check if --message works as -m and --prefix as -P' ' - git subtree add -P subdir -m "Added subproject using git subtree" sub1 && - check_equal ''"$(last_commit_message)"'' "Added subproject using git subtree" && - undo + git subtree add -P subdir -m "Added subproject using git subtree" sub1 && + check_equal ''"$(last_commit_message)"'' "Added subproject using git subtree" && + undo ' test_expect_success 'check if --message works with squash too' ' - git subtree add -P subdir -m "Added subproject with squash" --squash sub1 && - check_equal ''"$(last_commit_message)"'' "Added subproject with squash" && - undo + git subtree add -P subdir -m "Added subproject with squash" --squash sub1 && + check_equal ''"$(last_commit_message)"'' "Added subproject with squash" && + undo ' test_expect_success 'add subproj to mainline' ' - git subtree add --prefix=subdir/ FETCH_HEAD && - check_equal ''"$(last_commit_message)"'' "Add '"'subdir/'"' from commit '"'"'''"$(git rev-parse sub1)"'''"'"'" + git subtree add --prefix=subdir/ FETCH_HEAD && + check_equal ''"$(last_commit_message)"'' "Add '"'subdir/'"' from commit '"'"'''"$(git rev-parse sub1)"'''"'"'" ' # this shouldn't actually do anything, since FETCH_HEAD is already a parent test_expect_success 'merge fetched subproj' ' - git merge -m "merge -s -ours" -s ours FETCH_HEAD + git merge -m "merge -s -ours" -s ours FETCH_HEAD ' test_expect_success 'add main-sub5' ' - create subdir/main-sub5 && - git commit -m "main-sub5" + create subdir/main-sub5 && + git commit -m "main-sub5" ' test_expect_success 'add main6' ' - create main6 && - git commit -m "main6 boring" + create main6 && + git commit -m "main6 boring" ' test_expect_success 'add main-sub7' ' - create subdir/main-sub7 && - git commit -m "main-sub7" + create subdir/main-sub7 && + git commit -m "main-sub7" ' test_expect_success 'fetch new subproj history' ' - git fetch ./subproj sub2 && - git branch sub2 FETCH_HEAD + git fetch ./subproj sub2 && + git branch sub2 FETCH_HEAD ' test_expect_success 'check if --message works for merge' ' - git subtree merge --prefix=subdir -m "Merged changes from subproject" sub2 && - check_equal ''"$(last_commit_message)"'' "Merged changes from subproject" && - undo + git subtree merge --prefix=subdir -m "Merged changes from subproject" sub2 && + check_equal ''"$(last_commit_message)"'' "Merged changes from subproject" && + undo ' test_expect_success 'check if --message for merge works with squash too' ' - git subtree merge --prefix subdir -m "Merged changes from subproject using squash" --squash sub2 && - check_equal ''"$(last_commit_message)"'' "Merged changes from subproject using squash" && - undo + git subtree merge --prefix subdir -m "Merged changes from subproject using squash" --squash sub2 && + check_equal ''"$(last_commit_message)"'' "Merged changes from subproject using squash" && + undo ' test_expect_success 'merge new subproj history into subdir' ' - git subtree merge --prefix=subdir FETCH_HEAD && - git branch pre-split && - check_equal ''"$(last_commit_message)"'' "Merge commit '"'"'"$(git rev-parse sub2)"'"'"' into mainline" + git subtree merge --prefix=subdir FETCH_HEAD && + git branch pre-split && + check_equal ''"$(last_commit_message)"'' "Merge commit '"'"'"$(git rev-parse sub2)"'"'"' into mainline" && + undo ' test_expect_success 'Check that prefix argument is required for split' ' - echo "You must provide the --prefix option." > expected && - test_must_fail git subtree split > actual 2>&1 && + echo "You must provide the --prefix option." > expected && + test_must_fail git subtree split > actual 2>&1 && test_debug "printf '"'"'expected: '"'"'" && - test_debug "cat expected" && + test_debug "cat expected" && test_debug "printf '"'"'actual: '"'"'" && - test_debug "cat actual" && - test_cmp expected actual && - rm -f expected actual + test_debug "cat actual" && + test_cmp expected actual && + rm -f expected actual ' test_expect_success 'Check that the <prefix> exists for a split' ' - echo "'"'"'non-existent-directory'"'"'" does not exist\; use "'"'"'git subtree add'"'"'" > expected && - test_must_fail git subtree split --prefix=non-existent-directory > actual 2>&1 && + echo "'"'"'non-existent-directory'"'"'" does not exist\; use "'"'"'git subtree add'"'"'" > expected && + test_must_fail git subtree split --prefix=non-existent-directory > actual 2>&1 && test_debug "printf '"'"'expected: '"'"'" && - test_debug "cat expected" && + test_debug "cat expected" && test_debug "printf '"'"'actual: '"'"'" && - test_debug "cat actual" && - test_cmp expected actual -# rm -f expected actual + test_debug "cat actual" && + test_cmp expected actual +# rm -f expected actual ' test_expect_success 'check if --message works for split+rejoin' ' - spl1=''"$(git subtree split --annotate='"'*'"' --prefix subdir --onto FETCH_HEAD --message "Split & rejoin" --rejoin)"'' && - git branch spl1 "$spl1" && - check_equal ''"$(last_commit_message)"'' "Split & rejoin" && - undo + spl1=''"$(git subtree split --annotate='"'*'"' --prefix subdir --onto FETCH_HEAD --message "Split & rejoin" --rejoin)"'' && + git branch spl1 "$spl1" && + check_equal ''"$(last_commit_message)"'' "Split & rejoin" && + undo ' test_expect_success 'check split with --branch' ' @@ -218,79 +219,76 @@ test_expect_success 'check split with --branch' ' test_expect_success 'check hash of split' ' spl1=$(git subtree split --prefix subdir) && - undo && git subtree split --prefix subdir --branch splitbr1test && - check_equal ''"$(git rev-parse splitbr1test)"'' "$spl1" - git checkout splitbr1test && - new_hash=$(git rev-parse HEAD~2) && - git checkout mainline && + check_equal ''"$(git rev-parse splitbr1test)"'' "$spl1" && + new_hash=$(git rev-parse splitbr1test~2) && check_equal ''"$new_hash"'' "$subdir_hash" ' test_expect_success 'check split with --branch for an existing branch' ' - spl1=''"$(git subtree split --annotate='"'*'"' --prefix subdir --onto FETCH_HEAD --message "Split & rejoin" --rejoin)"'' && - undo && - git branch splitbr2 sub1 && - git subtree split --annotate='"'*'"' --prefix subdir --onto FETCH_HEAD --branch splitbr2 && - check_equal ''"$(git rev-parse splitbr2)"'' "$spl1" + spl1=''"$(git subtree split --annotate='"'*'"' --prefix subdir --onto FETCH_HEAD --message "Split & rejoin" --rejoin)"'' && + undo && + git branch splitbr2 sub1 && + git subtree split --annotate='"'*'"' --prefix subdir --onto FETCH_HEAD --branch splitbr2 && + check_equal ''"$(git rev-parse splitbr2)"'' "$spl1" ' test_expect_success 'check split with --branch for an incompatible branch' ' - test_must_fail git subtree split --prefix subdir --onto FETCH_HEAD --branch subdir + test_must_fail git subtree split --prefix subdir --onto FETCH_HEAD --branch subdir ' test_expect_success 'check split+rejoin' ' - spl1=''"$(git subtree split --annotate='"'*'"' --prefix subdir --onto FETCH_HEAD --message "Split & rejoin" --rejoin)"'' && - undo && - git subtree split --annotate='"'*'"' --prefix subdir --onto FETCH_HEAD --rejoin && - check_equal ''"$(last_commit_message)"'' "Split '"'"'subdir/'"'"' into commit '"'"'"$spl1"'"'"'" + spl1=''"$(git subtree split --annotate='"'*'"' --prefix subdir --onto FETCH_HEAD --message "Split & rejoin" --rejoin)"'' && + undo && + git subtree split --annotate='"'*'"' --prefix subdir --onto FETCH_HEAD --rejoin && + check_equal ''"$(last_commit_message)"'' "Split '"'"'subdir/'"'"' into commit '"'"'"$spl1"'"'"'" ' test_expect_success 'add main-sub8' ' - create subdir/main-sub8 && - git commit -m "main-sub8" + create subdir/main-sub8 && + git commit -m "main-sub8" ' # To the subproject! cd ./subproj test_expect_success 'merge split into subproj' ' - git fetch .. spl1 && - git branch spl1 FETCH_HEAD && - git merge FETCH_HEAD + git fetch .. spl1 && + git branch spl1 FETCH_HEAD && + git merge FETCH_HEAD ' test_expect_success 'add sub9' ' - create sub9 && - git commit -m "sub9" + create sub9 && + git commit -m "sub9" ' # Back to mainline cd .. test_expect_success 'split for sub8' ' - split2=''"$(git subtree split --annotate='"'*'"' --prefix subdir/ --rejoin)"'' - git branch split2 "$split2" + split2=''"$(git subtree split --annotate='"'*'"' --prefix subdir/ --rejoin)"'' && + git branch split2 "$split2" ' test_expect_success 'add main-sub10' ' - create subdir/main-sub10 && - git commit -m "main-sub10" + create subdir/main-sub10 && + git commit -m "main-sub10" ' test_expect_success 'split for sub10' ' - spl3=''"$(git subtree split --annotate='"'*'"' --prefix subdir --rejoin)"'' && - git branch spl3 "$spl3" + spl3=''"$(git subtree split --annotate='"'*'"' --prefix subdir --rejoin)"'' && + git branch spl3 "$spl3" ' # To the subproject! cd ./subproj test_expect_success 'merge split into subproj' ' - git fetch .. spl3 && - git branch spl3 FETCH_HEAD && - git merge FETCH_HEAD && - git branch subproj-merge-spl3 + git fetch .. spl3 && + git branch spl3 FETCH_HEAD && + git merge FETCH_HEAD && + git branch subproj-merge-spl3 ' chkm="main4 main6" @@ -300,44 +298,44 @@ chks="sub1 sub2 sub3 sub9" chks_sub=$(echo $chks | multiline | sed 's,^,subdir/,' | fixnl) test_expect_success 'make sure exactly the right set of files ends up in the subproj' ' - subfiles=''"$(git ls-files | fixnl)"'' && - check_equal "$subfiles" "$chkms $chks" + subfiles=''"$(git ls-files | fixnl)"'' && + check_equal "$subfiles" "$chkms $chks" ' test_expect_success 'make sure the subproj history *only* contains commits that affect the subdir' ' - allchanges=''"$(git log --name-only --pretty=format:'"''"' | sort | fixnl)"'' && - check_equal "$allchanges" "$chkms $chks" + allchanges=''"$(git log --name-only --pretty=format:'"''"' | sort | fixnl)"'' && + check_equal "$allchanges" "$chkms $chks" ' # Back to mainline cd .. test_expect_success 'pull from subproj' ' - git fetch ./subproj subproj-merge-spl3 && - git branch subproj-merge-spl3 FETCH_HEAD && - git subtree pull --prefix=subdir ./subproj subproj-merge-spl3 + git fetch ./subproj subproj-merge-spl3 && + git branch subproj-merge-spl3 FETCH_HEAD && + git subtree pull --prefix=subdir ./subproj subproj-merge-spl3 ' test_expect_success 'make sure exactly the right set of files ends up in the mainline' ' - mainfiles=''"$(git ls-files | fixnl)"'' && - check_equal "$mainfiles" "$chkm $chkms_sub $chks_sub" + mainfiles=''"$(git ls-files | fixnl)"'' && + check_equal "$mainfiles" "$chkm $chkms_sub $chks_sub" ' test_expect_success 'make sure each filename changed exactly once in the entire history' ' - # main-sub?? and /subdir/main-sub?? both change, because those are the - # changes that were split into their own history. And subdir/sub?? never - # change, since they were *only* changed in the subtree branch. - allchanges=''"$(git log --name-only --pretty=format:'"''"' | sort | fixnl)"'' && - check_equal "$allchanges" ''"$(echo $chkms $chkm $chks $chkms_sub | multiline | sort | fixnl)"'' + # main-sub?? and /subdir/main-sub?? both change, because those are the + # changes that were split into their own history. And subdir/sub?? never + # change, since they were *only* changed in the subtree branch. + allchanges=''"$(git log --name-only --pretty=format:'"''"' | sort | fixnl)"'' && + check_equal "$allchanges" ''"$(echo $chkms $chkm $chks $chkms_sub | multiline | sort | fixnl)"'' ' test_expect_success 'make sure the --rejoin commits never make it into subproj' ' - check_equal ''"$(git log --pretty=format:'"'%s'"' HEAD^2 | grep -i split)"'' "" + check_equal ''"$(git log --pretty=format:'"'%s'"' HEAD^2 | grep -i split)"'' "" ' test_expect_success 'make sure no "git subtree" tagged commits make it into subproj' ' - # They are meaningless to subproj since one side of the merge refers to the mainline - check_equal ''"$(git log --pretty=format:'"'%s%n%b'"' HEAD^2 | grep "git-subtree.*:")"'' "" + # They are meaningless to subproj since one side of the merge refers to the mainline + check_equal ''"$(git log --pretty=format:'"'%s%n%b'"' HEAD^2 | grep "git-subtree.*:")"'' "" ' # prepare second pair of repositories @@ -345,27 +343,27 @@ mkdir test2 cd test2 test_expect_success 'init main' ' - test_create_repo main + test_create_repo main ' cd main test_expect_success 'add main1' ' - create main1 && - git commit -m "main1" + create main1 && + git commit -m "main1" ' cd .. test_expect_success 'init sub' ' - test_create_repo sub + test_create_repo sub ' cd sub test_expect_success 'add sub2' ' - create sub2 && - git commit -m "sub2" + create sub2 && + git commit -m "sub2" ' cd ../main @@ -373,33 +371,33 @@ cd ../main # check if split can find proper base without --onto test_expect_success 'add sub as subdir in main' ' - git fetch ../sub master && - git branch sub2 FETCH_HEAD && - git subtree add --prefix subdir sub2 + git fetch ../sub master && + git branch sub2 FETCH_HEAD && + git subtree add --prefix subdir sub2 ' cd ../sub test_expect_success 'add sub3' ' - create sub3 && - git commit -m "sub3" + create sub3 && + git commit -m "sub3" ' cd ../main test_expect_success 'merge from sub' ' - git fetch ../sub master && - git branch sub3 FETCH_HEAD && - git subtree merge --prefix subdir sub3 + git fetch ../sub master && + git branch sub3 FETCH_HEAD && + git subtree merge --prefix subdir sub3 ' test_expect_success 'add main-sub4' ' - create subdir/main-sub4 && - git commit -m "main-sub4" + create subdir/main-sub4 && + git commit -m "main-sub4" ' test_expect_success 'split for main-sub4 without --onto' ' - git subtree split --prefix subdir --branch mainsub4 + git subtree split --prefix subdir --branch mainsub4 ' # at this point, the new commit parent should be sub3 if it is not, @@ -408,21 +406,21 @@ test_expect_success 'split for main-sub4 without --onto' ' # itself) test_expect_success 'check that the commit parent is sub3' ' - check_equal ''"$(git log --pretty=format:%P -1 mainsub4)"'' ''"$(git rev-parse sub3)"'' + check_equal ''"$(git log --pretty=format:%P -1 mainsub4)"'' ''"$(git rev-parse sub3)"'' ' test_expect_success 'add main-sub5' ' - mkdir subdir2 && - create subdir2/main-sub5 && - git commit -m "main-sub5" + mkdir subdir2 && + create subdir2/main-sub5 && + git commit -m "main-sub5" ' test_expect_success 'split for main-sub5 without --onto' ' - # also test that we still can split out an entirely new subtree - # if the parent of the first commit in the tree is not empty, + # also test that we still can split out an entirely new subtree + # if the parent of the first commit in the tree is not empty, # then the new subtree has accidentally been attached to something - git subtree split --prefix subdir2 --branch mainsub5 && - check_equal ''"$(git log --pretty=format:%P -1 mainsub5)"'' "" + git subtree split --prefix subdir2 --branch mainsub5 && + check_equal ''"$(git log --pretty=format:%P -1 mainsub5)"'' "" ' # make sure no patch changes more than one file. The original set of commits @@ -450,20 +448,20 @@ joincommits() } test_expect_success 'verify one file change per commit' ' - x= && - list=''"$(git log --pretty=format:'"'commit: %H'"' | joincommits)"'' && -# test_debug "echo HERE" && -# test_debug "echo ''"$list"''" && - (git log --pretty=format:'"'commit: %H'"' | joincommits | - ( while read commit a b; do - test_debug "echo Verifying commit "''"$commit"'' - test_debug "echo a: "''"$a"'' - test_debug "echo b: "''"$b"'' - check_equal "$b" "" - x=1 - done - check_equal "$x" 1 - )) + x= && + list=''"$(git log --pretty=format:'"'commit: %H'"' | joincommits)"'' && +# test_debug "echo HERE" && +# test_debug "echo ''"$list"''" && + (git log --pretty=format:'"'commit: %H'"' | joincommits | + ( while read commit a b; do + test_debug "echo Verifying commit "''"$commit"'' + test_debug "echo a: "''"$a"'' + test_debug "echo b: "''"$b"'' + check_equal "$b" "" + x=1 + done + check_equal "$x" 1 + )) ' test_done |