]> Savannah Git Hosting - gnulib.git/commitdiff
vc-mtime: Add API for more efficient use of git.
authorBruno Haible <bruno@clisp.org>
Thu, 1 May 2025 15:26:27 +0000 (17:26 +0200)
committerBruno Haible <bruno@clisp.org>
Thu, 1 May 2025 15:28:27 +0000 (17:28 +0200)
Reported by Serhii Tereshchenko, Arthur, Adam YS, Foucauld Degeorges
at <https://savannah.gnu.org/bugs/?66865>.

* lib/vc-mtime.h (max_vc_mtime): New declaration.
* lib/vc-mtime.c: Include <errno.h>, <stdio.h>, <string.h>, filename.h,
xalloc.h, xgetcwd.h, xvasprintf.h, gl_map.h, gl_xmap.h, gl_hash_map.h,
hashkey-string.h, unlocked-io.h.
(is_git_present): New function, extracted from vc_mtime.
(vc_mtime): Invoke it.
(MAX_COMMAND_LENGTH, MAX_CMD_LEN): New macros.
(abs_git_checkout): New function, based on execute_and_read_line in
lib/javacomp.c.
(ancestor_level, relativize): New functions.
(struct accumulator): New type.
(accumulate): New function.
(max_vc_mtime): New function.
(test_ancestor_level, test_relativize, main) [TEST]: New functions.
* modules/vc-mtime (Depends-on): Add filename, xalloc, xgetcwd,
canonicalize-lgpl, xvasprintf, str_startswith, map, xmap, hash-map,
hashkey-string, getdelim.

ChangeLog
lib/vc-mtime.c
lib/vc-mtime.h
modules/vc-mtime

index 6026666ac07bb1c88bf814cb01e604d92e3b143c..866b0a4d656a72e90cec563b5bb8706a6e09f967 100644 (file)
--- a/ChangeLog
+++ b/ChangeLog
@@ -1,3 +1,26 @@
+2025-05-01  Bruno Haible  <bruno@clisp.org>
+
+       vc-mtime: Add API for more efficient use of git.
+       Reported by Serhii Tereshchenko, Arthur, Adam YS, Foucauld Degeorges
+       at <https://savannah.gnu.org/bugs/?66865>.
+       * lib/vc-mtime.h (max_vc_mtime): New declaration.
+       * lib/vc-mtime.c: Include <errno.h>, <stdio.h>, <string.h>, filename.h,
+       xalloc.h, xgetcwd.h, xvasprintf.h, gl_map.h, gl_xmap.h, gl_hash_map.h,
+       hashkey-string.h, unlocked-io.h.
+       (is_git_present): New function, extracted from vc_mtime.
+       (vc_mtime): Invoke it.
+       (MAX_COMMAND_LENGTH, MAX_CMD_LEN): New macros.
+       (abs_git_checkout): New function, based on execute_and_read_line in
+       lib/javacomp.c.
+       (ancestor_level, relativize): New functions.
+       (struct accumulator): New type.
+       (accumulate): New function.
+       (max_vc_mtime): New function.
+       (test_ancestor_level, test_relativize, main) [TEST]: New functions.
+       * modules/vc-mtime (Depends-on): Add filename, xalloc, xgetcwd,
+       canonicalize-lgpl, xvasprintf, str_startswith, map, xmap, hash-map,
+       hashkey-string, getdelim.
+
 2025-05-01  Bruno Haible  <bruno@clisp.org>
 
        javacomp: Fix resource leak.
index f80bd77b690368e4f78ca47042da9ba2f43d4a28..71fed793c8b7dbaf4ab342b9350a8db580b96c88 100644 (file)
 /* Specification.  */
 #include "vc-mtime.h"
 
+#include <errno.h>
 #include <stddef.h>
+#include <stdio.h>
 #include <stdlib.h>
+#include <string.h>
 #include <unistd.h>
 
 #include <error.h>
 #include "safe-read.h"
 #include "xstrtol.h"
 #include "stat-time.h"
+#include "filename.h"
+#include "xalloc.h"
+#include "xgetcwd.h"
+#include "xvasprintf.h"
+#include "gl_map.h"
+#include "gl_xmap.h"
+#include "gl_hash_map.h"
+#include "hashkey-string.h"
+#if USE_UNLOCKED_IO
+# include "unlocked-io.h"
+#endif
 #include "gettext.h"
 
 #define _(msgid) dgettext ("gnulib", msgid)
 
 
