Change the branch & tag commands to have a --no-contains option in
addition to their longstanding --contains options.

The use-case I have for this is mainly to find the last-good rollout
tag given a known-bad <commit>. Right given a hypothetically bad
commit v2.10.1-3-gcf5c7253e0 now you can find that with this hacky
one-liner:

    (./git tag -l 'v[0-9]*'; ./git tag -l 'v[0-9]*' --contains 
v2.10.1-3-gcf5c7253e0)|sort|uniq -c|grep -E '^ *1 '|awk '{print $2}'

But with the --no-contains option you can now get the exact same
output with:

    ./git tag -l 'v[0-9]*' --no-contains v2.10.1-3-gcf5c7253e0 | sort

Once I'd implemented this for "tag" it was easy enough to add it for
"branch". I haven't added it to "for-each-ref" but that would be
trivial if anyone cares, but that use-case would be even more obscure
than adding it to "branch", so I haven't bothered. The "describe"
command also has a --contains option, but its semantics are unrelated
to what tag/branch/for-each-ref use --contains for, and I don't see
how a --no-contains option for it would make any sense.

More notes about this patch:

 * I'm not really happy with the "special attention" documentation
   example in git-branch.txt, but it follows logically from the
   description for --contains just above it which I think is overly
   specific as well. IMO that entire NOTES section in git-branch.txt
   could just be removed.

 * I'm adding a --without option as an alias for --no-contains for
   consistency with --with and --contains. Since we don't even
   document --with anymore (or test it) perhaps we shouldn't be adding
   --without.

 * Where I'm changing existing documentation lines I'm mainly word
   wrapping at 75 columns to be consistent with the existing style.

 * Ditto the minor change to git-completion.bash.

 * Perhaps we should just copy/paste commit_contains() into
   commit_no_contains() and skip the ternary struct assignment. It's a
   hot loop, but I have faith in modern compilers.

 * All of the test changes I've made are just doing the inverse of the
   existing --contains tests, with this --no-contains for both tag &
   branch should be just as tested as the existing --contains option.

Signed-off-by: Ævar Arnfjörð Bjarmason <ava...@gmail.com>
---
 Documentation/git-branch.txt           |  24 +++--
 Documentation/git-tag.txt              |   6 +-
 builtin/branch.c                       |   4 +-
 builtin/tag.c                          |   2 +
 contrib/completion/git-completion.bash |   9 +-
 parse-options.h                        |   4 +-
 ref-filter.c                           |  17 ++--
 ref-filter.h                           |   1 +
 t/t3201-branch-contains.sh             |  40 +++++++-
 t/t7004-tag.sh                         | 163 ++++++++++++++++++++++++++++++++-
 10 files changed, 245 insertions(+), 25 deletions(-)

diff --git a/Documentation/git-branch.txt b/Documentation/git-branch.txt
index 092f1bcf9f..316ec5b2d4 100644
--- a/Documentation/git-branch.txt
+++ b/Documentation/git-branch.txt
@@ -11,7 +11,7 @@ SYNOPSIS
 'git branch' [--color[=<when>] | --no-color] [-r | -a]
        [--list] [-v [--abbrev=<length> | --no-abbrev]]
        [--column[=<options>] | --no-column]
-       [(--merged | --no-merged | --contains) [<commit>]] [--sort=<key>]
+       [(--merged | --no-merged | --contains | --no-contains) [<commit>]] 
[--sort=<key>]
        [--points-at <object>] [--format=<format>] [<pattern>...]
 'git branch' [--set-upstream | --track | --no-track] [-l] [-f] <branchname> 
[<start-point>]
 'git branch' (--set-upstream-to=<upstream> | -u <upstream>) [<branchname>]
