1#! /bin/sh
2#
3# Copyright (C) 2019-2021 Free Software Foundation, Inc.
4# Written by Bruno Haible <bruno@clisp.org>, 2019.
5#
6# This program is free software: you can redistribute it and/or modify
7# it under the terms of the GNU General Public License as published by
8# the Free Software Foundation; either version 3 of the License, or
9# (at your option) any later version.
10#
11# This program is distributed in the hope that it will be useful,
12# but WITHOUT ANY WARRANTY; without even the implied warranty of
13# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14# GNU General Public License for more details.
15#
16# You should have received a copy of the GNU General Public License
17# along with this program.  If not, see <https://www.gnu.org/licenses/>.
18
19# Program that manages the subdirectories of a git checkout of a package
20# that come from other packages (called "dependency packages").
21#
22# This program is similar in spirit to 'git submodule', with three
23# essential differences:
24#
25#   1) Its options are easy to remember, and do not require knowledge of
26#      'git submodule'.
27#
28#   2) The developer may choose to work on a different checkout for each
29#      dependency package.  This is important when the developer is
30#      preparing simultaneous changes to the package and the dependency
31#      package, or is using the dependency package in several packages.
32#
33#      The developer indicates this different checkout by setting the
34#      environment variable <SUBDIR>_SRCDIR (e.g. GNULIB_SRCDIR) to point to it.
35#
36#   3) The package maintainer may choose to use or not use git submodules.
37#
38#      The advantages of management through a git submodule are:
39#        - Changes to the dependency package cannot suddenly break your package.
40#          In other words, when there is an incompatible change that will cause
41#          a breakage, you can fix things at your pace; you are not forced to
42#          cope with such breakages in an emergency.
43#        - When you need to make a change as a response to a change in the
44#          dependency package, your co-developers cannot accidentally mix things
45#          up (for example, use a combination of your newest change with an
46#          older version of the dependency package).
47#
48#      The advantages of management without a git submodule (just as a plain
49#      subdirectory, let's call it a "subcheckout") are:
50#        - The simplicity: you are conceptually always using the newest revision
51#          of the dependency package.
52#        - You don't have to remember to periodially upgrade the dependency.
53#          Upgrading the dependency is an implicit operation.
54
55# This program is meant to be copied to the top-level directory of the package,
56# together with a configuration file.  The configuration is supposed to be
57# named '.gitmodules' and to define:
58# * The git submodules, as described in "man 5 gitmodules" or
59#   <https://git-scm.com/docs/gitmodules>.  For example:
60#
61#       [submodule "gnulib"]
62#               url = https://git.savannah.gnu.org/git/gnulib.git
63#               path = gnulib
64#
65#   You don't add this piece of configuration to .gitmodules manually.  Instead,
66#   you would invoke
67#     $ git submodule add --name "gnulib" -- https://git.savannah.gnu.org/git/gnulib.git gnulib
68#
69# * The subdirectories that are not git submodules, in a similar syntax.  For
70#   example:
71#
72#       [subcheckout "gnulib"]
73#               url = https://git.savannah.gnu.org/git/gnulib.git
74#               path = gnulib
75#
76# Here the URL is the one used for anonymous checkouts of the dependency
77# package. If the developer needs a checkout with write access, they can
78# either set the GNULIB_SRCDIR environment variable to point to that checkout
79# or modify the gnulib/.git/config file to enter a different URL.
80
81scriptname="$0"
82scriptversion='2019-04-01'
83nl='
84'
85IFS=" ""	$nl"
86
87# func_usage
88# outputs to stdout the --help usage message.
89func_usage ()
90{
91  echo "\
92Usage: gitsub.sh pull [SUBDIR]
93       gitsub.sh upgrade [SUBDIR]
94       gitsub.sh checkout SUBDIR REVISION
95
96Operations:
97
98gitsub.sh pull [GIT_OPTIONS] [SUBDIR]
99        You should perform this operation after 'git clone ...' and after
100        every 'git pull'.
101        It brings your checkout in sync with what the other developers of
102        your package have committed and pushed.
103        If an environment variable <SUBDIR>_SRCDIR is set, with a non-empty
104        value, nothing is done for this SUBDIR.
105        Supported GIT_OPTIONS (for expert git users) are:
106          --reference <repository>
107          --depth <depth>
108          --recursive
109        If no SUBDIR is specified, the operation applies to all dependencies.
110
111gitsub.sh upgrade [SUBDIR]
112        You should perform this operation periodically, to ensure currency
113        of the dependency package revisions that you use.
114        This operation pulls and checks out the changes that the developers
115        of the dependency package have committed and pushed.
116        If an environment variable <SUBDIR>_SRCDIR is set, with a non-empty
117        value, nothing is done for this SUBDIR.
118        If no SUBDIR is specified, the operation applies to all dependencies.
119
120gitsub.sh checkout SUBDIR REVISION
121        Checks out a specific revision for a dependency package.
122        If an environment variable <SUBDIR>_SRCDIR is set, with a non-empty
123        value, this operation fails.
124
125This script requires the git program in the PATH and an internet connection.
126"
127}
128
129# func_version
130# outputs to stdout the --version message.
131func_version ()
132{
133  year=`echo "$scriptversion" | sed -e 's/^\(....\)-.*/\1/'`
134  echo "\
135gitsub.sh (GNU gnulib) $scriptversion
136Copyright (C) 2019-$year Free Software Foundation, Inc.
137License GPLv3+: GNU GPL version 3 or later <https://gnu.org/licenses/gpl.html>
138This is free software: you are free to change and redistribute it.
139There is NO WARRANTY, to the extent permitted by law.
140"
141  printf "Written by %s.\n" "Bruno Haible"
142}
143
144# func_fatal_error message
145# outputs to stderr a fatal error message, and terminates the program.
146# Input:
147# - scriptname               name of this program
148func_fatal_error ()
149{
150  echo "$scriptname: *** $1" 1>&2
151  echo "$scriptname: *** Stop." 1>&2
152  exit 1
153}
154
155# func_warning message
156# Outputs to stderr a warning message,
157func_warning ()
158{
159  echo "gitsub.sh: warning: $1" 1>&2
160}
161
162# func_note message
163# Outputs to stdout a note message,
164func_note ()
165{
166  echo "gitsub.sh: note: $1"
167}
168
169# Unset CDPATH.  Otherwise, output from 'cd dir' can surprise callers.
170(unset CDPATH) >/dev/null 2>&1 && unset CDPATH
171
172# Command-line option processing.
173mode=
174while test $# -gt 0; do
175  case "$1" in
176    --help | --hel | --he | --h )
177      func_usage
178      exit $? ;;
179    --version | --versio | --versi | --vers | --ver | --ve | --v )
180      func_version
181      exit $? ;;
182    -- )
183      # Stop option processing
184      shift
185      break ;;
186    -* )
187      echo "gitsub.sh: unknown option $1" 1>&2
188      echo "Try 'gitsub.sh --help' for more information." 1>&2
189      exit 1 ;;
190    * )
191      break ;;
192  esac
193done
194if test $# = 0; then
195  echo "gitsub.sh: missing operation argument" 1>&2
196  echo "Try 'gitsub.sh --help' for more information." 1>&2
197  exit 1
198fi
199case "$1" in
200  pull | upgrade | checkout )
201    mode="$1"
202    shift ;;
203  *)
204    echo "gitsub.sh: unknown operation '$1'" 1>&2
205    echo "Try 'gitsub.sh --help' for more information." 1>&2
206    exit 1 ;;
207esac
208if { test $mode = upgrade && test $# -gt 1; } \
209   || { test $mode = checkout && test $# -gt 2; }; then
210  echo "gitsub.sh: too many arguments in '$mode' mode" 1>&2
211  echo "Try 'gitsub.sh --help' for more information." 1>&2
212  exit 1
213fi
214if test $# = 0 && test $mode = checkout; then
215  echo "gitsub.sh: too few arguments in '$mode' mode" 1>&2
216  echo "Try 'gitsub.sh --help' for more information." 1>&2
217  exit 1
218fi
219
220# Read the configuration.
221# Output:
222# - subcheckout_names        space-separated list of subcheckout names
223# - submodule_names          space-separated list of submodule names
224if test -f .gitmodules; then
225  subcheckout_names=`git config --file .gitmodules --get-regexp --name-only 'subcheckout\..*\.url' | sed -e 's/^subcheckout\.//' -e 's/\.url$//' | tr -d '\r' | tr '\n' ' '`
226  submodule_names=`git config --file .gitmodules --get-regexp --name-only 'submodule\..*\.url' | sed -e 's/^submodule\.//' -e 's/\.url$//' | tr -d '\r' | tr '\n' ' '`
227else
228  subcheckout_names=
229  submodule_names=
230fi
231
232# func_validate SUBDIR
233# Verifies that the state on the file system is in sync with the declarations
234# in the configuration file.
235# Input:
236# - subcheckout_names        space-separated list of subcheckout names
237# - submodule_names          space-separated list of submodule names
238# Output:
239# - srcdirvar                Environment that the user can set
240# - srcdir                   Value of the environment variable
241# - path                     if $srcdir = "": relative path of the subdirectory
242# - needs_init               if $srcdir = "" and $path is not yet initialized:
243#                            true
244# - url                      if $srcdir = "" and $path is not yet initialized:
245#                            the repository URL
246func_validate ()
247{
248  srcdirvar=`echo "$1" | LC_ALL=C sed -e 's/[^a-zA-Z0-9]/_/g' | LC_ALL=C tr '[a-z]' '[A-Z]'`"_SRCDIR"
249  eval 'srcdir=$'"$srcdirvar"
250  path=
251  url=
252  if test -n "$srcdir"; then
253    func_note "Ignoring '$1' because $srcdirvar is set."
254  else
255    found=false
256    needs_init=
257    case " $subcheckout_names " in *" $1 "*)
258      found=true
259      # It ought to be a subcheckout.
260      path=`git config --file .gitmodules "subcheckout.$1.path"`
261      if test -z "$path"; then
262        path="$1"
263      fi
264      if test -d "$path"; then
265        if test -d "$path/.git"; then
266          # It's a plain checkout.
267          :
268        else
269          if test -f "$path/.git"; then
270            # It's a submodule.
271            func_fatal_error "Subdirectory '$path' is supposed to be a plain checkout, but it is a submodule."
272          else
273            func_warning "Ignoring '$path' because it exists but is not a git checkout."
274          fi
275        fi
276      else
277        # The subdir does not yet exist.
278        needs_init=true
279        url=`git config --file .gitmodules "subcheckout.$1.url"`
280        if test -z "$url"; then
281          func_fatal_error "Property subcheckout.$1.url is not defined in .gitmodules"
282        fi
283      fi
284      ;;
285    esac
286    case " $submodule_names " in *" $1 "*)
287      found=true
288      # It ought to be a submodule.
289      path=`git config --file .gitmodules "submodule.$1.path"`
290      if test -z "$path"; then
291        path="$1"
292      fi
293      if test -d "$path"; then
294        if test -d "$path/.git" || test -f "$path/.git"; then
295          # It's likely a submodule.
296          :
297        else
298          path_if_empty=`find "$path" -prune -empty 2>/dev/null`
299          if test -n "$path_if_empty"; then
300            # The subdir is empty.
301            needs_init=true
302          else
303            # The subdir is not empty.
304            # It is important to report an error, because we don't want to erase
305            # the user's files and 'git submodule update gnulib' sometimes reports
306            # "fatal: destination path '$path' already exists and is not an empty directory."
307            # but sometimes does not.
308            func_fatal_error "Subdir '$path' exists but is not a git checkout."
309          fi
310        fi
311      else
312        # The subdir does not yet exist.
313        needs_init=true
314      fi
315      # Another way to determine needs_init could be:
316      # if git submodule status "$path" | grep '^-' > /dev/null; then
317      #   needs_init=true
318      # fi
319      if test -n "$needs_init"; then
320        url=`git config --file .gitmodules "submodule.$1.url"`
321        if test -z "$url"; then
322          func_fatal_error "Property submodule.$1.url is not defined in .gitmodules"
323        fi
324      fi
325      ;;
326    esac
327    if ! $found; then
328      func_fatal_error "Subdir '$1' is not configured as a subcheckout or a submodule in .gitmodules"
329    fi
330  fi
331}
332
333# func_cleanup_current_git_clone
334# Cleans up the current 'git clone' operation.
335# Input:
336# - path
337func_cleanup_current_git_clone ()
338{
339  rm -rf "$path"
340  func_fatal_error "git clone failed"
341}
342
343# func_pull SUBDIR GIT_OPTIONS
344# Implements the 'pull' operation.
345func_pull ()
346{
347  func_validate "$1"
348  if test -z "$srcdir"; then
349    case " $subcheckout_names " in *" $1 "*)
350      # It's a subcheckout.
351      if test -d "$path"; then
352        if test -d "$path/.git"; then
353          (cd "$path" && git pull) || func_fatal_error "git operation failed"
354        fi
355      else
356        # The subdir does not yet exist. Create a plain checkout.
357        trap func_cleanup_current_git_clone 1 2 13 15
358        git clone $2 "$url" "$path" || func_cleanup_current_git_clone
359        trap - 1 2 13 15
360      fi
361      ;;
362    esac
363    case " $submodule_names " in *" $1 "*)
364      # It's a submodule.
365      if test -n "$needs_init"; then
366        # Create a submodule checkout.
367        git submodule init -- "$path" && git submodule update $2 -- "$path" || func_fatal_error "git operation failed"
368      else
369        # See https://stackoverflow.com/questions/1030169/easy-way-to-pull-latest-of-all-git-submodules
370        # https://stackoverflow.com/questions/4611512/is-there-a-way-to-make-git-pull-automatically-update-submodules
371        git submodule update "$path" || func_fatal_error "git operation failed"
372      fi
373      ;;
374    esac
375  fi
376}
377
378# func_upgrade SUBDIR
379# Implements the 'upgrade' operation.
380func_upgrade ()
381{
382  func_validate "$1"
383  if test -z "$srcdir"; then
384    if test -d "$path"; then
385      case " $subcheckout_names " in *" $1 "*)
386        # It's a subcheckout.
387        if test -d "$path/.git"; then
388          (cd "$path" && git pull) || func_fatal_error "git operation failed"
389        fi
390        ;;
391      esac
392      case " $submodule_names " in *" $1 "*)
393        # It's a submodule.
394        if test -z "$needs_init"; then
395          (cd "$path" && git fetch && git merge origin/master) || func_fatal_error "git operation failed"
396        fi
397        ;;
398      esac
399    else
400      # The subdir does not yet exist.
401      func_fatal_error "Subdirectory '$path' does not exist yet. Use 'gitsub.sh pull' to create it."
402    fi
403  fi
404}
405
406# func_checkout SUBDIR REVISION
407# Implements the 'checkout' operation.
408func_checkout ()
409{
410  func_validate "$1"
411  if test -z "$srcdir"; then
412    if test -d "$path"; then
413      case " $subcheckout_names " in *" $1 "*)
414        # It's a subcheckout.
415        if test -d "$path/.git"; then
416          (cd "$path" && git checkout "$2") || func_fatal_error "git operation failed"
417        fi
418        ;;
419      esac
420      case " $submodule_names " in *" $1 "*)
421        # It's a submodule.
422        if test -z "$needs_init"; then
423          (cd "$path" && git checkout "$2") || func_fatal_error "git operation failed"
424        fi
425        ;;
426      esac
427    else
428      # The subdir does not yet exist.
429      func_fatal_error "Subdirectory '$path' does not exist yet. Use 'gitsub.sh pull' to create it."
430    fi
431  fi
432}
433
434case "$mode" in
435  pull )
436    git_options=""
437    while test $# -gt 0; do
438      case "$1" in
439        --reference=* | --depth=* | --recursive)
440          git_options="$git_options $1"
441          shift
442          ;;
443        --reference | --depth)
444          git_options="$git_options $1 $2"
445          shift; shift
446          ;;
447        *)
448          break
449          ;;
450      esac
451    done
452    if test $# -gt 1; then
453      echo "gitsub.sh: too many arguments in '$mode' mode" 1>&2
454      echo "Try 'gitsub.sh --help' for more information." 1>&2
455      exit 1
456    fi
457    if test $# = 0; then
458      for sub in $subcheckout_names $submodule_names; do
459        func_pull "$sub" "$git_options"
460      done
461    else
462      valid=false
463      for sub in $subcheckout_names $submodule_names; do
464        if test "$sub" = "$1"; then
465          valid=true
466        fi
467      done
468      if $valid; then
469        func_pull "$1" "$git_options"
470      else
471        func_fatal_error "Subdir '$1' is not configured as a subcheckout or a submodule in .gitmodules"
472      fi
473    fi
474    ;;
475
476  upgrade )
477    if test $# = 0; then
478      for sub in $subcheckout_names $submodule_names; do
479        func_upgrade "$sub"
480      done
481    else
482      valid=false
483      for sub in $subcheckout_names $submodule_names; do
484        if test "$sub" = "$1"; then
485          valid=true
486        fi
487      done
488      if $valid; then
489        func_upgrade "$1"
490      else
491        func_fatal_error "Subdir '$1' is not configured as a subcheckout or a submodule in .gitmodules"
492      fi
493    fi
494    ;;
495
496  checkout )
497    valid=false
498    for sub in $subcheckout_names $submodule_names; do
499      if test "$sub" = "$1"; then
500        valid=true
501      fi
502    done
503    if $valid; then
504      func_checkout "$1" "$2"
505    else
506      func_fatal_error "Subdir '$1' is not configured as a subcheckout or a submodule in .gitmodules"
507    fi
508    ;;
509esac
510