From: Léo Gaspard <l...@gaspard.io>

The tweak-fetch hook is fed lines on stdin for all refs that were
fetched, and outputs on stdout possibly modified lines. Its output is
then parsed and used when `git fetch` updates the remote tracking refs,
records the entries in FETCH_HEAD, and produces its report.

The modifications here are heavily based on prior work by Joey Hess.

Based-on-patch-by: Joey Hess <j...@kitenet.net>
Signed-off-by: Leo Gaspard <l...@gaspard.io>
---
 Documentation/githooks.txt          |  37 +++++++
 builtin/fetch.c                     | 210 +++++++++++++++++++++++++++++++++++-
 t/t5574-fetch-tweak-fetch-hook.sh   |  90 ++++++++++++++++
 templates/hooks--tweak-fetch.sample |  24 +++++
 4 files changed, 359 insertions(+), 2 deletions(-)
 create mode 100755 t/t5574-fetch-tweak-fetch-hook.sh
 create mode 100755 templates/hooks--tweak-fetch.sample

diff --git a/Documentation/githooks.txt b/Documentation/githooks.txt
index f877f7b7c..1b4a18bf0 100644
--- a/Documentation/githooks.txt
+++ b/Documentation/githooks.txt
@@ -177,6 +177,43 @@ This hook can be used to perform repository validity 
checks, auto-display
 differences from the previous HEAD if different, or set working dir metadata
 properties.
 
+tweak-fetch
+~~~~~~~~~~~
+
+This hook is invoked by 'git fetch' (commonly called by 'git pull'), after refs
+have been fetched from the remote repository. It is not executed, if nothing 
was
+fetched.
+
+The output of the hook is used to update the remote-tracking branches, and
+`.git/FETCH_HEAD`, in preparation for a later merge operation done by 'git
+merge'.
+
+It takes no arguments, but is fed a line of the following format on its 
standard
+input for each ref that was fetched.
+
+  <sha1> SP not-for-merge|merge|ignore SP <remote-refname> SP <local-refname> 
LF
+
+Where the "not-for-merge" flag indicates the ref is not to be merged into the
+current branch, and the "merge" flag indicates that 'git merge' should later
+merge it.
+
+The `<remote-refname>` is the remote's name for the ref that was fetched, and
+`<local-refname>` is a name of a remote-tracking branch, like
+"refs/remotes/origin/master". `<local-refname>` can be undefined if the fetched
+ref is not being stored in a local refname. In this case, it will be set to 
`@`,
+an invalide refspec, so that scripts can be written more easily.
+
+TODO: Add documentation for the “ignore” parameter. Unfortunately, I'm not
+really sure I get what this does or what invariants it is supposed to maintain
+(eg. all “ignore” updates at the end of the refs list?), so this may also
+require code changes.
+
+The hook must consume all of its standard input, and output back lines of the
+same format. It can modify its input as desired, including adding or removing
+lines, updating the sha1 (i.e. re-point the remote-tracking branch), changing
+the merge flag, and changing the `<local-refname>` (i.e. use different
+remote-tracking branch).
+
 post-merge
 ~~~~~~~~~~
 
diff --git a/builtin/fetch.c b/builtin/fetch.c
index 76dc05f61..1bb394530 100644
--- a/builtin/fetch.c
+++ b/builtin/fetch.c
@@ -28,6 +28,8 @@ static const char * const builtin_fetch_usage[] = {
        NULL
 };
 