@@ -35,11 +35,12 @@ as branch creation.
 
 With `--contains`, shows only the branches that contain the named commit
 (in other words, the branches whose tip commits are descendants of the
-named commit).  With `--merged`, only branches merged into the named
-commit (i.e. the branches whose tip commits are reachable from the named
-commit) will be listed.  With `--no-merged` only branches not merged into
-the named commit will be listed.  If the <commit> argument is missing it
-defaults to `HEAD` (i.e. the tip of the current branch).
+named commit), `--no-contains` inverts it. With `--merged`, only branches
+merged into the named commit (i.e. the branches whose tip commits are
+reachable from the named commit) will be listed.  With `--no-merged` only
+branches not merged into the named commit will be listed.  If the <commit>
+argument is missing it defaults to `HEAD` (i.e. the tip of the current
+branch).
 
 The command's second form creates a new branch head named <branchname>
 which points to the current `HEAD`, or <start-point> if given.
@@ -213,6 +214,10 @@ start-point is either a local or remote-tracking branch.
        Only list branches which contain the specified commit (HEAD
        if not specified). Implies `--list`.
 
+--no-contains [<commit>]::
+       Only list branches which don't contain the specified commit
+       (HEAD if not specified). Implies `--list`.
+
 --merged [<commit>]::
        Only list branches whose tips are reachable from the
        specified commit (HEAD if not specified). Implies `--list`.
@@ -296,13 +301,16 @@ If you are creating a branch that you want to checkout 
immediately, it is
 easier to use the git checkout command with its `-b` option to create
 a branch and check it out with a single command.
 
-The options `--contains`, `--merged` and `--no-merged` serve three related
-but different purposes:
+The options `--contains`, `--no-contains`, `--merged` and `--no-merged`
+serve three related but different purposes:
 
 - `--contains <commit>` is used to find all branches which will need
   special attention if <commit> were to be rebased or amended, since those
   branches contain the specified <commit>.
 
+- `--no-contains <commit>` is used to find those branches which won't need
+  that special attention.
+
 - `--merged` is used to find all branches which can be safely deleted,
   since those branches are fully contained by HEAD.
 
diff --git a/Documentation/git-tag.txt b/Documentation/git-tag.txt
index 525737a5d8..4938496194 100644
--- a/Documentation/git-tag.txt
+++ b/Documentation/git-tag.txt
@@ -12,7 +12,7 @@ SYNOPSIS
 'git tag' [-a | -s | -u <keyid>] [-f] [-m <msg> | -F <file>]
        <tagname> [<commit> | <object>]
 'git tag' -d <tagname>...
-'git tag' [-n[<num>]] -l [--contains <commit>] [--points-at <object>]
+'git tag' [-n[<num>]] -l [--[no-]contains <commit>] [--points-at <object>]
        [--column[=<options>] | --no-column] [--create-reflog] [--sort=<key>]
        [--format=<format>] [--[no-]merged [<commit>]] [<pattern>...]
 'git tag' -v [--format=<format>] <tagname>...
@@ -124,6 +124,10 @@ This option is only applicable when listing tags without 
annotation lines.
        Only list tags which contain the specified commit (HEAD if not
        specified).
 
+--no-contains [<commit>]::
+       Only list tags which don't contain the specified commit (HEAD if
+       not secified).
+
 --points-at <object>::
        Only list tags of the given object.
 
