]> git.cworth.org Git - notmuch/commitdiff
cli: start remote helper for git.
authorDavid Bremner <david@tethera.net>
Sat, 17 Aug 2024 15:40:40 +0000 (12:40 -0300)
committerDavid Bremner <david@tethera.net>
Mon, 11 Aug 2025 12:52:10 +0000 (09:52 -0300)
This is closely based on git-remote-nm (in ruby) by Felipe Contreras.
Initially just implement the commands 'capabilites' and 'list'.  This
isn't enough to do anything useful so start some unit tests. Testing
of URL passing will be done after clone (import command) support is
added.

.gitignore
Makefile.local
git-remote-notmuch.c [new file with mode: 0644]
test/T860-git-remote.sh [new file with mode: 0755]
test/make-export.py [new file with mode: 0644]

index eda6d9cff7473613aca1e36d54fb4dd5e8872a2c..a9f4366570c6ae0cb74342ef722d6e538db065d4 100644 (file)
@@ -8,6 +8,8 @@
 /.stamps
 /Makefile.config
 /bindings/python-cffi/build/
+/git-remote-notmuch
+/git-remote-notmuch-shared
 /lib/libnotmuch*.dylib
 /lib/libnotmuch.so*
 /nmbug
index 2cc9bd29a2f40c14b3e1a8973d6969b20a6ddfac..2eb0ead82b21b087bfbc25d8738cd96ee1886fbc 100644 (file)
@@ -1,7 +1,8 @@
 # -*- makefile-gmake -*-
 
 .PHONY: all
-all: notmuch notmuch-shared build-man build-info ruby-bindings python-cffi-bindings notmuch-git nmbug
+all: notmuch notmuch-shared git-remote-notmuch git-remote-notmuch-shared \
+       build-man build-info ruby-bindings python-cffi-bindings notmuch-git nmbug
 ifeq ($(MAKECMDGOALS),)
 ifeq ($(shell cat .first-build-message 2>/dev/null),)
        @NOTMUCH_FIRST_BUILD=1 $(MAKE) --no-print-directory all
@@ -272,10 +273,17 @@ notmuch: $(notmuch_client_modules) lib/libnotmuch.a util/libnotmuch_util.a parse
 notmuch-shared: $(notmuch_client_modules) lib/$(LINKER_NAME)
        $(call quiet,$(FINAL_NOTMUCH_LINKER) $(CFLAGS)) $(notmuch_client_modules) $(FINAL_NOTMUCH_LDFLAGS) -o $@
 
+git-remote-notmuch: git-remote-notmuch.o status.o tag-util.o query-string.o lib/libnotmuch.a util/libnotmuch_util.a parse-time-string/libparse-time-string.a
+       $(call quiet,CXX $(CFLAGS)) $^ $(FINAL_LIBNOTMUCH_LDFLAGS) -o $@
+
+git-remote-notmuch-shared: git-remote-notmuch.o status.o tag-util.o query-string.o
+       $(call quiet,$(FINAL_NOTMUCH_LINKER) $(CFLAGS)) $^ $(FINAL_NOTMUCH_LDFLAGS) -o $@
+
 .PHONY: install
 install: all install-man install-info
        mkdir -p "$(DESTDIR)$(prefix)/bin/"
        install notmuch-shared "$(DESTDIR)$(prefix)/bin/notmuch"
+       install git-remote-notmuch-shared "$(DESTDIR)$(prefix)/bin/git-remote-notmuch"
 ifeq ($(MAKECMDGOALS), install)
        @echo ""
        @echo "Notmuch is now installed to $(DESTDIR)$(prefix)"
@@ -300,6 +308,7 @@ endif
 
 SRCS  := $(SRCS) $(notmuch_client_srcs)
 CLEAN := $(CLEAN) notmuch notmuch-shared $(notmuch_client_modules)
+CLEAN := $(CLEAN) git-remote-notmuch git-remote-notmuch.o
 CLEAN := $(CLEAN) version.stamp notmuch-*.tar.gz.tmp
 CLEAN := $(CLEAN) .deps
 