+/* ========================================================================== */
+
+/* Determines whether git is present.  */
+static bool
+is_git_present (void)
+{
+  static bool git_tested;
+  static bool git_present;
+
+  if (!git_tested)
+    {
+      /* Test for presence of git:
+         "git --version >/dev/null 2>/dev/null"  */
+      const char *argv[3];
+      int exitstatus;
+
+      argv[0] = "git";
+      argv[1] = "--version";
+      argv[2] = NULL;
+      exitstatus = execute ("git", "git", argv, NULL, NULL,
+                            false, false, true, true,
+                            true, false, NULL);
+      git_present = (exitstatus == 0);
+      git_tested = true;
+    }
+
+  return git_present;
+}
+
 /* Determines whether the specified file is under version control.  */
 static bool
 git_vc_controlled (const char *filename)
@@ -180,27 +223,7 @@ git_mtime (struct timespec *mtime, const char *filename)
 int
 vc_mtime (struct timespec *mtime, const char *filename)
 {
-  static bool git_tested;
-  static bool git_present;
-
-  if (!git_tested)
-    {
-      /* Test for presence of git:
-         "git --version >/dev/null 2>/dev/null"  */
-      const char *argv[3];
-      int exitstatus;
-
-      argv[0] = "git";
-      argv[1] = "--version";
-      argv[2] = NULL;
-      exitstatus = execute ("git", "git", argv, NULL, NULL,
-                            false, false, true, true,
-                            true, false, NULL);
-      git_present = (exitstatus == 0);
-      git_tested = true;
-    }
-
-  if (git_present
+  if (is_git_present ()
       && git_vc_controlled (filename)
       && git_unmodified (filename))
     {
@@ -215,3 +238,804 @@ vc_mtime (struct timespec *mtime, const char *filename)
     }
   return -1;
 }
+
+/* ========================================================================== */
+
+/* Maximum length of a command that is guaranteed to work.  */
+#if defined _WIN32 || defined __CYGWIN__
+/* Windows */
+# define MAX_COMMAND_LENGTH 8192
+#else
+/* Unix platforms */
+# define MAX_COMMAND_LENGTH 32768
+#endif
+/* Keep some safe distance to this maximum.  */
+#define MAX_CMD_LEN ((int) (MAX_COMMAND_LENGTH * 0.8))
+
+/* Returns the directory name of the git checkout that contains tha current
+   directory, as an absolute file name, or NULL if the current directory is
+   not in a git checkout.  */
+static char *
+abs_git_checkout (void)
+{
+  /* Run "git rev-parse --show-toplevel 2>/dev/null" and return its output,
+     without the trailing newline.  */
+  const char *argv[4];
+  pid_t child;
+  int fd[1];
+
+  argv[0] = "git";
+  argv[1] = "rev-parse";
+  argv[2] = "--show-toplevel";
+  argv[3] = NULL;
+  child = create_pipe_in ("git", "git", argv, NULL, NULL,
+                          DEV_NULL, true, true, false, fd);
+
+  if (child == -1)
+    return NULL;
+
+  /* Retrieve its result.  */
+  FILE *fp = fdopen (fd[0], "r");
+  if (fp == NULL)
+    error (EXIT_FAILURE, errno, _("fdopen() failed"));
+
+  char *line = NULL;
+  size_t linesize = 0;
+  size_t linelen = getline (&line, &linesize, fp);
+  if (linelen == (size_t)(-1))
+    {
+      fclose (fp);
+      wait_subprocess (child, "git", true, true, true, false, NULL);
+      return NULL;
+    }
+  else
+    {
+      int exitstatus;
+
+      if (linelen > 0 && line[linelen - 1] == '\n')
+        line[linelen - 1] = '\0';
+
+      /* Read until EOF (otherwise the child process may get a SIGPIPE signal).  */
+      while (getc (fp) != EOF)
+        ;
+
+      fclose (fp);
+
+      /* Remove zombie process from process list, and retrieve exit status.  */
+      exitstatus =
+        wait_subprocess (child, "git", true, true, true, false, NULL);
+      if (exitstatus == 0)
+        return line;
+    }
+  free (line);
+  return NULL;
+}
+
+/* Given an absolute canonicalized directory DIR1 and an absolute canonicalized
+   directory DIR2, returns N where DIR1 = DIR2 "/.." ... "/.." with N times
+   "/..", or -1 if DIR1 is not an ancestor directory of DIR2.  */
+static long
+ancestor_level (const char *dir1, const char *dir2)
+{
+  if (strcmp (dir1, "/") == 0)
+    dir1 = "";
+  if (strcmp (dir2, "/") == 0)
+    dir2 = "";
+  size_t dir1_len = strlen (dir1);
+  if (strncmp (dir1, dir2, dir1_len) == 0)
+    {
+      /* DIR2 starts with DIR1.  */
+      const char *p = dir2 + dir1_len;
+      if (*p == '\0')
+        /* DIR2 and DIR1 are the same.  */
+        return 0;
+      if (ISSLASH (*p))
+        {
+          /* Return the number of slashes in the tail of DIR2 that starts
+             at P.  */
+          long n = 1;
+          p++;
+          for (; *p != '\0'; p++)
+            if (ISSLASH (*p))
+              n++;
+          return n;
+        }
+    }
+  return -1;
+}
+
+/* Given an absolute canolicalized FILENAME that starts with DIR1, returns the
+   same file name relative to DIR2, where DIR1 = DIR2 "/.." ... "/.." with
+   N times "/..", as a freshly allocated string.  */
+static char *
+relativize (const char *filename,
+            unsigned long n, const char *dir1, const char *dir2)
+{
+  if (strcmp (dir1, "/") == 0)
+    dir1 = "";
+  size_t dir1_len = strlen (dir1);
+  if (!(strncmp (filename, dir1, dir1_len) == 0
+        && (filename[dir1_len] == '\0' || ISSLASH (filename[dir1_len]))))
+    /* Invalid argument.  */
+    abort ();
+  if (strcmp (dir2, "/") == 0)
+    dir2 = "";
+
+  dir2 += dir1_len;
+  filename += dir1_len;
+  for (;;)
+    {
+      /* Invariant: The result will be N times "../" followed by FILENAME.  */
+      if (*filename == '\0')
+        break;
+      if (!ISSLASH (*filename))
+        abort ();
+      filename++;
+      if (*dir2 == '\0')
+        break;
+      if (!ISSLASH (*dir2))
+        abort ();
+      dir2++;
+      /* Skip one component in DIR2.  */
+      const char *dir2_s;
+      for (dir2_s = dir2; *dir2_s != '\0'; dir2_s++)
+        if (ISSLASH (*dir2_s))
+          break;
+      /* Skip one component in FILENAME, at P.  */
+      const char *filename_s;
+      for (filename_s = filename; *filename_s != '\0'; filename_s++)
+        if (ISSLASH (*filename_s))
+          break;
+      /* Did the components match?  */
+      if (!(filename_s - filename == dir2_s - dir2
+            && memcmp (filename, dir2, dir2_s - dir2) == 0))
+        break;
+      dir2 = dir2_s;
+      filename = filename_s;
+      n--;
+    }
+
+  if (n == 0 && *filename == '\0')
+    return xstrdup (".");
+
+  char *result = (char *) xmalloc (3 * n + strlen (filename) + 1);
+  {
+    char *q = result;
+    for (; n > 0; n--)
+      {
+        q[0] = '.'; q[1] = '.'; q[2] = '/'; q += 3;
+      }
+    strcpy (q, filename);
+  }
+  return result;
+}
+
+/* Accumulating mtimes.  */
+struct accumulator
+{
+  bool has_some_mtimes;
+  struct timespec max_of_mtimes;
+};
+
+static void
+accumulate (struct accumulator *accu, struct timespec mtime)
+{
+  if (accu->has_some_mtimes)
+    {
+      /* Compute the maximum of accu->max_of_mtimes and mtime.  */
+      if (accu->max_of_mtimes.tv_sec < mtime.tv_sec
+          || (accu->max_of_mtimes.tv_sec == mtime.tv_sec
+              && accu->max_of_mtimes.tv_nsec < mtime.tv_nsec))
+       accu->max_of_mtimes = mtime;
+    }
+  else
+    {
+      accu->max_of_mtimes = mtime;
+      accu->has_some_mtimes = true;
+    }
+}
+
+int
+max_vc_mtime (struct timespec *max_of_mtimes,
+              size_t nfiles, const char * const *filenames)
+{
+  if (nfiles == 0)
+    /* Invalid argument.  */
+    abort ();
+
+  struct accumulator accu = { false };
+
+  /* Determine which of the specified files are under version control,
+     and which are duplicates.  (The case of duplicates is rare, but it needs
+     special attention, because 'git ls-files' eliminates duplicates.)
+     vc_controlled[n] = 1 means that filenames[n] is under version control.
+     vc_controlled[n] = 0 means that filenames[n] is not under version control.
+     vc_controlled[n] = -1 means that filenames[n] is a duplicate.  */
+  signed char *vc_controlled = XNMALLOC (nfiles, signed char);
+  for (size_t n = 0; n < nfiles; n++)
+    vc_controlled[n] = 0;
+
+  if (is_git_present ())
+    {
+      /* Since 'git ls-files' produces an error when at least one of the files
+         is outside the git checkout that contains tha current directory, we
+         need to filter out such files.  This is most easily done by converting
+         each file name to a canonical file name first and then comparing with
+         the directory name of said git checkout.  */
+      char *git_checkout = abs_git_checkout ();
+      if (git_checkout != NULL)
+        {
+          char *currdir = xgetcwd ();
+          /* git_checkout is expected to be an ancestor directory of the
+             current directory.  */
+          long ancestor = ancestor_level (git_checkout, currdir);
+          if (ancestor >= 0)
+            {
+              char **canonical_filenames = XNMALLOC (nfiles, char *);
+              for (size_t n = 0; n < nfiles; n++)
+                {
+                  char *canonical = canonicalize_file_name (filenames[n]);
+                  if (canonical == NULL)
+                    {
+                      if (errno == ENOMEM)
+                        xalloc_die ();
+                      /* The file filenames[n] does not exist.  */
+                      for (size_t k = n; k > 0; )
+                        free (canonical_filenames[--k]);
+                      free (canonical_filenames);
+                      free (currdir);
+                      free (git_checkout);
+                      free (vc_controlled);
+                      return -1;
+                    }
+                  canonical_filenames[n] = canonical;
+                }
+
+              /* Test which of these absolute file names are outside of the
+                 git_checkout.  */
+              char *git_checkout_slash =
+                (strcmp (git_checkout, "/") == 0
+                 ? xstrdup (git_checkout)
+                 : xasprintf ("%s/", git_checkout));
+
+              char **checkout_relative_filenames = XNMALLOC (nfiles, char *);
+              char **currdir_relative_filenames = XNMALLOC (nfiles, char *);
+              for (size_t n = 0; n < nfiles; n++)
+                {
+                  if (str_startswith (canonical_filenames[n], git_checkout_slash))
+                    {
+                      vc_controlled[n] = 1;
+                      checkout_relative_filenames[n] =
+                        relativize (canonical_filenames[n],
+                                    0, git_checkout, git_checkout);
+                      currdir_relative_filenames[n] =
+                        relativize (canonical_filenames[n],
+                                    ancestor, git_checkout, currdir);
+                    }
+                  else
+                    {
+                      vc_controlled[n] = 0;
+                      checkout_relative_filenames[n] = NULL;
+                      currdir_relative_filenames[n] = NULL;
+                    }
+                }
+
+              /* Room for passing arguments to git commands.  */
+              const char **argv = XNMALLOC (6 + nfiles + 1, const char *);
+
+              {
+                /* Put the relative file names into a hash table.  This is needed
+                   because 'git ls-files' returns the files in a different order
+                   than the one we provide in the command.  */
+                gl_map_t relative_filenames_ht =
+                  gl_map_create_empty (GL_HASH_MAP,
+                                       hashkey_string_equals, hashkey_string_hash,
+                                       NULL, NULL);
+                for (size_t n = 0; n < nfiles; n++)
+                  if (currdir_relative_filenames[n] != NULL)
+                    {
+                      if (gl_map_get (relative_filenames_ht, currdir_relative_filenames[n]) != NULL)
+                        {
+                          /* It's already in the table.  */
+                          vc_controlled[n] = -1;
+                        }
+                      else
+                        gl_map_put (relative_filenames_ht, currdir_relative_filenames[n], &vc_controlled[n]);
+                    }
+
+                /* Run "git ls-files -c -o -t -z FILE1..." for as many files as
+                   possible, and inspect the output.  */
+                size_t n0 = 0;
+                do
+                  {
+                    size_t i = 0;
+                    argv[i++] = "git";
+                    argv[i++] = "ls-files";
+                    argv[i++] = "-c";
+                    argv[i++] = "-o";
+                    argv[i++] = "-t";
+                    argv[i++] = "-z";
+                    size_t i0 = i;
+
+                    size_t n = n0;
+                    size_t cmd_len = 25;
+                    for (; n < nfiles; n++)
+                      {
+                        if (vc_controlled[n] == 1)
+                          {
+                            if (cmd_len + strlen (currdir_relative_filenames[n]) >= MAX_CMD_LEN
+                                && i > i0)
+                              break;
+                            argv[i++] = currdir_relative_filenames[n];
+                            cmd_len += 1 + strlen (currdir_relative_filenames[n]);
+                          }
+                        n++;
+                      }
+                    if (i > i0)
+                      {
+                        pid_t child;
+                        int fd[1];
+
+                        argv[i] = NULL;
+                        child = create_pipe_in ("git", "git", argv, NULL, NULL,
+                                                DEV_NULL, true, true, false, fd);
+                        if (child == -1)
+                          break;
+
+                        /* Read the subprocess output.  It is expected to be of the form
+                             T1 <space> <currdir_relative_filename1> NUL
+                             T2 <space> <currdir_relative_filename2> NUL
+                             ...
+                           where the relative filenames correspond to the given file
+                           names (because we have already relativized them).  */
+                        FILE *fp = fdopen (fd[0], "r");
+                        if (fp == NULL)
+                          error (EXIT_FAILURE, errno, _("fdopen() failed"));
+
+                        char *fn = NULL;
+                        size_t fn_size = 0;
+                        for (;;)
+                          {
+                            int status = fgetc (fp);
+                            if (status == EOF)
+                              break;
+                            /* status is a status tag, as documented in
+                               "man git-ls-files".  */
+
+                            int space = fgetc (fp);
+                            if (space != ' ')
+                              {
+                                fprintf (stderr, "vc-mtime: git ls-files output not as expected\n");
+                                break;
+                              }
+
+                            if (getdelim (&fn, &fn_size, '\0', fp) == -1)
+                              {
+                                if (errno == ENOMEM)
+                                  xalloc_die ();
+                                fprintf (stderr, "vc-mtime: failed to read git ls-files output\n");
+                                break;
+                              }
+                            signed char *vc_controlled_p =
+                              (signed char *) gl_map_get (relative_filenames_ht, fn);
+                            if (vc_controlled_p == NULL)
+                              fprintf (stderr, "vc-mtime: git ls-files returned an unexpected file name: %s\n", fn);
+                            else
+                              *vc_controlled_p = (status == 'H' ? 1 : 0);
+                          }
+
+                        free (fn);
+                        fclose (fp);
+
+                        /* Remove zombie process from process list, and retrieve exit status.  */
+                        int exitstatus =
+                          wait_subprocess (child, "git", false, true, true, false, NULL);
+                        if (exitstatus != 0)
+                          fprintf (stderr, "vc-mtime: git ls-files failed with exit code %d\n", exitstatus);
+                      }
+                    n0 = n;
+                  }
+                while (n0 < nfiles);
+
+                gl_map_free (relative_filenames_ht);
+              }
+
+              {
+                /* Put the relative file names into a hash table.  This is needed
+                   because 'git diff' returns the files in a different order
+                   than the one we provide in the command.  */
+                gl_map_t relative_filenames_ht =
+                  gl_map_create_empty (GL_HASH_MAP,
+                                       hashkey_string_equals, hashkey_string_hash,
+                                       NULL, NULL);
+                for (size_t n = 0; n < nfiles; n++)
+                  if (vc_controlled[n] == 1)
+                    {
+                      /* No need to test for duplicates here.  We have already set
+                         vc_controlled[n] to -1 for duplicates, above.  */
+                      gl_map_put (relative_filenames_ht, checkout_relative_filenames[n], &vc_controlled[n]);
+                    }
+
+                /* Run "git diff --name-only --no-relative -z HEAD -- FILE1..." for
+                   as many files as possible, and inspect the output.  */
+                size_t n0 = 0;
+                do
+                  {
+                    size_t i = 0;
+                    argv[i++] = "git";
+                    argv[i++] = "diff";
+                    argv[i++] = "--name-only";
+                    argv[i++] = "--no-relative";
+                    argv[i++] = "-z";
+                    argv[i++] = "HEAD";
+                    argv[i++] = "--";
+                    size_t i0 = i;
+
+                    size_t n = n0;
+                    size_t cmd_len = 46;
+                    for (; n < nfiles; n++)
+                      {
+                        if (vc_controlled[n] == 1)
+                          {
+                            if (cmd_len + strlen (currdir_relative_filenames[n]) >= MAX_CMD_LEN
+                                && i > i0)
+                              break;
+                            argv[i++] = currdir_relative_filenames[n];
+                            cmd_len += 1 + strlen (currdir_relative_filenames[n]);
+                          }
+                        n++;
+                      }
+                    if (i > i0)
+                      {
+                        pid_t child;
+                        int fd[1];
+
+                        argv[i] = NULL;
+                        child = create_pipe_in ("git", "git", argv, NULL, NULL,
+                                                DEV_NULL, true, true, false, fd);
+                        if (child == -1)
+                          break;
+
+                        /* Read the subprocess output.  It is expected to be of the form
+                             <checkout_relative_filename1> NUL
+                             <checkout_relative_filename2> NUL
+                             ...
+                           where the relative filenames are relative to the git
+                           checkout dir, not to currdir!  */
+                        FILE *fp = fdopen (fd[0], "r");
+                        if (fp == NULL)
+                          error (EXIT_FAILURE, errno, _("fdopen() failed"));
+
+                        char *fn = NULL;
+                        size_t fn_size = 0;
+                        for (;;)
+                          {
+                            /* Test for EOF.  */
+                            int c = fgetc (fp);
+                            if (c == EOF)
+                              break;
+                            ungetc (c, fp);
+
+                            if (getdelim (&fn, &fn_size, '\0', fp) == -1)
+                              {
+                                if (errno == ENOMEM)
+                                  xalloc_die ();
+                                fprintf (stderr, "vc-mtime: failed to read git diff output\n");
+                                break;
+                              }
+                            signed char *vc_controlled_p =
+                              (signed char *) gl_map_get (relative_filenames_ht, fn);
+                            if (vc_controlled_p == NULL)
+                              fprintf (stderr, "vc-mtime: git diff returned an unexpected file name: %s\n", fn);
+                            else
+                              /* filenames[n] is under version control but is modified.
+                                 Treat it like a file not under version control.  */
+                              *vc_controlled_p = 0;
+                          }
+
+                        free (fn);
+                        fclose (fp);
+
+                        /* Remove zombie process from process list, and retrieve exit status.  */
+                        int exitstatus =
+                          wait_subprocess (child, "git", false, true, true, false, NULL);
+                        if (exitstatus != 0)
+                          fprintf (stderr, "vc-mtime: git diff failed with exit code %d\n", exitstatus);
+                      }
+                    n0 = n;
+                  }
+                while (n0 < nfiles);
+
+                gl_map_free (relative_filenames_ht);
+              }
+
+              {
+                /* Run "git log -1 --format=%ct -- FILE1...".  It prints the
+                   time of last modification (the 'CommitDate', not the
+                   'AuthorDate' which merely represents the time at which the
+                   author locally committed the first version of the change),
+                   as the number of seconds since the Epoch.  The '--' option
+                   is for the case that the specified file was removed.  */
+                size_t n0 = 0;
+                do
+                  {
+                    size_t i = 0;
+                    argv[i++] = "git";
+                    argv[i++] = "log";
+                    argv[i++] = "-1";
+                    argv[i++] = "--format=%ct";
+                    argv[i++] = "--";
+                    size_t i0 = i;
+
+                    size_t n = n0;
+                    size_t cmd_len = 27;
+                    for (; n < nfiles; n++)
+                      {
+                        if (vc_controlled[n] == 1)
+                          {
+                            if (cmd_len + strlen (currdir_relative_filenames[n]) >= MAX_CMD_LEN
+                                && i > i0)
+                              break;
+                            argv[i++] = currdir_relative_filenames[n];
+                            cmd_len += 1 + strlen (currdir_relative_filenames[n]);
+                          }
+                        n++;
+                      }
+                    if (i > i0)
+                      {
+                        pid_t child;
+                        int fd[1];
+
+                        argv[i] = NULL;
+                        child = create_pipe_in ("git", "git", argv, NULL, NULL,
+                                                DEV_NULL, true, true, false, fd);
+                        if (child == -1)
+                          break;
+
+                        /* Read the subprocess output.  It is expected to be a
+                           single line, containing a positive integer.  */
+                        FILE *fp = fdopen (fd[0], "r");
+                        if (fp == NULL)
+                          error (EXIT_FAILURE, errno, _("fdopen() failed"));
+
+                        char *line = NULL;
+                        size_t linesize = 0;
+                        size_t linelen = getline (&line, &linesize, fp);
+                        if (linelen == (size_t)(-1))
+                          {
+                            if (errno == ENOMEM)
+                              xalloc_die ();
+                            fprintf (stderr, "vc-mtime: failed to read git log output\n");
+                           git_log_fail1:
+                            free (line);
+                            fclose (fp);
+                            wait_subprocess (child, "git", true, false, true, false, NULL);
+                           git_log_fail2:
+                            free (argv);
+                            for (size_t k = nfiles; k > 0; )
+                              free (currdir_relative_filenames[--k]);
+                            free (currdir_relative_filenames);
+                            for (size_t k = nfiles; k > 0; )
+                              free (checkout_relative_filenames[--k]);
+                            free (checkout_relative_filenames);
+                            free (git_checkout_slash);
+                            for (size_t k = nfiles; k > 0; )
+                              free (canonical_filenames[--k]);
+                            free (canonical_filenames);
+                            free (currdir);
+                            free (git_checkout);
+                            free (vc_controlled);
+                            return -1;
+                          }
+                        if (linelen > 0 && line[linelen - 1] == '\n')
+                          line[linelen - 1] = '\0';
+
+                        char *endptr;
+                        unsigned long git_log_time;
+                        if (!(xstrtoul (line, &endptr, 10, &git_log_time, NULL) == LONGINT_OK
+                              && endptr == line + strlen (line)))
+                          {
+                            fprintf (stderr, "vc-mtime: git log output not as expected\n");
+                            goto git_log_fail1;
+                          }
+
+                        struct timespec mtime;
+                        mtime.tv_sec = git_log_time;
+                        mtime.tv_nsec = 0;
+                        accumulate (&accu, mtime);
+
+                        free (line);
+                        fclose (fp);
+
+                        /* Remove zombie process from process list, and retrieve exit status.  */
+                        int exitstatus =
+                          wait_subprocess (child, "git", false, true, true, false, NULL);
+                        if (exitstatus != 0)
+                          {
+                            fprintf (stderr, "vc-mtime: git log failed with exit code %d\n", exitstatus);
+                            goto git_log_fail2;
+                          }
+                      }
+                    n0 = n;
+                  }
+                while (n0 < nfiles);
+              }
+
+              free (argv);
+              for (size_t k = nfiles; k > 0; )
+                free (currdir_relative_filenames[--k]);
+              free (currdir_relative_filenames);
+              for (size_t k = nfiles; k > 0; )
+                free (checkout_relative_filenames[--k]);
+              free (checkout_relative_filenames);
+              free (git_checkout_slash);
+              for (size_t k = nfiles; k > 0; )
+                free (canonical_filenames[--k]);
+              free (canonical_filenames);
+            }
+          free (currdir);
+        }
+      free (git_checkout);
+    }
+
+  /* For the files that are not under version control, or that are modified
+     compared to HEAD, use the file's time stamp.  */
+  for (size_t n = 0; n < nfiles; n++)
+    if (vc_controlled[n] == 0)
+      {
+        struct stat statbuf;
+        if (stat (filenames[n], &statbuf) < 0)
+          {
+            free (vc_controlled);
+            return -1;
+          }
+
+        struct timespec mtime = get_stat_mtime (&statbuf);
+        accumulate (&accu, mtime);
+      }
+
+  free (vc_controlled);
+
+  /* Since nfiles > 0, we must have accumulated at least one mtime.  */
+  if (!accu.has_some_mtimes)
+    abort ();
+  *max_of_mtimes = accu.max_of_mtimes;
+  return 0;
+}
+
+/* ========================================================================== */
+
+#ifdef TEST
+
+#include <assert.h>
+#include <stdio.h>
+#include <time.h>
+
+/* Some unit tests for internal functions.  */
+
+static void
+test_ancestor_level (void)
+{
+  assert (ancestor_level ("/home/user/projects/gnulib", "/home/user/projects/gnulib") == 0);
+  assert (ancestor_level ("/", "/") == 0);
+
+  assert (ancestor_level ("/home/user/projects/gnulib", "/home/user/projects/gnulib/lib/crypto") == 2);
+  assert (ancestor_level ("/", "/home/user") == 2);
+
+  assert (ancestor_level ("/home/user/.local", "/home/user/projects/gnulib") == -1);
+  assert (ancestor_level ("/.local", "/home/user") == -1);
+  assert (ancestor_level ("/.local", "/") == -1);
+}
+
+static void
+test_relativize (void)
+{
+  assert (strcmp (relativize ("/home/user/projects/gnulib",
+                              0, "/home/user/projects/gnulib", "/home/user/projects/gnulib"),
+                  ".") == 0);
+  assert (strcmp (relativize ("/home/user/projects/gnulib/NEWS",
+                              0, "/home/user/projects/gnulib", "/home/user/projects/gnulib"),
+                  "NEWS") == 0);
+  assert (strcmp (relativize ("/home/user/projects/gnulib/doc/Makefile",
+                              0, "/home/user/projects/gnulib", "/home/user/projects/gnulib"),
+                  "doc/Makefile") == 0);
+
+  assert (strcmp (relativize ("/",
+                              0, "/", "/"),
+                  ".") == 0);
+  assert (strcmp (relativize ("/swapfile",
+                              0, "/", "/"),
+                  "swapfile") == 0);
+  assert (strcmp (relativize ("/etc/passwd",
+                              0, "/", "/"),
+                  "etc/passwd") == 0);
+
+  assert (strcmp (relativize ("/home/user/projects/gnulib",
+                              2, "/home/user/projects/gnulib", "/home/user/projects/gnulib/lib/crypto"),
+                  "../../") == 0);
+  assert (strcmp (relativize ("/home/user/projects/gnulib/lib",
+                              2, "/home/user/projects/gnulib", "/home/user/projects/gnulib/lib/crypto"),
+                  "../") == 0);
+  assert (strcmp (relativize ("/home/user/projects/gnulib/lib/crypto",
+                              2, "/home/user/projects/gnulib", "/home/user/projects/gnulib/lib/crypto"),
+                  ".") == 0);
+  assert (strcmp (relativize ("/home/user/projects/gnulib/lib/malloc",
+                              2, "/home/user/projects/gnulib", "/home/user/projects/gnulib/lib/crypto"),
+                  "../malloc") == 0);
+  assert (strcmp (relativize ("/home/user/projects/gnulib/lib/cr",
+                              2, "/home/user/projects/gnulib", "/home/user/projects/gnulib/lib/crypto"),
+                  "../cr") == 0);
+  assert (strcmp (relativize ("/home/user/projects/gnulib/lib/cryptography",
+                              2, "/home/user/projects/gnulib", "/home/user/projects/gnulib/lib/crypto"),
+                  "../cryptography") == 0);
+  assert (strcmp (relativize ("/home/user/projects/gnulib/doc",
+                              2, "/home/user/projects/gnulib", "/home/user/projects/gnulib/lib/crypto"),
+                  "../../doc") == 0);
+  assert (strcmp (relativize ("/home/user/projects/gnulib/doc/Makefile",
+                              2, "/home/user/projects/gnulib", "/home/user/projects/gnulib/lib/crypto"),
+                  "../../doc/Makefile") == 0);
+
+  assert (strcmp (relativize ("/",
+                              2, "/", "/home/user"),
+                  "../../") == 0);
+  assert (strcmp (relativize ("/home",
+                              2, "/", "/home/user"),
+                  "../") == 0);
+  assert (strcmp (relativize ("/home/user",
+                              2, "/", "/home/user"),
+                  ".") == 0);
+  assert (strcmp (relativize ("/home/root",
+                              2, "/", "/home/user"),
+                  "../root") == 0);
+  assert (strcmp (relativize ("/home/us",
+                              2, "/", "/home/user"),
+                  "../us") == 0);
+  assert (strcmp (relativize ("/home/users",
+                              2, "/", "/home/user"),
+                  "../users") == 0);
+  assert (strcmp (relativize ("/etc",
+                              2, "/", "/home/user"),
+                  "../../etc") == 0);
+  assert (strcmp (relativize ("/etc/passwd",
+                              2, "/", "/home/user"),
+                  "../../etc/passwd") == 0);
+}
+
+/* Usage: ./a.out FILE[...]
+ */
+int
+main (int argc, char *argv[])
+{
+  test_ancestor_level ();
+  test_relativize ();
+
+  if (argc == 1)
+    {
+      fprintf (stderr, "Usage: ./a.out FILE[...]\n");
+      return 1;
+    }
+  struct timespec mtime;
+  int ret = max_vc_mtime (&mtime, argc - 1, (const char **) argv + 1);
+  if (ret == 0)
+    {
+      time_t t = mtime.tv_sec;
+      struct tm *gmt = gmtime (&t);
+      printf ("mtime = %04d-%02d-%02d %02d:%02d:%02d UTC\n",
+              gmt->tm_year + 1900, gmt->tm_mon + 1, gmt->tm_mday,
+              gmt->tm_hour, gmt->tm_min, gmt->tm_sec);
+      return 0;
+    }
+  else
+    {
+      printf ("failed\n");
+      return 1;
+    }
+}
+
+/*
+ * Local Variables:
+ * compile-command: "gcc -ggdb -DTEST -Wall -I. -I.. vc-mtime.c libgnu.a"
+ * End:
+ */
+
+#endif
index b6d04c9db06e1eb634e9bcba83ec439a11809635..a13c909ab576ced807b60d55b29f35af8dcd3e88 100644 (file)
@@ -90,6 +90,13 @@ extern "C" {
    Upon failure, it returns -1.  */
 extern int vc_mtime (struct timespec *mtime, const char *filename);
 
+/* Determines the maximum of the version-controlled modification times of
+   FILENAMES[0..NFILES-1], and returns 0.
+   Upon failure, it returns -1.
+   NFILES must be > 0.  */
+extern int max_vc_mtime (struct timespec *max_of_mtimes,
+                         size_t nfiles, const char * const *filenames);
+
 #ifdef __cplusplus
 }
 #endif
index dc5de043c629744cfd5c702bcc5e7f06ce692ce6..207e62781c348c6e7e14293c7d9a817749a2539c 100644 (file)
@@ -16,6 +16,17 @@ error
 getline
 xstrtol
 stat-time
+filename
+xalloc
+xgetcwd
+canonicalize-lgpl
+xvasprintf
+str_startswith
+map
+xmap
+hash-map
+hashkey-string
+getdelim
 gettext-h
 gnulib-i18n