diff --git a/builtin/branch.c b/builtin/branch.c
index 94f7de7fa5..e8d534604c 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -548,7 +548,9 @@ int cmd_branch(int argc, const char **argv, const char 
*prefix)
                OPT_SET_INT('r', "remotes",     &filter.kind, N_("act on 
remote-tracking branches"),
                        FILTER_REFS_REMOTES),
                OPT_CONTAINS(&filter.with_commit, N_("print only branches that 
contain the commit")),
+               OPT_NO_CONTAINS(&filter.no_commit, N_("print only branches that 
don't contain the commit")),
                OPT_WITH(&filter.with_commit, N_("print only branches that 
contain the commit")),
+               OPT_WITHOUT(&filter.with_commit, N_("print only branches that 
don't contain the commit")),
                OPT__ABBREV(&filter.abbrev),
 
                OPT_GROUP(N_("Specific git-branch actions:")),
@@ -604,7 +606,7 @@ int cmd_branch(int argc, const char **argv, const char 
*prefix)
        if (!delete && !rename && !edit_description && !new_upstream && 
!unset_upstream && argc == 0)
                list = 1;
 
-       if (filter.with_commit || filter.merge != REF_FILTER_MERGED_NONE || 
filter.points_at.nr)
+       if (filter.with_commit || filter.no_commit || filter.merge != 
REF_FILTER_MERGED_NONE || filter.points_at.nr)
                list = 1;
 
        if (!!delete + !!rename + !!new_upstream +
diff --git a/builtin/tag.c b/builtin/tag.c
index ad29be6923..737a83028a 100644
--- a/builtin/tag.c
+++ b/builtin/tag.c
@@ -424,7 +424,9 @@ int cmd_tag(int argc, const char **argv, const char *prefix)
                OPT_GROUP(N_("Tag listing options")),
                OPT_COLUMN(0, "column", &colopts, N_("show tag list in 
columns")),
                OPT_CONTAINS(&filter.with_commit, N_("print only tags that 
contain the commit")),
+               OPT_NO_CONTAINS(&filter.no_commit, N_("print only tags that 
don't contain the commit")),
                OPT_WITH(&filter.with_commit, N_("print only tags that contain 
the commit")),
+               OPT_WITHOUT(&filter.no_commit, N_("print only tags that don't 
contain the commit")),
                OPT_MERGED(&filter, N_("print only tags that are merged")),
                OPT_NO_MERGED(&filter, N_("print only tags that are not 
merged")),
                OPT_CALLBACK(0 , "sort", sorting_tail, N_("key"),
diff --git a/contrib/completion/git-completion.bash 
b/contrib/completion/git-completion.bash
index fc32286a43..fa3da49478 100644
--- a/contrib/completion/git-completion.bash
+++ b/contrib/completion/git-completion.bash
@@ -1093,9 +1093,9 @@ _git_branch ()
        --*)
                __gitcomp "
                        --color --no-color --verbose --abbrev= --no-abbrev
-                       --track --no-track --contains --merged --no-merged
-                       --set-upstream-to= --edit-description --list
-                       --unset-upstream --delete --move --remotes
+                       --track --no-track --contains --no-contains --merged
+                       --no-merged --set-upstream-to= --edit-description
+                       --list --unset-upstream --delete --move --remotes
                        --column --no-column --sort= --points-at
                        "
                ;;
@@ -2862,7 +2862,8 @@ _git_tag ()
                __gitcomp "
                        --list --delete --verify --annotate --message --file
                        --sign --cleanup --local-user --force --column --sort=
-                       --contains --points-at --merged --no-merged 
--create-reflog
+                       --contains --no-contains --points-at --merged
+                       --no-merged --create-reflog
                        "
                ;;
        esac
diff --git a/parse-options.h b/parse-options.h
index dcd8a0926c..0eac90b510 100644
--- a/parse-options.h
+++ b/parse-options.h
@@ -258,7 +258,9 @@ extern int parse_opt_passthru_argv(const struct option *, 
const char *, int);
          PARSE_OPT_LASTARG_DEFAULT | flag, \
          parse_opt_commits, (intptr_t) "HEAD" \
        }
-#define OPT_CONTAINS(v, h) _OPT_CONTAINS_OR_WITH("contains", v, h, 0)
+#define OPT_CONTAINS(v, h) _OPT_CONTAINS_OR_WITH("contains", v, h, 
PARSE_OPT_NONEG)
+#define OPT_NO_CONTAINS(v, h) _OPT_CONTAINS_OR_WITH("no-contains", v, h, 
PARSE_OPT_NONEG)
 #define OPT_WITH(v, h) _OPT_CONTAINS_OR_WITH("with", v, h, PARSE_OPT_HIDDEN)
+#define OPT_WITHOUT(v, h) _OPT_CONTAINS_OR_WITH("without", v, h, 
PARSE_OPT_HIDDEN)
 
 #endif
diff --git a/ref-filter.c b/ref-filter.c
index 1ec0fb8391..6a7ca1cdac 100644
--- a/ref-filter.c
+++ b/ref-filter.c
@@ -1571,11 +1571,12 @@ static enum contains_result contains_tag_algo(struct 
commit *candidate,
        return contains_test(candidate, want);
 }
 
-static int commit_contains(struct ref_filter *filter, struct commit *commit)
+static int commit_contains(struct ref_filter *filter, struct commit *commit, 
const int with_commit)
 {
+       struct commit_list *tmp = with_commit ? filter->with_commit : 
filter->no_commit;
        if (filter->with_commit_tag_algo)
-               return contains_tag_algo(commit, filter->with_commit);
-       return is_descendant_of(commit, filter->with_commit);
+               return contains_tag_algo(commit, tmp);
+       return is_descendant_of(commit, tmp);
 }
 
 /*
@@ -1765,13 +1766,17 @@ static int ref_filter_handler(const char *refname, 
const struct object_id *oid,
         * obtain the commit using the 'oid' available and discard all
         * non-commits early. The actual filtering is done later.
         */
-       if (filter->merge_commit || filter->with_commit || filter->verbose) {
+       if (filter->merge_commit || filter->with_commit || filter->no_commit || 
filter->verbose) {
                commit = lookup_commit_reference_gently(oid->hash, 1);
                if (!commit)
                        return 0;
-               /* We perform the filtering for the '--contains' option */
+               /* We perform the filtering for the '--contains' option... */
                if (filter->with_commit &&
-                   !commit_contains(filter, commit))
+                   !commit_contains(filter, commit, 1))
+                       return 0;
+               /* ...or for the `--no-contains' option */
+               if (filter->no_commit &&
+                   commit_contains(filter, commit, 0))
                        return 0;
        }
 
diff --git a/ref-filter.h b/ref-filter.h
index 154e24c405..af85eb4592 100644
--- a/ref-filter.h
+++ b/ref-filter.h
@@ -53,6 +53,7 @@ struct ref_filter {
        const char **name_patterns;
        struct sha1_array points_at;
        struct commit_list *with_commit;
+       struct commit_list *no_commit;
 
        enum {
                REF_FILTER_MERGED_NONE = 0,
diff --git a/t/t3201-branch-contains.sh b/t/t3201-branch-contains.sh
index 7f3ec47241..9fb79e66f0 100755
--- a/t/t3201-branch-contains.sh
+++ b/t/t3201-branch-contains.sh
@@ -1,6 +1,6 @@
 #!/bin/sh
 
-test_description='branch --contains <commit>, --merged, and --no-merged'
+test_description='branch --contains <commit>, --no-contains <commit> --merged, 
and --no-merged'
 
 . ./test-lib.sh
 
@@ -45,6 +45,22 @@ test_expect_success 'branch --contains master' '
 
 '
 
+test_expect_success 'branch --no-contains=master' '
+
+       git branch --no-contains=master >actual &&
+       >expect &&
+       test_cmp expect actual
+
+'
+
+test_expect_success 'branch --no-contains master' '
+
+       git branch --no-contains master >actual &&
+       >expect &&
+       test_cmp expect actual
+
+'
+
 test_expect_success 'branch --contains=side' '
 
        git branch --contains=side >actual &&
@@ -55,6 +71,16 @@ test_expect_success 'branch --contains=side' '
 
 '
 
+test_expect_success 'branch --no-contains=side' '
+
+       git branch --no-contains=side >actual &&
+       {
+               echo "  master"
+       } >expect &&
+       test_cmp expect actual
+
+'
+
 test_expect_success 'branch --contains with pattern implies --list' '
 
        git branch --contains=master master >actual &&
@@ -65,6 +91,14 @@ test_expect_success 'branch --contains with pattern implies 
--list' '
 
 '
 
+test_expect_success 'branch --no-contains with pattern implies --list' '
+
+       git branch --no-contains=master master >actual &&
+       >expect &&
+       test_cmp expect actual
+
+'
+
 test_expect_success 'side: branch --merged' '
 
        git branch --merged >actual &&
@@ -126,7 +160,9 @@ test_expect_success 'branch --no-merged with pattern 
implies --list' '
 test_expect_success 'implicit --list conflicts with modification options' '
 
        test_must_fail git branch --contains=master -d &&
-       test_must_fail git branch --contains=master -m foo
+       test_must_fail git branch --contains=master -m foo &&
+       test_must_fail git branch --no-contains=master -d &&
+       test_must_fail git branch --no-contains=master -m foo
 
 '
 
diff --git a/t/t7004-tag.sh b/t/t7004-tag.sh
index b4698ab5f5..f01bcafea4 100755
--- a/t/t7004-tag.sh
+++ b/t/t7004-tag.sh
@@ -1385,6 +1385,23 @@ test_expect_success 'checking that first commit is in 
all tags (relative)' "
        test_cmp expected actual
 "
 
+# All the --contains tests above, but with --no-contains
+test_expect_success 'checking that first commit is not listed in any tag with 
--no-contains  (hash)' "
+       >expected &&
+       git tag -l --no-contains $hash1 v* >actual &&
+       test_cmp expected actual
+"
+
+test_expect_success 'checking that first commit is in all tags (tag)' "
+       git tag -l --no-contains v1.0 v* >actual &&
+       test_cmp expected actual
+"
+
+test_expect_success 'checking that first commit is in all tags (relative)' "
+       git tag -l --no-contains HEAD~2 v* >actual &&
+       test_cmp expected actual
+"
+
 cat > expected <<EOF
 v2.0
 EOF
@@ -1394,6 +1411,17 @@ test_expect_success 'checking that second commit only 
has one tag' "
        test_cmp expected actual
 "
 
+cat > expected <<EOF
+v0.2.1
+v1.0
+v1.0.1
+v1.1.3
+EOF
+
+test_expect_success 'inverse of the last test, with --no-contains' "
+       git tag -l --no-contains $hash2 v* >actual &&
+       test_cmp expected actual
+"
 
 cat > expected <<EOF
 EOF
@@ -1403,6 +1431,19 @@ test_expect_success 'checking that third commit has no 
tags' "
        test_cmp expected actual
 "
 
+cat > expected <<EOF
+v0.2.1
+v1.0
+v1.0.1
+v1.1.3
+v2.0
+EOF
+
+test_expect_success 'conversely --no-contains on the third commit lists all 
tags' "
+       git tag -l --no-contains $hash3 v* >actual &&
+       test_cmp expected actual
+"
+
 # how about a simple merge?
 
 test_expect_success 'creating simple branch' '
@@ -1424,6 +1465,19 @@ test_expect_success 'checking that branch head only has 
one tag' "
        test_cmp expected actual
 "
 
+cat > expected <<EOF
+v0.2.1
+v1.0
+v1.0.1
+v1.1.3
+v2.0
+EOF
+
+test_expect_success 'checking that branch head with --no-contains lists all 
but one tag' "
+       git tag -l --no-contains $hash4 v* >actual &&
+       test_cmp expected actual
+"
+
 test_expect_success 'merging original branch into this branch' '
        git merge --strategy=ours master &&
         git tag v4.0
@@ -1445,6 +1499,20 @@ v1.0.1
 v1.1.3
 v2.0
 v3.0
+EOF
+
+test_expect_success 'checking that original branch head with --no-contains 
lists all but one tag now' "
+       git tag -l --no-contains $hash3 v* >actual &&
+       test_cmp expected actual
+"
+
+cat > expected <<EOF
+v0.2.1
+v1.0
+v1.0.1
+v1.1.3
+v2.0
+v3.0
 v4.0
 EOF
 
@@ -1453,6 +1521,12 @@ test_expect_success 'checking that initial commit is in 
all tags' "
        test_cmp expected actual
 "
 
+test_expect_success 'checking that initial commit is in all tags with 
--no-contains' "
+       >expected &&
+       git tag -l --no-contains $hash1 v* >actual &&
+       test_cmp expected actual
+"
+
 # mixing modes and options:
 
 test_expect_success 'mixing incompatibles modes and options is forbidden' '
@@ -1708,8 +1782,91 @@ run_with_limited_stack () {
 
 test_lazy_prereq ULIMIT_STACK_SIZE 'run_with_limited_stack true'
 
+# These are all the tags we've created above
+cat >expect.no-contains <<EOF
+a1
+aa1
+annotated-again-v4.0
+annotated-tag
+annotated-v4.0
+blank-annotated-tag
+blank-signed-tag
+blankfile-annotated-tag
+blankfile-signed-tag
+blanknonlfile-annotated-tag
+blanknonlfile-signed-tag
+blanks-annotated-tag
+blanks-signed-tag
+cba
+comment-annotated-tag
+comment-signed-tag
+commentfile-annotated-tag
+commentfile-signed-tag
+commentnonlfile-annotated-tag
+commentnonlfile-signed-tag
+comments-annotated-tag
+comments-signed-tag
+empty-annotated-tag
+empty-signed-tag
+emptyfile-annotated-tag
+emptyfile-signed-tag
+far-far-away
+file-annotated-tag
+file-signed-tag
+foo1.10
+foo1.10-alpha
+foo1.10-beta
+foo1.10-delta
+foo1.10-gamma
+foo1.10-unlisted-suffix
+foo1.3
+foo1.6
+foo1.6-rc1
+foo1.6-rc2
+foo1.7
+foo1.7-after1
+foo1.7-before1
+foo1.8
+foo1.8-foo-bar
+foo1.8-foo-baz
+foo1.9-pre1
+foo1.9-pre2
+foo1.9-prerelease1
+forcesignannotated-annotate
+forcesignannotated-disabled
+forcesignannotated-implied-sign
+forcesignannotated-lightweight
+forged-tag
+implied-annotate
+implied-sign
+non-annotated-tag
+reuse
+signed-tag
+stag-lines
+stag-one-line
+stag-zero-lines
+stdin-annotated-tag
+stdin-signed-tag
+t210
+t211
+tag-from-subdir
+tag-from-subdir-2
+tag-lines
+tag-one-line
+tag-signed-tag
+tag-zero-lines
+u-signed-tag
+v0.2.1
+v1.0
+v1.0.1
+v1.1.3
+v2.0
+v3.0
+v4.0
+EOF
+
 # we require ulimit, this excludes Windows
-test_expect_success ULIMIT_STACK_SIZE '--contains works in a deep repo' '
+test_expect_success ULIMIT_STACK_SIZE '--contains and --no-contains work in a 
deep repo' '
        >expect &&
        i=1 &&
        while test $i -lt 8000
@@ -1725,7 +1882,9 @@ EOF"
        git checkout master &&
        git tag far-far-away HEAD^ &&
        run_with_limited_stack git tag --contains HEAD >actual &&
-       test_cmp expect actual
+       test_cmp expect actual &&
+       run_with_limited_stack git tag --no-contains HEAD >actual &&
+       test_cmp expect.no-contains actual
 '
 
 test_expect_success '--format should list tags as per format given' '
-- 
2.11.0

Reply via email to