+static const char tweak_fetch_hook[] = "tweak-fetch";
+
 enum {
        TAGS_UNSET = 0,
        TAGS_DEFAULT = 1,
@@ -181,6 +183,206 @@ static struct option builtin_fetch_options[] = {
        OPT_END()
 };
 
+static int feed_tweak_fetch_hook(int in, int out, void *data)
+{
+       struct ref *ref;
+       struct strbuf buf = STRBUF_INIT;
+       const char *kw, *peer_ref;
+       char oid_buf[GIT_SHA1_HEXSZ + 1];
+       int ret;
+
+       for (ref = data; ref; ref = ref->next) {
+               if (ref->fetch_head_status == FETCH_HEAD_MERGE)
+                       kw = "merge";
+               else if (ref->fetch_head_status == FETCH_HEAD_IGNORE)
+                       kw = "ignore";
+               else
+                       kw = "not-for-merge";
+               if (!ref->name)
+                       die("trying to fetch an inexistant ref");
+               if (ref->peer_ref && ref->peer_ref->name)
+                       peer_ref = ref->peer_ref->name;
+               else
+                       peer_ref = "@";
+               strbuf_addf(&buf, "%s %s %s %s\n",
+                               oid_to_hex_r(oid_buf, &ref->old_oid), kw,
+                               ref->name, peer_ref);
+       }
+
+       ret = write_in_full(out, buf.buf, buf.len) != buf.len;
+       if (ret)
+               warning("%s hook failed to consume all its input",
+                               tweak_fetch_hook);
+       close(out);
+       strbuf_release(&buf);
+       return ret;
+}
+
+static struct ref *parse_tweak_fetch_hook_line(char *l,
+               struct string_list *existing_refs)
+{
+       struct ref *ref = NULL, *peer_ref = NULL;
+       struct string_list_item *peer_item = NULL;
+       char *words[4];
+       int i, word = 0;
+       char *problem;
+
+       for (i = 0; l[i]; i++) {
+               if (isspace(l[i])) {
+                       l[i] = '\0';
+                       words[word] = l;
+                       l += i + 1;
+                       i = 0;
+                       word++;
+                       if (word > 3) {
+                               problem = "too many words";
+                               goto unparsable;
+                       }
+               }
+       }
+       if (word < 3) {
+               problem = "not enough words";
+               goto unparsable;
+       }
+
+       ref = alloc_ref(words[2]);
+       peer_ref = ref->peer_ref = alloc_ref(l);
+       ref->peer_ref->force = 1;
+
+       if (get_oid_hex(words[0], &ref->old_oid)) {
+               problem="bad oid";
+               goto unparsable;
+       }
+
+       if (!strcmp(words[1], "merge")) {
+               ref->fetch_head_status = FETCH_HEAD_MERGE;
+       } else if (!strcmp(words[1], "ignore")) {
+               ref->fetch_head_status = FETCH_HEAD_IGNORE;
+       } else if (!strcmp(words[1], "not-for-merge")) {
+               ref->fetch_head_status = FETCH_HEAD_NOT_FOR_MERGE;
+       } else {
+               problem = "bad merge flag";
+               goto unparsable;
+       }
+
+       peer_item = string_list_lookup(existing_refs, peer_ref->name);
+       if (peer_item)
+               hashcpy(peer_ref->old_oid.hash, peer_item->util);
+
+       return ref;
+
+unparsable:
+       warning("%s hook output a wrongly formed line: %s",
+                       tweak_fetch_hook, problem);
+       free(ref);
+       free(peer_ref);
+       return NULL;
+}
+
+static struct refs_result read_tweak_fetch_hook(int in)
+{
+       struct refs_result res;
+       FILE *f;
+       struct strbuf buf;
+       struct string_list existing_refs = STRING_LIST_INIT_DUP;
+       struct ref *ref, *prevref = NULL;
+
+       res.status = 0;
+       res.new_refs = NULL;
+
+       f = fdopen(in, "r");
+       if (f == NULL) {
+               res.status = 1;
+               return res;
+       }
+
+       strbuf_init(&buf, 128);
+       for_each_ref(add_existing, &existing_refs);
+
+       while (strbuf_getline(&buf, f) != EOF) {
+               char *l = strbuf_detach(&buf, NULL);
+               ref = parse_tweak_fetch_hook_line(l, &existing_refs);
+               if (!ref) {
+                       res.status = 1;
+               } else {
+                       if (prevref) {
+                               prevref->next = ref;
+                               prevref = ref;
+                       } else {
+                               res.new_refs = prevref = ref;
+                       }
+               }
+               free(l);
+       }
+
+       string_list_clear(&existing_refs, 0);
+       strbuf_release(&buf);
+       fclose(f);
+       return res;
+}
+
+/*
+ * The hook is fed lines of the form:
+ * <sha1> SP <not-for-merge|merge|ignore> SP <remote-refname> SP 
<local-refname> LF
+ * And should output rewritten lines of the same form.
+ */
+static struct ref *run_tweak_fetch_hook(struct ref *fetched_refs)
+{
+       struct child_process hook;
+       const char *argv[2];
+       struct async async;
+       struct refs_result res;
+
+       if (!fetched_refs)
+               return fetched_refs;
+
+       argv[0] = find_hook(tweak_fetch_hook);
+       if (access(argv[0], X_OK) < 0)
+               return fetched_refs;
+       argv[1] = NULL;
+
+       memset(&hook, 0, sizeof(hook));
+       hook.argv = argv;
+       hook.in = -1;
+       hook.out = -1;
+       if (start_command(&hook))
+               return fetched_refs;
+
+       /*
+        * Use an async writer to feed the hook process.
+        * This allows the hook to read and write a line at
+        * a time without blocking.
+        */
+       memset(&async, 0, sizeof(async));
+       async.proc = feed_tweak_fetch_hook;
+       async.data = fetched_refs;
+       async.out = hook.in;
+       if (start_async(&async)) {
+               close(hook.in);
+               close(hook.out);
+               finish_command(&hook);
+               return fetched_refs;
+       }
+
+       res = read_tweak_fetch_hook(hook.out);
+       res.status |= finish_async(&async);
+       res.status |= finish_command(&hook);
+
+       if (res.status) {
+               warning("%s hook failed, ignoring its output", 
tweak_fetch_hook);
+               free(res.new_refs);
+               return fetched_refs;
+       } else {
+               /*
+                * The new_refs are returned, to be used in place of
+                * fetched_refs, so it is not needed anymore and can
+                * be freed here.
+                */
+               free_refs(fetched_refs);
+               return res.new_refs;
+       }
+}
+
 static void unlock_pack(void)
 {
        if (gtransport)
@@ -934,7 +1136,7 @@ static struct refs_result fetch_refs(struct transport 
*transport,
                ret.status = transport_fetch_refs(transport, ref_map);
        }
        if (!ret.status) {
-               ret.new_refs = ref_map;
+               ret.new_refs = run_tweak_fetch_hook(ref_map);
                ret.status |= store_updated_refs(transport->url,
                                transport->remote->name,
                                ret.new_refs);
@@ -1150,7 +1352,11 @@ static int do_fetch(struct transport *transport,
                                   transport->url);
                }
        }
-
+       // TODO(?): Were this placed above the `if (prune)`, it would avoid the
+       // unfortunate fact that `git fetch --prune` first drops the ref then
+       // re-adds it (in cases where the tweak-fetch hook renames it). There is
+       // likely a better solution than this one that would break Commit
+       // 10a6cc889 ("fetch --prune: Run prune before fetching", 2014-01-02)
        res = fetch_refs(transport, ref_map);
        ref_map = res.new_refs;
        if (res.status) {
diff --git a/t/t5574-fetch-tweak-fetch-hook.sh 
b/t/t5574-fetch-tweak-fetch-hook.sh
new file mode 100755
index 000000000..17cf52684
--- /dev/null
+++ b/t/t5574-fetch-tweak-fetch-hook.sh
@@ -0,0 +1,90 @@
+#!/bin/sh
+
+test_description='testing tweak-fetch-hook'
+. ./test-lib.sh
+
+HOOKDIR="$(git rev-parse --git-dir)/hooks"
+HOOK="$HOOKDIR/tweak-fetch"
+mkdir -p "$HOOKDIR"
+
+# Setup
+test_expect_success 'setup' '
+       git init parent-repo &&
+       git remote add parent parent-repo &&
+       (cd parent-repo && test_commit commit-100) &&
+       git fetch parent &&
+       git tag | grep -E "^commit-100$"
+'
+
+# No-effect hook
+write_script "$HOOK" <<EOF
+cat
+EOF
+test_expect_success 'no-op hook' '
+       (cd parent-repo && test_commit commit-200) &&
+       git fetch parent &&
+       git tag | grep -E "^commit-200$"
+'
+
+# Ref-renaming hook
+write_script "$HOOK" <<EOF
+sed 's/commit-/tag-/g'
+EOF
+test_expect_success 'ref-renaming hook' '
+       (cd parent-repo && test_commit commit-300) &&
+       git fetch parent &&
+       git tag | grep -E "^tag-300" &&
+       ! git tag | grep -E "^commit-300"
+'
+
+# Drop branch
+write_script "$HOOK" <<EOF
+cat
+EOF
+test_expect_success 'dropping hook setup' '
+       (cd parent-repo && test_commit commit-400) &&
+       git fetch parent &&
+       test "$(git rev-parse parent/master)" = "$(git rev-parse commit-400)"
+'
+write_script "$HOOK" <<EOF
+grep -v 'refs/remotes/parent/master'
+exit 0
+EOF
+test_expect_success 'dropping hook' '
+       (cd parent-repo && test_commit commit-401) &&
+       git fetch parent &&
+       test "$(git rev-parse parent/master)" = "$(git rev-parse commit-400)" &&
+       chmod -x "'"$HOOK"'" &&
+       git fetch parent &&
+       test "$(git rev-parse parent/master)" = "$(git rev-parse commit-401)"
+'
+
+# Repointing hook
+write_script "$HOOK" <<EOF
+cat
+EOF
+test_expect_success 'repointing hook setup' '
+       (cd parent-repo && test_commit commit-500) &&
+       git fetch parent
+'
+write_script "$HOOK" <<'EOF'
+while read hash merge remote_ref local_ref; do
+       if [ "$local_ref" = "refs/remotes/parent/master" ]; then
+               repointed="$(git rev-parse "$hash^")"
+               echo "$repointed $merge $remote_ref $local_ref"
+       else
+               echo "$hash $merge $remote_ref $local_ref"
+       fi
+done
+exit 0
+EOF
+test_expect_success 'repointing hook' '
+       (cd parent-repo && test_commit commit-501 && test_commit commit-502) &&
+       git fetch parent &&
+       test "$(git rev-parse parent/master)" = "$(git rev-parse commit-501)" &&
+       (cd parent-repo && test_commit commit-503) &&
+       git fetch parent &&
+       test "$(git rev-parse parent/master)" = "$(git rev-parse commit-502)"
+'
+
+test_done
diff --git a/templates/hooks--tweak-fetch.sample 
b/templates/hooks--tweak-fetch.sample
new file mode 100755
index 000000000..93b86ad2f
--- /dev/null
+++ b/templates/hooks--tweak-fetch.sample
@@ -0,0 +1,24 @@
+#!/bin/sh
+#
+# Copyright (c) 2018 Leo Gaspard
+#
+# The "tweak-fetch" hook is run during the fetching process. It is called with
+# no parameters. Its communication protocol is reading fetched references on
+# stdin, and outputting references to update on stdout, with the same protocol
+# described in `git help hooks`.
+#
+# This sample shows how to refuse fetching any unsigned commit.
+
+while read hash merge remote_ref local_ref; do
+    allowed_commit="$(git rev-parse "$local_ref")"
+    git rev-list "$local_ref..$hash" | tac | while read commit; do
+        if git verify-commit "$commit" > /dev/null 2>&1; then
+            allowed_commit="$commit"
+        else
+            echo "Commit '$commit' is not signed! Refusing to fetch past it" 
>&2
+            break
+        fi
+    done
+    echo "$allowed_commit $merge $remote_ref $local_ref"
+done
+# TODO: actually verify this hook works
-- 
2.16.1

Reply via email to