From 49e8cf17a4947bc1fbdc911c798b83762632a17c Mon Sep 17 00:00:00 2001 From: Mark Eissler Date: Mon, 9 Mar 2015 14:54:42 -0700 Subject: [PATCH] Complete rewrite to support commit ranges for pretty and list output. --- AUTHORS | 1 + bin/git-changelog | 356 ++++++++++++++++++++++++++++++++++------- man/git-changelog.1 | 121 ++++++++++---- man/git-changelog.html | 95 +++++++---- man/git-changelog.md | 84 +++++++--- 5 files changed, 524 insertions(+), 133 deletions(-) diff --git a/AUTHORS b/AUTHORS index eebf30732..bf21165b0 100644 --- a/AUTHORS +++ b/AUTHORS @@ -13,6 +13,7 @@ Maintainer Patches and Suggestions ``````````````````````` +- Mark Eissler - Kenneth Reitz - Aggelos Orfanakos - Jonathan "Duke" Leto diff --git a/bin/git-changelog b/bin/git-changelog index e6bc4fbdd..3761998cd 100755 --- a/bin/git-changelog +++ b/bin/git-changelog @@ -1,59 +1,307 @@ #!/usr/bin/env bash -FILE="" -LIST=false -TAG="n.n.n" -GIT_LOG_OPTS=$(git config changelog.opts) -GIT_LOG_FORMAT=$(git config changelog.format) -test -z "$GIT_LOG_FORMAT" && GIT_LOG_FORMAT=' * %s' -EDITOR=$(git var GIT_EDITOR) - -while [ "$1" != "" ]; do - case $1 in - -l | --list ) - LIST=true - ;; - -t | --tag ) - TAG=$2 - shift - ;; - --no-merges ) - GIT_LOG_OPTS='--no-merges' - ;; - * ) - FILE=$1 - ;; - esac - shift -done - -if $LIST; then - lasttag=$(git rev-list --tags --max-count=1 2>/dev/null) - version=$(git describe --tags --abbrev=0 $lasttag 2>/dev/null) - if test -z "$version"; then +DEF_TAG_RECENT="n.n.n" +GIT_LOG_OPTS="$(git config changelog.opts)" +GIT_LOG_FORMAT="$(git config changelog.format)" +[[ -z "$GIT_LOG_FORMAT" ]] && GIT_LOG_FORMAT=' * %s' +GIT_EDITOR="$(git var GIT_EDITOR)" +PROGNAME="git-changelog" + +_usage() { +cat << EOF +usage: $PROGNAME options [file] +usage: $PROGNAME -h|help|? + +Generate a Changelog from git(1) tags (annotated or lightweight) and commit +messages. Existing Changelog files with filenames that begin with 'Change' or +'History' will be identified automatically and their content will be appended +to the new output generated (unless the -p|--prune-old option is used). If no +tags exist, then all commits are output; if tags exist, then only the most- +recent commits are output up to the last identified tag. + +OPTIONS: + -a, --all Retrieve all commits (ignores --start-tag, --final-tag) + -l, --list Display commits as a list, with no titles + -t, --tag Tag label to use for most-recent (untagged) commits + -f, --final-tag Newest tag to retrieve commits from in a range + -s, --start-tag Oldest tag to retrieve commits from in a range + -n, --no-merges Suppress commits from merged branches + -p, --prune-old Replace existing Changelog entirely with new content + -x, --stdout Write output to stdout instead of to a Changelog file + -h, --help, ? Show this message +EOF +} + +_error() { + [ $# -eq 0 ] && _usage && exit 0 + + echo + echo "ERROR: " "$@" + echo +} + + +_fetchCommitRange() { + local list_all="${1:-false}" + local start_tag="$2" + local final_tag="$3" + + if [[ "$list_all" == true ]]; then git log $GIT_LOG_OPTS --pretty=format:"${GIT_LOG_FORMAT}" - else - git log $GIT_LOG_OPTS --pretty=format:"${GIT_LOG_FORMAT}" $version.. + elif [[ -n "$final_tag" && "$start_tag" == "null" ]]; then + git log $GIT_LOG_OPTS --pretty=format:"${GIT_LOG_FORMAT}" "${final_tag}" + elif [[ -n "$final_tag" ]]; then + git log $GIT_LOG_OPTS --pretty=format:"${GIT_LOG_FORMAT}" "${start_tag}"'..'"${final_tag}" + elif [[ -n "$start_tag" ]]; then + git log $GIT_LOG_OPTS --pretty=format:"${GIT_LOG_FORMAT}" "${start_tag}"'..' fi | sed 's/^ \* \*/ */g' - exit -fi - -DATE=`date +'%Y-%m-%d'` -HEAD="\n$TAG / $DATE\n" -for i in $(seq 5 ${#HEAD}); do HEAD="$HEAD="; done -HEAD="$HEAD\n\n" - -CHANGELOG=$FILE -if test "$CHANGELOG" = ""; then - CHANGELOG=`ls | egrep 'change|history' -i|head -n1` - if test "$CHANGELOG" = ""; then - CHANGELOG='History.md'; +} + +_formatCommitPlain() { + local start_tag="$1" + local final_tag="$2" + + printf "%s" "$(_fetchCommitRange "false" "$start_tag" "$final_tag")" +} + +_formatCommitPretty() { + local title_tag="$1" + local title_date="$2" + local start_tag="$3" + local final_tag="$4" + local title="$title_tag / $title_date" + local title_underline="" + + local i + for i in $(seq ${#title}); do + title_underline+="=" + done + unset i + + printf '\n%s\n%s\n' "$title" "$title_underline" + printf "\n%s\n" "$(_fetchCommitRange "false" "$start_tag" "$final_tag")" +} + +commitList() { + # parameter list supports empty arguments! + local list_all="${1:-false}"; shift + local title_tag="$1"; shift + local start_tag="$1"; shift + local final_tag="$1"; shift + local list_style="${1:-false}" # enable/disable list format + local changelog="$FILE" + local title_date="$(date +'%Y-%m-%d')" + local -A tags_list=() + local tags_list_keys=() + local defaultIFS="$IFS" + local IFS="$defaultIFS" + + # fetch our tags + local _ref _date _tag _tab='%x09' + while IFS=$'\t' read _ref _date _tag; do + [[ -z "${_tag}" ]] && continue + # strip out any additional tags pointing to same commit, remove tag label + _tag="${_tag%%,*}"; _tag="${_tag#tag: }" + # add tag to assoc array; copy tag to tag_list_keys for ordered iteration + tags_list["${_tag}"]="${_ref}=>${_date}" + tags_list_keys+=( "${_tag}" ) + done <<< "$(git log --tags --simplify-by-decoration --date="short" --pretty="format:%h${_tab}%ad${_tab}%D")" + IFS="$defaultIFS" + unset _ref _date _tag _tab + + local _tags_list_keys_length="${#tags_list_keys[@]}" + local _final_tag_found=false + local _start_tag_found=false + local i + for (( i=0; i<"${_tags_list_keys_length}"; i++ )); do + local __curr_tag="${tags_list_keys[$i]}" + local __prev_tag="${tags_list_keys[$i+1]:-null}" + local __curr_date="${tags_list[${__curr_tag}]##*=>}" + + # output latest commits, up until the most-recent tag, these are all + # new commits made since the last tagged commit. + if [[ $i -eq 0 && ( -z "$final_tag" || "$final_tag" == "null" ) ]]; then + if [[ "$list_style" == true ]]; then + _formatCommitPlain "${__curr_tag}" >> "$tmpfile" + else + _formatCommitPretty "$title_tag" "$title_date" "${__curr_tag}" + fi + fi + + # both final_tag and start_tag are "null", user just wanted recent commits + [[ "$final_tag" == "null" && "$start_tag" == "null" ]] && break; + + # find the specified final tag, continue until found + if [[ -n "$final_tag" && "$final_tag" != "null" ]]; then + [[ "$final_tag" == "${__curr_tag}" ]] && _final_tag_found=true + [[ "$final_tag" != "${__curr_tag}" && "${_final_tag_found}" == false ]] && continue + fi + + # find the specified start tag, break when found + if [[ -n "$start_tag" ]]; then + [[ "$start_tag" == "${__curr_tag}" ]] && _start_tag_found=true + [[ "$start_tag" != "${__curr_tag}" && "${_start_tag_found}" == true ]] && break + fi + + # output commits made between prev_tag and curr_tag, these are all of the + # commits related to the tag of interest. + if [[ "$list_style" == true ]]; then + _formatCommitPlain "${__prev_tag}" "${__curr_tag}" + else + _formatCommitPretty "${__curr_tag}" "${__curr_date}" "${__prev_tag}" "${__curr_tag}" + fi + done + unset i + unset _start_tag_found + unset _final_tag_found + + return +} + +commitListPlain() { + local list_all="${1:-false}" + local start_tag="$2" + local final_tag="$3" + + commitList "$list_all" "" "$start_tag" "$final_tag" "true" +} + +commitListPretty() { + local list_all="${1:-false}" + local title_tag="$2" + local start_tag="$3" + local final_tag="$4" + local title_date="$(date +'%Y-%m-%d')" + + commitList "$list_all" "$title_tag" "$start_tag" "$final_tag" +} + +main() { + local start_tag="null" # empty string and "null" mean two different things! + local final_tag="null" + + local -A option=() + option["list_all"]=false + option["list_style"]=false + option["title_tag"]="$DEF_TAG_RECENT" + option["start_tag"]="" + option["final_tag"]="" + option["output_file"]="" + option["use_stdout"]=false + option["prune_old"]=false + + # + # We work chronologically backwards from NOW towards start_tag where NOW also + # includes the most-recent (un-tagged) commits. If no start_tag has been + # specified, we work back to the very first commit; if a final_tag has been + # specified, we begin at the final_tag and work backwards towards start_tag. + # + + # An existing ChangeLog/History file will be appended to the output unless the + # prune old (-p | --prune-old) option has been enabled. + + while [ "$1" != "" ]; do + case $1 in + -a | --all ) + option["list_all"]=true + ;; + -l | --list ) + option["list_style"]=true + ;; + -t | --tag ) + option["title_tag"]="$2" + shift + ;; + -f | --final-tag ) + option["final_tag"]="$2" + shift + ;; + -s | --start-tag ) + option["start_tag"]="$2" + shift + ;; + -n | --no-merges ) + GIT_LOG_OPTS='--no-merges' + ;; + -p | --prune-old ) + option["prune_old"]=true + ;; + -x | --stdout ) + option["use_stdout"]=true + ;; + -h | ? | help | --help ) + _usage + exit 1 + ;; + * ) + [[ "${1:0:1}" == '-' ]] && _error "Invalid option: $1" && _usage && exit 1 + option["output_file"]="$1" + ;; + esac + shift + done + + if [[ -n "${option["start_tag"]}" ]]; then + start_tag="$(git describe --tags --abbrev=0 "${option["start_tag"]}" 2>/dev/null)" + if [[ -z "$start_tag" ]]; then + _error "Specified start-tag does not exist!" + return 1 + fi + fi + + if [[ -n "${option["final_tag"]}" ]]; then + final_tag="$(git describe --tags --abbrev=0 "${option["final_tag"]}" 2>/dev/null)" + if [[ -z "$final_tag" ]]; then + _error "Specified final-tag does not exist!" + return 1 + fi + fi + + # + # generate changelog + # + local tmpfile="$(git_extra_mktemp)" + local changelog="${option["output_file"]}" + local title_tag="${option["title_tag"]}" + + if [[ "${option["list_style"]}" == true ]]; then + if [[ "${option["list_all"]}" == true ]]; then + commitListPlain "true" >> "$tmpfile" + else + commitListPlain "false" "$start_tag" "$final_tag" >> "$tmpfile" + fi + else + if [[ "${option["list_all"]}" == true ]]; then + commitListPretty "true" "$title_tag" >> "$tmpfile" + else + commitListPretty "false" "$title_tag" "$start_tag" "$final_tag" >> "$tmpfile" + fi + fi + + if [[ -z "$changelog" ]]; then + changelog="$(ls | egrep 'change|history' -i | head -n1)" + if [[ -z "$changelog" ]]; then + changelog="History.md"; + fi + fi + + # append existing changelog? + if [[ -f "$changelog" && "${option["prune_old"]}" == false ]]; then + cat "$changelog" >> "$tmpfile" fi -fi -tmp=$(git_extra_mktemp) -printf "$HEAD" > $tmp -git-changelog $GIT_LOG_OPTS --list >> $tmp -printf '\n' >> $tmp -if [ -f $CHANGELOG ]; then cat $CHANGELOG >> $tmp; fi -mv -f $tmp $CHANGELOG -test -n "$EDITOR" && $EDITOR $CHANGELOG + + # output file to stdout or move into place + if [[ "${option["use_stdout"]}" == true ]]; then + cat "$tmpfile" + rm -f "$tmpfile" + else + mv -f "$tmpfile" "$changelog" + [[ -n "$GIT_EDITOR" ]] && $GIT_EDITOR "$changelog" + fi + + return +} + +main "$@" + +exit 0 diff --git a/man/git-changelog.1 b/man/git-changelog.1 index 6c5988625..be278d81e 100644 --- a/man/git-changelog.1 +++ b/man/git-changelog.1 @@ -1,80 +1,135 @@ .\" generated with Ronn/v0.7.3 .\" http://github.com/rtomayko/ronn/tree/0.7.3 . -.TH "GIT\-CHANGELOG" "1" "November 2014" "" "" +.TH "GIT\-CHANGELOG" "1" "March 2015" "" "Git Extras" . .SH "NAME" -\fBgit\-changelog\fR \- Generate the changelog report +\fBgit\-changelog\fR \- Generate a changelog report . .SH "SYNOPSIS" -\fBgit\-changelog\fR [\-l, \-\-list] +\fBgit\-changelog\fR [options] [] +. +.br +\fBgit\-changelog\fR {\-h | \-\-help | ?} . .SH "DESCRIPTION" -Populates the file named matching \fIchange|history \-i\fR with the commits since the previous tag or since the project began when no tags are present\. Opens the changelog in \fB$EDITOR\fR when set\. +Generates a changelog from git(1) tags (annotated or lightweight) and commit messages\. Existing changelog files with filenames that begin with \fIChange\fR or \fIHistory\fR will be identified automatically with a case insensitive match pattern and existing content will be appended to the new output generated\-\-this behavior can be disabled by specifying the prune option (\-p|\-\-prune\-old)\. The generated file will be opened in \fB$EDITOR\fR when set\. +. +.P +If no tags exist, then all commits are output; if tags exist, then only the most\-recent commits are output up to the last identified tag\. This behavior can be changed by specifing one or both of the range options (\-f|\-\-final\-tag and \-s|\-\-start\-tag)\. . .SH "OPTIONS" + +. +.P +The name of the output file\. By default the new file will be \fIHistory\.md\fR unless an existing changelog is detected in which case the existing file will be updated\. +. +.P +\-a, \-\-all +. +.P +Retrieve all commits\. Ignores \-s|\-\-start\-tag and \-f|\-\-final\-tag options (if set)\. +. +.P \-l, \-\-list . .P -Show commit logs from the current version\. +Show commits in list format (without titles, dates)\. +. +.P +\-t, \-\-tag +. +.P +Specify a tag label to use for most\-recent (untagged) commits\. +. +.P +\-f, \-\-final\-tag +. +.P +When specifying a range, the newest tag at which point commit retrieval will end\. Commits will be returned from the very first commit until the final tag unless a start tag is also specified\. +. +.P +\-s, \-\-start\-tag . .P -\-\-no\-merges +When specifying a range, the oldest tag to retrieve commits from\. Commits will be returned from the start tag to now unless a final tag is also specified\. +. +.P +\-n, \-\-no\-merges . .P Filters out merge commits (commits with more than 1 parent) from generated changelog\. . +.P +\-p, \-\-prune\-old +. +.P +Replace existing changelog entirely with newly generated content, thereby disabling the default behavior of appending the content of any detected changelog to the end of newly generated content\. +. +.P +\-x, \-\-stdout +. +.P +Write output to stdout instead of to a new changelog file\. +. +.P +\-h, \-\-help, ? +. +.P +Show a help message with basic usage information\. +. .SH "EXAMPLES" . -.IP "\(bu" 4 -Updating changelog file: +.TP +Updating existing file or creating a new \fIHistory\.md\fR file with pretty formatted output: . .IP $ git changelog . -.IP "\(bu" 4 +.TP Listing commits from the current version: . .IP $ git changelog \-\-list . -.IP "\(bu" 4 -Docs for git\-ignore\. Closes #3 +.TP +Listing a range of commits from 2\.1\.0 to now: . -.IP "\(bu" 4 -Merge branch \'ignore\' +.IP +$ git changelog \-\-list \-\-start\-tag 2\.1\.0 . -.IP "\(bu" 4 -Added git\-ignore +.TP +Listing a pretty formatted version of the same: . -.IP "\(bu" 4 -Fixed in docs +.IP +$ git changelog \-\-start\-tag 2\.1\.0 . -.IP "\(bu" 4 -Install docs +.TP +Listing a range of commits from initial commit to 2\.1\.0: . -.IP "\(bu" 4 -Merge branch \'release\' +.IP +$ git changelog \-\-list \-\-final\-tag 2\.1\.0 . -.IP "\(bu" 4 -Added git\-release +.TP +Listing a pretty formatted range of commits between 0\.5\.0 and 1\.0\.0: . -.IP "\(bu" 4 -Passing args to git shortlog +.IP +$ git changelog \-\-start\-tag 0\.5\.0 \-\-final\-tag 1\.0\.0 . -.IP "\(bu" 4 -Added \-\-all support to git\-count +.TP +Specifying a file for output: . -.IP "\(bu" 4 -Initial commit +.IP +$ git changelog ChangeLog\.md . -.IP "" 0 - +.TP +And if an existing Changelog exists, replace its contents entirely: . -.IP "" 0 +.IP +$ git changelog \-\-prune\-old . .SH "AUTHOR" -Written by Tj Holowaychuk <\fItj@vision\-media\.ca\fR> +Written by Mark Eissler <\fImark@mixtur\.com\fR> . .SH "REPORTING BUGS" <\fIhttps://github\.com/tj/git\-extras/issues\fR> diff --git a/man/git-changelog.html b/man/git-changelog.html index 60f9f5313..2c6d02950 100644 --- a/man/git-changelog.html +++ b/man/git-changelog.html @@ -3,7 +3,7 @@ - git-changelog(1) - Generate the changelog report + git-changelog(1) - Generate a changelog report