Commit attributes are custom commit extra headers the user can add to the commit object. The motivation for this patch is that in my company we have a custom continuous integration software that uses a custom formatted commit message (currently in YALM format) to show several information into our CI server front-end. But this YALM-based commit message pollutes the commit object not being human readable, so a good form of achieve the YALM's behaviour (without using YALM nor any other structured language) is to add custom attributes to the commit object itself. For example, in our CI server we show the risk of the change (that can be low, medium or high); we, as said before, add this information by putting YALM code inside the commit message, but the problem is that this message is not human readable. To solve this in Git, we can use this patch to add a commit attribute indicating the risk of the commit out of the commit message, so that commit message can be a normal message (git notes would be a good approach, but this information is not inside of the commit object and in my company we need the commit carries the whole information). We could achieve this behaviour using git notes, but this approach implies using two operations per commit, and we have found with complaints from users. For example, to do a commit with 'risk' attribute, we would do: $ edit file.txt $ git add file.txt $ git commit -m "Commit message." --attr risk=low file.txt Attributes are not shown in normal git log/show. They must be obtained in an explicit way. For example, to show the previous attribute, we would do: $ git log -1 --format="%A(risk)" low $ git log -1 --format="%A(not_found)" %A(not_found) $ git log -1 --format="%A?(not_found)" $ Diego Lago (1): Add support for commit attributes Documentation/git-commit.txt | 12 ++- Documentation/pretty-formats.txt | 17 ++++ builtin/commit.c | 200 ++++++++++++++++++++++++++++++++++++++ pretty.c | 41 ++++++++ t/t2400-commit-attributes.sh | 187 +++++++++++++++++++++++++++++++++++ 5 files changed, 456 insertions(+), 1 deletion(-) create mode 100755 t/t2400-commit-attributes.sh -- 1.7.9.5 >From 5d63d32fc94c5824e6189092065160eaf075ff5e Mon Sep 17 00:00:00 2001 From: Diego Lago <diego.lago.gonzalez@xxxxxxxxx> Date: Tue, 8 Apr 2014 17:29:48 +0200 Subject: [PATCH] Add support for commit attributes Commit objects support extended attributes using '-A' or '--attr' flag to add them with 'git commit' command. Also, commit attributes are obtained using '%A(...)' format in git log and git show command. Examples: git commit -m "Commit message." --attr name=value git log -1 --format="%A(name)" # Shows 'value'. In addition, documentation and tests have been added. Signed-off-by: Diego Lago <diego.lago.gonzalez@xxxxxxxxx> --- Documentation/git-commit.txt | 12 ++- Documentation/pretty-formats.txt | 17 ++++ builtin/commit.c | 200 ++++++++++++++++++++++++++++++++++++++ pretty.c | 41 ++++++++ t/t2400-commit-attributes.sh | 187 +++++++++++++++++++++++++++++++++++ 5 files changed, 456 insertions(+), 1 deletion(-) create mode 100755 t/t2400-commit-attributes.sh diff --git a/Documentation/git-commit.txt b/Documentation/git-commit.txt index 429267a..7967eca 100644 --- a/Documentation/git-commit.txt +++ b/Documentation/git-commit.txt @@ -13,7 +13,7 @@ SYNOPSIS [-F <file> | -m <msg>] [--reset-author] [--allow-empty] [--allow-empty-message] [--no-verify] [-e] [--author=<author>] [--date=<date>] [--cleanup=<mode>] [--[no-]status] - [-i | -o] [-S[<keyid>]] [--] [<file>...] + [(-A | --attr) <key=value>] [-i | -o] [-S[<keyid>]] [--] [<file>...] DESCRIPTION ----------- @@ -304,6 +304,16 @@ configuration variable documented in linkgit:git-config[1]. commit message template when using an editor to prepare the default commit message. +-A <key=value>:: +--attr=<key=value>:: + Add an attribute (extra header) to the commit object. The attribute form + is a 'key=value' pair and neither the key nor the value cannot be empty (if + the commit is an amend commit, value can be empty). You can add as many + attributes as you want and these attributes can be shown either by + linkgit:git-log[1] or linkgit:git-show[1]. If used with --amend option, + duplicated attributes are replaced and if attributes have an empty value, + they are removed from the commit object. + -S[<keyid>]:: --gpg-sign[=<keyid>]:: GPG-sign commit. diff --git a/Documentation/pretty-formats.txt b/Documentation/pretty-formats.txt index 1d174fd..23d54b6 100644 --- a/Documentation/pretty-formats.txt +++ b/Documentation/pretty-formats.txt @@ -134,6 +134,8 @@ The placeholders are: - '%b': body - '%B': raw body (unwrapped subject and body) - '%N': commit notes +- '%A[?](...)': commit attribute (specifying attribute name inside brackets). + If used with '?', the placeholder is not shown if attribute is not found. - '%GG': raw verification message from GPG for a signed commit - '%G?': show "G" for a Good signature, "B" for a Bad signature, "U" for a good, untrusted signature and "N" for no signature @@ -197,6 +199,21 @@ If you add a ` ` (space) after '%' of a placeholder, a space is inserted immediately before the expansion if and only if the placeholder expands to a non-empty string. +* 'Commit attributes' ++ +In order to show commit attributes (extra headers), you have to put the name +of the attribute inside brackets of the %A placeholder. For example: ++ +--------------------- +$ git commit --attr "example_attr=The attribute value." -m "Commit message." +$ git show --format="%h %s -- Example attribute: %A(example_attr) [%an]" +4dabe05 Commit message. -- Example attribute: The attribute value. [John Doe] +$ git show --format="%h %s -- Not found: %A(not_found_attr) [%an]" +4dbbe05 Commit message. -- Not found: %A(not_found_attr). [John Doe] +$ git show --format="%h %s -- Not found: %A?(not_found_attr). [%an]" +4dbbe05 Commit message. -- Not found: . [John Doe] +--------------------- + * 'tformat:' + The 'tformat:' format works exactly like 'format:', except that it diff --git a/builtin/commit.c b/builtin/commit.c index d9550c5..3ba4995 100644 --- a/builtin/commit.c +++ b/builtin/commit.c @@ -103,6 +103,9 @@ static int no_post_rewrite, allow_empty_message; static char *untracked_files_arg, *force_date, *ignore_submodule_arg; static char *sign_commit; +/* Commit attributes (commit extra headers) */ +static struct commit_extra_header *commit_attrs = NULL; + /* * The default commit message cleanup mode will remove the lines * beginning with # (shell comments) and leading and trailing @@ -1479,6 +1482,192 @@ int run_commit_hook(int editor_is_used, const char *index_file, const char *name return ret; } +/* Removes the given commit extra header returning its parent. */ +static struct commit_extra_header *remove_commit_extra_header( + struct commit_extra_header **headers, struct commit_extra_header *to_remove) +{ + if (!headers || !*headers) + return NULL; + + struct commit_extra_header *r = *headers, *parent = NULL; + while(r != NULL) { + if (r == to_remove) { + if (parent == NULL) { + *headers = r->next; + } else { + parent->next = r->next; + } + free(to_remove->key); + free(to_remove->value); + free(to_remove); + break; + } + parent = r; + r = r->next; + } + return parent != NULL ? parent : *headers; +} + +/* Check for duplicated/empty attributes. */ +static void process_commit_attrs(struct commit_extra_header **attrs, + struct commit_extra_header **new_attrs, int is_amend) +{ + /* + * Check first if there are duplicated attributes from the attributes + * to be appended (new_attrs). + */ + struct commit_extra_header *r = *new_attrs, *r2 = *new_attrs; + while(r != NULL) { + r2 = *new_attrs; + while(r2 != NULL) { + if (r != r2 && strcmp(r->key, r2->key) == 0) { + die(_("Cannot add duplicated attributes to a commit: %s"), + r->key); + } + r2 = r2->next; + } + r = r->next; + } + + /* + * Now, check if there are duplicates for amended commits. Then, replace + * if they have a value or remove if they have an empty value. + */ + if (is_amend) { + r = *attrs, r2 = *new_attrs; + while(r != NULL) { + r2 = *new_attrs; + while(r2 != NULL) { + if (r != r2 && strcmp(r->key, r2->key) == 0) { + struct commit_extra_header *to_remove = r2; + if (r2->len == 0) { + /* If attribute is 'key=', remove it from main attrs. */ + r = remove_commit_extra_header(attrs, r); + } else { + free(r->value); + r->value = xmalloc(r2->len); + memcpy(r->value, r2->value, r2->len); + r->len = r2->len; + } + r2 = r2->next; + remove_commit_extra_header(new_attrs, to_remove); + } else { + r2 = r2->next; + } + } // while r2 + if (!r) + break; + r = r->next; + } // while r + } +} + +/* Checks if there are any commit extra header with an empty value. */ +static void check_for_empty_attrs(struct commit_extra_header *attrs) +{ + struct commit_extra_header *r = attrs; + + while(r != NULL) { + if (r->len == 0) + die(_("Cannot add an empty attribute to a commit: %s"), r->key); + r = r->next; + } +} + +/* Append attrs to current_attrs. */ +static void append_commit_attrs(struct commit_extra_header **current_attrs, + struct commit_extra_header *attrs) +{ + if(*current_attrs == NULL) { + *current_attrs = attrs; + return; + } + + struct commit_extra_header *r = *current_attrs; + + while(r->next != NULL) + r = r->next; + + r->next = attrs; +} + +static void check_disallowed_attribute_names(const char *attr_name) +{ + const char *disallowed_names[] = { "author", "committer", "encoding", + "gpgsig", "mergetag", "parent", "tree" }; + + int i; + for (i = 0; i < ARRAY_SIZE(disallowed_names); i++) { + if (strcmp(attr_name, disallowed_names[i]) == 0) { + die(_("Invalid commit attribute name: %s"), attr_name); + } + } +} + +/* Parses -A/--attr value of the form 'key=value'. */ +static int parse_attr_option_callback(const struct option *option, + const char *arg, int unset) +{ + unsigned short key_len = 0; + unsigned short value_len = 0; + short equal_found = 0; + short invalid_char_in_name = 0; + short invalid_char_in_value = 0; + + int i; + for (i = 0; arg[i] != '\0'; i++) { + if (arg[i] == '=') { + equal_found = 1; + if (invalid_char_in_name) { + break; + } + } else if (!equal_found && (arg[i] == ' ' || arg[i] == '\n')) { + invalid_char_in_name = 1; + key_len++; + } else if (equal_found && (arg[i] == '\n')) { + invalid_char_in_value = 1; + value_len++; + } else { + if(equal_found) { + value_len++; + } else { + key_len++; + } + } + } + + if(!equal_found || key_len == 0) { + die(_("Invalid commit attribute format (must be 'key=value'): %s"), arg); + } + + struct commit_extra_header *attr = xmalloc(sizeof(*attr)); + attr->next = NULL; + attr->key = xmalloc(key_len + 1); + memcpy(attr->key, arg, key_len); + attr->key[key_len] = '\0'; + if (invalid_char_in_name) { + die(_("Invalid character in commit attribute name: %s"), attr->key); + } + + check_disallowed_attribute_names(attr->key); + + attr->value = xmalloc(value_len); + memcpy(attr->value, arg + key_len + 1, value_len); + attr->len = value_len; + + if (invalid_char_in_value) { + die(_("Invalid character in commit attribute value: %s"), attr->value); + } + + if (!amend && value_len == 0) { + die(_("Commit attribute value cannot be empty if not amend.")); + } + + append_commit_attrs(&commit_attrs, attr); + + return 0; +} + int cmd_commit(int argc, const char **argv, const char *prefix) { static struct wt_status s; @@ -1526,6 +1715,7 @@ int cmd_commit(int argc, const char **argv, const char *prefix) OPT_BOOL(0, "amend", &amend, N_("amend previous commit")), OPT_BOOL(0, "no-post-rewrite", &no_post_rewrite, N_("bypass post-rewrite hook")), { OPTION_STRING, 'u', "untracked-files", &untracked_files_arg, N_("mode"), N_("show untracked files, optional modes: all, normal, no. (Default: all)"), PARSE_OPT_OPTARG, NULL, (intptr_t)"all" }, + OPT_CALLBACK('A', "attr", NULL, N_("key=value"), N_("add a commit attribute"), &parse_attr_option_callback), /* end commit contents options */ OPT_HIDDEN_BOOL(0, "allow-empty", &allow_empty, @@ -1659,6 +1849,16 @@ int cmd_commit(int argc, const char **argv, const char *prefix) append_merge_tag_headers(parents, &tail); } + /* Check for duplicated commit attributes on extra and commit_attrs. + Replace them if we are amending the commit. */ + process_commit_attrs(&extra, &commit_attrs, amend); + + /* Append extra attributes (supplied with --attr or -A). */ + append_commit_attrs(&extra, commit_attrs); + + /* Check for empty attributes again (may come from amend). */ + check_for_empty_attrs(extra); + if (commit_tree_extended(&sb, active_cache_tree->sha1, parents, sha1, author_ident.buf, sign_commit, extra)) { rollback_index_files(); diff --git a/pretty.c b/pretty.c index 3c43db5..8f4dc95 100644 --- a/pretty.c +++ b/pretty.c @@ -1081,6 +1081,45 @@ static size_t parse_padding_placeholder(struct strbuf *sb, return 0; } +static size_t parse_commit_attribute(struct strbuf *sb, /* in UTF-8 */ + const char *placeholder, + struct format_commit_context *c, + const struct commit *commit) +{ + unsigned short offset = 1; + unsigned short hide_if_not_found = 0; + + if (placeholder[offset] == '?') { + offset++; + hide_if_not_found = 1; + } + + if (placeholder[offset] == '(') { + const char *start = placeholder + offset + 1; + const char *end = strchr(start, ')'); + + if (!end || start == end) + return 0; + + const size_t len = end - start; + + char *attr_name = xmalloc(len + 1); + memcpy(attr_name, start, len); + attr_name[len] = '\0'; + + const char *msg = commit->buffer; + char *header = get_header(commit, msg, attr_name); + + if (header) { + strbuf_addstr(sb, header); + return len + offset + 2; + } else { + return hide_if_not_found ? len + offset + 2 : 0; + } + } else + return 0; +} + static size_t format_commit_one(struct strbuf *sb, /* in UTF-8 */ const char *placeholder, void *context) @@ -1240,6 +1279,8 @@ static size_t format_commit_one(struct strbuf *sb, /* in UTF-8 */ return 1; } return 0; + case 'A': + return parse_commit_attribute(sb, placeholder, c, commit); } if (placeholder[0] == 'G') { diff --git a/t/t2400-commit-attributes.sh b/t/t2400-commit-attributes.sh new file mode 100755 index 0000000..8993322 --- /dev/null +++ b/t/t2400-commit-attributes.sh @@ -0,0 +1,187 @@ +#!/bin/sh +# +# Copyright (c) 2014 Diego Lago González +# <diego.lago.gonzalez@xxxxxxxxx> +# + +test_description='git commit attributes (--attr option) + +This script test the commit attributes feature with +command git commit --attr <key=value>.' + +. ./test-lib.sh + +count=1 + +test_expect_success 'git commit --attr key=value' ' +echo test_$count > test && +count=$(($count+1)) && +git add test && +git commit --attr key=value -m "Commit message." +' + +test_expect_success 'git commit -A key=value' ' +echo test_$count > test && +count=$(($count+1)) && +git add test && +git commit -A key=value -m "Commit message." +' + +test_expect_success 'git commit --attr=key=value' ' +echo test_$count > test && +count=$(($count+1)) && +git add test && +git commit --attr=key=value -m "Commit message." +' + +test_expect_success 'git commit --attr with utf8 key' ' +echo test_$count > test && +count=$(($count+1)) && +git add test && +git commit --attr key→=value -m "Commit message." +' + +test_expect_success 'git commit --attr with utf8 value' ' +echo test_$count > test && +count=$(($count+1)) && +git add test && +git commit --attr key=valu€ -m "Commit message." +' + +test_expect_success 'git commit --attr with a long value' ' +echo test_$count > test && +count=$(($count+1)) && +git add test && +git commit --attr "key=Long message for an attribute." -m "Commit message." +' + +test_expect_success 'git commit --attr with a very long value' ' +echo test_$count > test && +count=$(($count+1)) && +git add test && +git commit --attr "key=Very long message for a commit attribute. This is a very long message to check if commit attributes (extra headers for commit objects) work as expected." -m "Commit message." +' + +test_expect_success 'git commit --attr key= (no value)' ' +echo test_$count > test && +count=$(($count+1)) && +git add test && +test_must_fail git commit --attr key= -m "Commit message." +' + +test_expect_success 'git commit --attr = (no key and no value)' ' +echo test_$count > test && +count=$(($count+1)) && +git add test && +test_must_fail git commit --attr = -m "Commit message." +' + +test_expect_success 'git commit --attr key (no value and no = sign)' ' +echo test_$count > test && +count=$(($count+1)) && +git add test && +test_must_fail git commit --attr key -m "Commit message." +' + +test_expect_success 'git commit --attr =value (no key with value)' ' +echo test_$count > test && +count=$(($count+1)) && +git add test && +test_must_fail git commit --attr =value -m "Commit message." +' + +test_expect_success 'git commit --attr "key =value" (key with space)' ' +echo test_$count > test && +count=$(($count+1)) && +git add test && +test_must_fail git commit --attr "key =value" -m "Commit message." +' + +test_expect_success 'git commit --attr: key with invalid chars' ' +echo test_$count > test && +count=$(($count+1)) && +git add test && +test_must_fail git commit --attr "key +=value" -m "Commit message." +' + +test_expect_success 'git commit --attr: value with invalid chars' ' +echo test_$count > test && +count=$(($count+1)) && +git add test && +test_must_fail git commit --attr "key=value +with invalid +chars" -m "Commit message." +' + +test_expect_success 'git commit --amend: new attribute on amend' ' +echo test_$count > test && +count=$(($count+1)) && +git add test && +git commit --amend --attr "new_key=new value" -m "New attribute on amend." +' + +test_expect_success 'git commit --amend: replace attribute on amend' ' +echo test_$count > test && +count=$(($count+1)) && +git add test && +git commit --amend --attr "new_key=replaced value" -m "Replaced on amend." +' + +test_expect_success 'git commit --amend: remove attribute on amend' ' +echo test_$count > test && +count=$(($count+1)) && +git add test && +git commit --amend --attr "new_key=" -m "Removed on amend." +' + +test_expect_success 'git log -1: get attribute' ' +echo test_$count > test && +count=$(($count+1)) && +git add test && +git commit --attr "key=testthis" -m "Commit message." && +test $(git log -1 --format="%A(key)") = "testthis" +' + +test_expect_success 'git log -1: get attribute (key not found)' ' +test_must_fail test $(git log -1 --format="%A(key_invalid)") = "testthis" +' + +test_expect_success 'git log -1: get attribute (text of key not found)' ' +test $(git log -1 --format="%A(key_invalid)") = "%A(key_invalid)" +' + +test_expect_success 'git log -1: get attribute (invalid attribute name)' ' +test_must_fail test $(git log -1 --format="%A(key)") = "testthis_invalid" +' + +test_expect_success 'git log -1: get attribute (key not found but hidden)' ' +test "$(git log -1 --format="%A?(key_invalid)")" = "" +' + +test_expect_success 'git log -1: get attribute (key with space)' ' +test "$(git log -1 --format="% A(key)")" = " testthis" +' + +test_expect_success 'git log -1: get attribute (key -hidden- and space)' ' +test "$(git log -1 --format="% A?(key)")" = " testthis" +' + +test_expect_success 'git log -1: get attribute (key not found but hidden -no space-)' ' +test "$(git log -1 --format="% A?(key_invalid)")" = "" +' + +test_expect_success 'git commit --amend: check for replaced attribute' ' +git commit --amend --attr key=testthat -m "Amend: replace attribute." +test_must_fail test "$(git log -1 --format="%A(key)")" = "testthis" +test "$(git log -1 --format="%A(key)")" = "testthat" +' + +test_expect_success 'git commit --amend: check for removed attribute' ' +git commit --amend --attr key= -m "Amend: replace attribute." +test_must_fail test "$(git log -1 --format="%A(key)")" = "testthis" +test "$(git log -1 --format="%A(key)")" = "%A(key)" +test "$(git log -1 --format="%A?(key)")" = "" +' + +test_done -- 1.7.9.5 -- To unsubscribe from this list: send the line "unsubscribe git" in the body of a message to majordomo@xxxxxxxxxxxxxxx More majordomo info at http://vger.kernel.org/majordomo-info.html