diff --git a/git-remote-notmuch.c b/git-remote-notmuch.c
new file mode 100644 (file)
index 0000000..fb5cfd5
--- /dev/null
@@ -0,0 +1,315 @@
+/* notmuch - Not much of an email program, (just index and search)
+ *
+ * Copyright © 2023 Felipe Contreras
+ * Copyright © 2024 David Bremner
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see https://www.gnu.org/licenses/ .
+ *
+ * Authors: David Bremner <david@tethera.net>
+ *         Felipe Contreras (prototype in ruby)
+ */
+
+#include <assert.h>
+#include <stdlib.h>
+#include <stdio.h>
+#include <string.h>
+#include <notmuch.h>
+#include "notmuch-client.h"
+#include "path-util.h"
+#include "hex-escape.h"
+#include "string-util.h"
+#include "tag-util.h"
+
+#define ASSERT(x) assert ((x))
+
+/* File scope globals */
+const char *debug_flags = NULL;
+FILE *log_file = NULL;
+
+/* For use with getline. */
+char *buffer = NULL;
+size_t buffer_len = 0;
+
+static inline bool
+equal_lastmod (const char *uuid1, unsigned long counter1,
+              const char *uuid2, unsigned long counter2)
+{
+    return (strcmp_null (uuid1, uuid2) == 0) && (counter1 == counter2);
+}
+
+/* Error handling */
+static void
+ensure (bool condition, const char *format, ...)
+{
+    va_list va_args;
+
+    if (! condition) {
+       va_start (va_args, format);
+       vfprintf (stderr, format, va_args);
+       va_end (va_args);
+       fprintf (stderr, "\n");
+       exit (EXIT_FAILURE);
+    }
+}
+
+/* It is a (protocol) error to call this at/after EOF */
+static void
+buffer_line (FILE *stream)
+{
+    ssize_t nread;
+
+    nread = getline (&buffer, &buffer_len, stream);
+    ensure (nread >= 0, "getline %s", strerror (errno));
+    chomp_newline (buffer);
+}
+
+static GStrv
+tokenize_buffer ()
+{
+    char *tok = buffer;
+    size_t tok_len = 0;
+
+    g_autoptr (GStrvBuilder) builder = g_strv_builder_new ();
+
+    while ((tok = strtok_len (tok + tok_len, " \t\n", &tok_len))) {
+       g_autofree char *null_terminated = g_strndup (tok, tok_len);
+       g_strv_builder_add (builder, null_terminated);
+    }
+
+    return g_strv_builder_end (builder);
+}
+
+static void
+flog (const char *format, ...)
+{
+    va_list va_args;
+
+    if (log_file) {
+       va_start (va_args, format);
+       vfprintf (log_file, format, va_args);
+       fflush (log_file);
+       va_end (va_args);
+    }
+}
+
+static const char *
+gmessage (GError *err)
+{
+    if (err)
+       return err->message;
+    else
+       return NULL;
+}
+
+static void
+str2ul (const char *str, unsigned long int *num_p)
+{
+    gboolean ret;
+
+    g_autoptr (GError) gerror = NULL;
+
+    ret = g_ascii_string_to_unsigned (str, 10, 0, G_MAXUINT64, num_p, &gerror);
+    ensure (ret, "converting %s to unsigned long: %s", str, gmessage (gerror));
+}
+
+static void
+read_lastmod (const char *dir, char **uuid_out, unsigned long *counter_out)
+{
+    g_autoptr (GString) filename = g_string_new (dir);
+    unsigned long num = 0;
+    FILE *in;
+
+    assert (uuid_out);
+    assert (counter_out);
+
+    g_string_append (filename, "/lastmod");
+
+    in = fopen (filename->str, "r");
+    if (! in) {
+       ensure (errno == ENOENT, "error opening lastmod file");
+       *uuid_out = NULL;
+       *counter_out = 0;
+    } else {
+       g_auto (GStrv) tokens = NULL;
+       buffer_line (in);
+
+       tokens = tokenize_buffer ();
+
+       *uuid_out = tokens[0];
+       str2ul (tokens[1], &num);
+
+       flog ("loaded uuid = %s\tlastmod = %zu\n", tokens[0], num);
+    }
+
+    *counter_out = num;
+
+}
+
+static void
+cmd_capabilities ()
+{
+    fputs ("import\nexport\nrefspec refs/heads/*:refs/notmuch/*\n\n", stdout);
+    fflush (stdout);
+}
+
+static void
+cmd_list (notmuch_database_t *db, const char *uuid, unsigned long lastmod)
+{
+    unsigned long db_lastmod;
+    const char *db_uuid;
+
+    db_lastmod = notmuch_database_get_revision (db, &db_uuid);
+
+    printf ("? refs/heads/master%s\n\n",
+           equal_lastmod (uuid, lastmod, db_uuid, db_lastmod) ? " unchanged" : "");
+}
+
+/* stubs since we cannot link with notmuch.o */
+const notmuch_opt_desc_t notmuch_shared_options[] = {
+    { }
+};
+
+const char *notmuch_requested_db_uuid = NULL;
+
+void
+notmuch_process_shared_options (unused (notmuch_database_t *notmuch),
+                               unused (const char *dummy))
+{
+}
+
+int
+notmuch_minimal_options (unused (const char *subcommand),
+                        unused (int argc),
+                        unused (char **argv))
+{
+    return 0;
+}
+
+static notmuch_database_t *
+open_database (const char *arg)
+{
+    notmuch_status_t status;
+    notmuch_database_t *notmuch;
+    const char *path = NULL;
+    const char *config = NULL;
+    const char *profile = NULL;
+    const char *scheme = NULL;
+    const char *uriquery = NULL;
+    g_autofree char *status_string = NULL;
+
+    g_autoptr (GUri) uri = NULL;
+    g_autoptr (GHashTable) params = NULL;
+    g_autoptr (GError) gerror = NULL;
+    g_autoptr (GString) address = NULL;
+
+    address = g_string_new (arg);
+
+    scheme = g_uri_peek_scheme (address->str);
+    if (! scheme || (strcmp (scheme, "notmuch") != 0)) {
+       ASSERT (g_string_prepend (address, "notmuch://"));
+    }
+
+    uri = g_uri_parse (address->str, G_URI_FLAGS_ENCODED_QUERY, &gerror);
+    ensure (uri, "unable to parse URL/address %s: %s\n", address->str, gmessage (gerror));
+
+    uriquery = g_uri_get_query (uri);
+    if (uriquery) {
+       flog ("uriquery = %s\n", uriquery);
+       params = g_uri_parse_params (uriquery, -1, "&", G_URI_PARAMS_NONE, &gerror);
+       ensure (params,  "unable to parse parameters %s: %s\n", uriquery, gmessage (gerror));
+    }
+
+    if (strlen (g_uri_get_path (uri)) > 0) {
+       path = g_uri_get_path (uri);
+       config = "";
+    }
+
+    if (params) {
+       if (! path)
+           path = g_hash_table_lookup (params, "path");
+       config = g_hash_table_lookup (params, "config");
+       profile = g_hash_table_lookup (params, "profile");
+    }
+
+    flog ("url = %s\npath = %s\nconfig = %s\nprofile = %s\n",
+         address->str, path, config, profile);
+
+    status = notmuch_database_open_with_config (path,
+                                               NOTMUCH_DATABASE_MODE_READ_WRITE,
+                                               config,
+                                               profile,
+                                               &notmuch,
+                                               &status_string);
+
+    ensure (status == 0, "open database: %s", status_string);
+
+    return notmuch;
+}
+
+int
+main (int argc, char *argv[])
+{
+    notmuch_status_t status;
+    notmuch_database_t *db;
+    unsigned long lastmod = 0;
+    char *uuid = NULL;
+    const char *nm_dir = NULL;
+    g_autofree char *status_string = NULL;
+    const char *git_dir;
+    ssize_t nread;
+    const char *log_file_name;
+
+    debug_flags = getenv ("GIT_REMOTE_NM_DEBUG");
+    log_file_name = getenv ("GIT_REMOTE_NM_LOG");
+
+    if (log_file_name)
+       log_file = fopen (log_file_name, "w");
+
+    ensure (argc >= 3, "usage: %s ALIAS URL\n", argv[0]);
+
+    db = open_database (argv[2]);
+
+    git_dir = getenv ("GIT_DIR");
+    ensure (git_dir, "GIT_DIR not set");
+    flog ("GIT_DIR=%s\n", git_dir);
+
+    ASSERT (nm_dir = talloc_asprintf (db, "%s/%s", git_dir, "notmuch"));
+
+    status = mkdir_recursive (db, nm_dir, 0700, &status_string);
+    ensure (status == 0, "mkdir: %s", status_string);
+
+    read_lastmod (nm_dir, &uuid, &lastmod);
+
+    while ((nread = getline (&buffer, &buffer_len, stdin)) != -1) {
+       char *s = buffer;
+       flog ("command = %s\n", buffer);
+
+       /* skip leading space */
+       while (*s && isspace (*s)) s++;
+
+       if (! *s)
+           break;
+
+       if (STRNCMP_LITERAL (s, "capabilities") == 0)
+           cmd_capabilities ();
+       else if (STRNCMP_LITERAL (s, "list") == 0)
+           cmd_list (db, uuid, lastmod);
+
+       fflush (stdout);
+       flog ("finished command = %s\n", buffer);
+    }
+    flog ("finished loop\n");
+
+    notmuch_database_destroy (db);
+}
diff --git a/test/T860-git-remote.sh b/test/T860-git-remote.sh
new file mode 100755 (executable)
index 0000000..76ba792
--- /dev/null
@@ -0,0 +1,60 @@
+#!/usr/bin/env bash
+test_description='git-remote-notmuch'
+. $(dirname "$0")/test-lib.sh || exit 1
+
+notmuch_sanitize_git() {
+    sed 's/^committer \(.*\) \(<[^>]*>\) [1-9][0-9]* [-+][0-9]*/committer \1 \2 TIMESTAMP TIMEZONE/'
+}
+
+add_email_corpus
+
+mkdir repo
+
+git_tmp=$(mktemp -d gitXXXXXXXX)
+
+run_helper () {
+    env -u NOTMUCH_CONFIG GIT_DIR=${git_tmp} git-remote-notmuch dummy-alias "?config=${NOTMUCH_CONFIG}"
+}
+
+backup_state () {
+    backup_database
+    rm -rf repo.bak
+    cp -a repo repo.bak
+}
+
+restore_state () {
+    restore_database
+    rm -rf repo
+    mv repo.bak repo
+}
+
+export GIT_AUTHOR_NAME="Notmuch Test Suite"
+export GIT_AUTHOR_EMAIL="notmuch@example.com"
+export GIT_COMMITTER_NAME="Notmuch Test Suite"
+export GIT_COMMITTER_EMAIL="notmuch@example.com"
+export GIT_REMOTE_NM_DEBUG="s"
+export GIT_REMOTE_NM_LOG=grn-log.txt
+EXPECTED=$NOTMUCH_SRCDIR/test/git-remote.expected-output
+MAKE_EXPORT_PY=$NOTMUCH_SRCDIR/test/make-export.py
+
+TAG_FILE="_notmuch_metadata/87/b1/4EFC743A.3060609@april.org/tags"
+
+test_begin_subtest 'capabilities'
+echo capabilities | run_helper > OUTPUT
+cat <<EOF > EXPECTED
+import
+export
+refspec refs/heads/*:refs/notmuch/*
+
+EOF
+test_expect_equal_file EXPECTED OUTPUT
+
+test_begin_subtest 'list'
+echo list | run_helper > OUTPUT
+cat <<EOF > EXPECTED
+? refs/heads/master
+
+EOF
+test_expect_equal_file EXPECTED OUTPUT
+
+test_done
diff --git a/test/make-export.py b/test/make-export.py
new file mode 100644 (file)
index 0000000..3837dc3
--- /dev/null
@@ -0,0 +1,44 @@
+# generate a test input for the 'export' subcommand of the
+# git-remote-notmuch helper
+
+from notmuch2 import Database
+from time import time
+from hashlib import sha1
+
+def hexencode(str):
+    output_charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+-_@=.,"
+    out = ""
+    for char in str:
+        if not char in output_charset:
+            out+= f"%{ord(char):x}"
+        else:
+            out+= char
+    return out
+
+db = Database(config=Database.CONFIG.SEARCH)
+
+count=1
+print("export")
+mark={}
+
+for msg in db.messages(""):
+    mark[msg.messageid]=count
+    blob=""
+    for tag in msg.tags:
+        blob += f"{tag}\n"
+    print (f"blob\nmark :{count}");
+    print (f"data {len(blob)}\n{blob}")
+    count=count+1
+
+print (f"\ncommit refs/heads/master\nmark :{count+1}")
+ctime = int(time())
+print (f"author Notmuch Test Suite <notmuch@example.com> {ctime} +0000")
+print (f"committer Notmuch Test Suite <notmuch@example.com> {ctime} +0000")
+print (f"data 8\nignored")
+
+for msg in db.messages(""):
+    digest = sha1(msg.messageid.encode('utf8')).hexdigest()
+    filename = hexencode(msg.messageid)
+    print (f"M 100644 :{mark[msg.messageid]} {digest[0:2]}/{digest[2:4]}/{filename}/tags")
+
+print("\ndone\n")